@jrpool/kilotest 50.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/requestFeature.test.ts +81 -3
- package/api/requestFeature.ts +46 -10
- package/api/requestRetest.test.ts +7 -0
- package/api/requestTest.test.ts +7 -0
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +17 -0
- package/docs/SERVICE.md +18 -5
- package/index.test.ts +62 -0
- package/index.ts +27 -2
- package/linkValidation.test.ts +1 -0
- package/package.json +1 -1
- package/robots.txt +1 -0
- package/smokeTest.ts +10 -5
- package/util.test.ts +14 -13
- package/util.ts +11 -16
- package/web/deleteNotesForm/deleteNotesForm.test.ts +237 -0
- package/web/deleteNotesForm/index.html +29 -0
- package/web/deleteNotesForm/index.ts +167 -0
- package/web/manage/index.html +1 -0
- package/web/tutorialAI/index.ts +6 -11
- package/web/tutorialAI/tutorialAI.test.ts +9 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +8 -2
- package/.claude/scheduled_tasks.lock +0 -1
|
@@ -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
|
|
@@ -72,3 +89,64 @@ test('requestFeature includes tool name and metadata', async () => {
|
|
|
72
89
|
assert.equal(body['tool name'], 'requestFeature');
|
|
73
90
|
assert.ok(body['response metadata'].identifier);
|
|
74
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,14 +8,20 @@
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
10
|
import {sendAlert} from '../alerts.ts';
|
|
11
|
-
import {checkLength} from '../util.ts';
|
|
11
|
+
import {checkCommentDuplicate, checkLength, getJSON, getNowStamp} from '../util.ts';
|
|
12
12
|
import {requestFeatureResponseSchema} from './schemas.ts';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
13
15
|
|
|
14
16
|
// TYPES
|
|
15
17
|
|
|
16
18
|
// The response content defined by the response schema.
|
|
17
19
|
type ResponseContent = z.infer<typeof requestFeatureResponseSchema>['response content'];
|
|
18
20
|
|
|
21
|
+
// FUNCTIONS (helpers)
|
|
22
|
+
|
|
23
|
+
const getFeatureRequestsPath = () => process.env.FEATURE_REQUESTS_PATH || path.join(import.meta.dirname, '../db/featureRequests.json');
|
|
24
|
+
|
|
19
25
|
// FUNCTIONS
|
|
20
26
|
|
|
21
27
|
// Returns the response body.
|
|
@@ -35,15 +41,45 @@ export const response = async (args: string[]) => {
|
|
|
35
41
|
}
|
|
36
42
|
// Otherwise, i.e. if it is valid:
|
|
37
43
|
else {
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
|
47
83
|
}
|
|
48
84
|
// Create a response body.
|
|
49
85
|
const body = {
|
|
@@ -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 ./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;
|
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/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/SERVICE.md
CHANGED
|
@@ -256,11 +256,18 @@ kilotest.com {
|
|
|
256
256
|
}
|
|
257
257
|
# Enable Zstandard and Gzip compression of responses.
|
|
258
258
|
encode zstd gzip
|
|
259
|
-
# Specify the only paths of forwardable requests.
|
|
259
|
+
# Specify the only paths of forwardable requests.
|
|
260
260
|
@allowedGET {
|
|
261
|
-
method GET
|
|
261
|
+
method GET
|
|
262
262
|
path /mcp / /index.html /robots.txt /openapi.yaml /openapi.json /swagger.yaml /swagger.json /api-docs /llms.txt /llms-full.txt /capability.md *.html* /fullReport.json/* /api/* /tutorialWeb/images/* /tutorialAI/images/* /qai /qai/comments /favicon.* /style.css /sitemap.xml
|
|
263
263
|
}
|
|
264
|
+
# UptimeRobot's uptime check is the only known HEAD caller, and it targets only the site
|
|
265
|
+
# root (see "Health monitoring" below), so HEAD is forwarded only for that path, not the
|
|
266
|
+
# full GET allowlist above. index.ts's HEAD branch matches this same scope.
|
|
267
|
+
@allowedHEAD {
|
|
268
|
+
method HEAD
|
|
269
|
+
path / /index.html
|
|
270
|
+
}
|
|
264
271
|
@allowedPOST {
|
|
265
272
|
method POST
|
|
266
273
|
path /mcp /requestRetest.html/* /requestTest.html /requestAction.html /reannotate.html /renewWCAG.html /ai0BalanceForm.html /expungeReportsForm.html /hideReportForm.html /metrics.html /pruneReportsForm.html /rewindReportsForm.html /showHiddenReportsForm.html /unhideReportForm.html /api/* /tutorialWebComment.html /tutorialAIComment.html /worker/job /worker/report
|
|
@@ -275,13 +282,19 @@ kilotest.com {
|
|
|
275
282
|
}
|
|
276
283
|
respond 204
|
|
277
284
|
}
|
|
278
|
-
# Forward any other GET,
|
|
285
|
+
# Forward any other GET, HEAD, or POST request, if allowed, to port 3000.
|
|
279
286
|
handle @allowedGET {
|
|
280
287
|
reverse_proxy localhost:3000 {
|
|
281
288
|
# Improve SSE latency.
|
|
282
289
|
flush_interval -1
|
|
283
290
|
}
|
|
284
291
|
}
|
|
292
|
+
handle @allowedHEAD {
|
|
293
|
+
reverse_proxy localhost:3000 {
|
|
294
|
+
# Improve SSE latency.
|
|
295
|
+
flush_interval -1
|
|
296
|
+
}
|
|
297
|
+
}
|
|
285
298
|
handle @allowedPOST {
|
|
286
299
|
reverse_proxy localhost:3000 {
|
|
287
300
|
# Improve SSE latency.
|
|
@@ -362,7 +375,7 @@ The deployment is monitored for external health and availability using [UptimeRo
|
|
|
362
375
|
|
|
363
376
|
UptimeRobot also sends a recovery message when the service recovers and a subsequent check succeeds after a prior failure.
|
|
364
377
|
|
|
365
|
-
This monitoring service requires `Caddyfile` to permit `HEAD` requests, not only `GET` requests, to the root path.
|
|
378
|
+
This monitoring service requires both `Caddyfile` and Kilotest itself to permit `HEAD` requests, not only `GET` requests, to the root path: `Caddyfile`’s `@allowedHEAD` matcher forwards such a request to port 3000, and `index.ts`’s own dedicated `HEAD` branch in `handleRequest` answers it. Before that branch existed, Kilotest treated `HEAD` as an invalid method for every path (its dispatch recognized only `GET` and `POST`), so UptimeRobot’s hourly check received a `400` response and reported the service as down even while it was actually up.
|
|
366
379
|
|
|
367
380
|
## Alerting
|
|
368
381
|
|
|
@@ -386,7 +399,7 @@ Alerts are sent to the maintainer in the following conditions:
|
|
|
386
399
|
|
|
387
400
|
7. **Test request received via API**: When a test request is submitted via the API, an alert is sent with subject `"Kilotest: new test request in the API"` (via the `requestTest` API operation) or `"Kilotest: new retest request in the API"` (via the `requestRetest` API operation), including the target URL, description, and the user's stated reason. A request is treated as a duplicate, and no alert is sent for it, only if a request already pending in `db/jobs/testRequests.json`, i.e., submitted but not yet manually approved by the maintainer into an actual job, for the same URL has the identical description; this check ignores the stated reason and the test-versus-retest distinction, and is not limited to a recent time window, it compares against every request still pending approval for that URL. Once the maintainer approves a pending request (turning it into a job via `/enqueue.html`), all pending requests for that URL are cleared, so this check cannot detect a duplicate against a request that has already been approved, is currently running, or has already completed; an identical request submitted after approval alerts again. The check and its dedup key are identical for the UI and API paths, since both funnel through the same underlying request-queuing function.
|
|
388
401
|
|
|
389
|
-
8. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request.
|
|
402
|
+
8. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request, and the request is also appended to `db/featureRequests.json`, the same way tutorial comments are appended to their own files (see item 1), so that a manager can review or delete it later via `/deleteNotesForm.html`.
|
|
390
403
|
|
|
391
404
|
9. **WAVE balance low**: When Kilotest detects that the account balance for the WAVE API service (used for accessibility testing) has fallen below a threshold, an alert is sent with subject `"Kilotest: WAVE balance low"`, indicating the number of credits remaining.
|
|
392
405
|
|
package/index.test.ts
CHANGED
|
@@ -15,9 +15,19 @@ process.env.DB_DIR = fixtureDBDir;
|
|
|
15
15
|
process.env.AUTH_CODE = 'test-auth-code';
|
|
16
16
|
process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialWeb.json');
|
|
17
17
|
process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialAI.json');
|
|
18
|
+
process.env.FEATURE_REQUESTS_PATH = path.join(testCommentsDir, 'featureRequests.json');
|
|
18
19
|
process.env.TESTARO_WORKERS = JSON.stringify({
|
|
19
20
|
worker1: {secret: 'secret1', name: 'Worker One'}
|
|
20
21
|
});
|
|
22
|
+
// Blank the alert configuration unconditionally, so this file sends no real alert
|
|
23
|
+
// emails (e.g. from the requestTest/requestRetest/tutorial-comment tests below) even
|
|
24
|
+
// when run directly (e.g. `npx tsx --test index.test.ts`) rather than via `npm test`,
|
|
25
|
+
// which normally guards against this by --require-ing test/setup.ts first. dotenv does
|
|
26
|
+
// not override an already-set env var, so setting these here, before index.ts is ever
|
|
27
|
+
// imported, prevents a real .env file's alert credentials from ever taking effect.
|
|
28
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
29
|
+
process.env[key] = '';
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
// IMPORTS
|
|
23
33
|
|
|
@@ -58,6 +68,7 @@ beforeEach(async () => {
|
|
|
58
68
|
await fs.mkdir(testCommentsDir, {recursive: true});
|
|
59
69
|
await fs.writeFile(path.join(testCommentsDir, 'tutorialWeb.json'), '[]\n');
|
|
60
70
|
await fs.writeFile(path.join(testCommentsDir, 'tutorialAI.json'), '[]\n');
|
|
71
|
+
await fs.writeFile(path.join(testCommentsDir, 'featureRequests.json'), '[]\n');
|
|
61
72
|
});
|
|
62
73
|
|
|
63
74
|
after(async () => {
|
|
@@ -215,6 +226,30 @@ test('GET /index.html serves the home page', async () => {
|
|
|
215
226
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
216
227
|
});
|
|
217
228
|
|
|
229
|
+
// TESTS: HEAD routes
|
|
230
|
+
|
|
231
|
+
test('HEAD / returns the same status and headers as GET /, with no body', async () => {
|
|
232
|
+
const getRes = await request('GET', '/');
|
|
233
|
+
const headRes = await request('HEAD', '/');
|
|
234
|
+
assert.equal(headRes.statusCode, getRes.statusCode);
|
|
235
|
+
assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
|
|
236
|
+
assert.equal(headRes.body, '');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('HEAD /index.html returns the same status and headers as GET /, with no body', async () => {
|
|
240
|
+
const getRes = await request('GET', '/');
|
|
241
|
+
const headRes = await request('HEAD', '/index.html');
|
|
242
|
+
assert.equal(headRes.statusCode, getRes.statusCode);
|
|
243
|
+
assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
|
|
244
|
+
assert.equal(headRes.body, '');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('HEAD /robots.txt returns an invalid HEAD request error, since only the home page supports HEAD', async () => {
|
|
248
|
+
const res = await request('HEAD', '/robots.txt');
|
|
249
|
+
assert.equal(res.statusCode, 400);
|
|
250
|
+
assert.equal(res.body, '');
|
|
251
|
+
});
|
|
252
|
+
|
|
218
253
|
test('GET /robots.txt serves the robots file as text', async () => {
|
|
219
254
|
const res = await request('GET', '/robots.txt');
|
|
220
255
|
assert.equal(res.statusCode, 200);
|
|
@@ -1247,6 +1282,7 @@ const htmlPagePaths = [
|
|
|
1247
1282
|
'/pruneReportsForm.html',
|
|
1248
1283
|
'/rewindReportsForm.html',
|
|
1249
1284
|
'/ai0BalanceForm.html',
|
|
1285
|
+
'/deleteNotesForm.html',
|
|
1250
1286
|
'/metrics.html'
|
|
1251
1287
|
];
|
|
1252
1288
|
|
|
@@ -1424,6 +1460,32 @@ test('POST /rewindReportsForm.html with no selections shows the form and records
|
|
|
1424
1460
|
assert.equal(await getManagerActivityCount('rewindReportsForm', 'ok'), countBefore + 1);
|
|
1425
1461
|
});
|
|
1426
1462
|
|
|
1463
|
+
test('POST /deleteNotesForm.html deletes multiple selected notes across sources in one submission', async () => {
|
|
1464
|
+
await fs.writeFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, JSON.stringify([
|
|
1465
|
+
{timeStamp: '260101T0000', content: 'A web tutorial comment'}
|
|
1466
|
+
]));
|
|
1467
|
+
await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, JSON.stringify([
|
|
1468
|
+
{timeStamp: '260101T0001', content: 'A feature request'},
|
|
1469
|
+
{timeStamp: '260101T0001', content: 'A different feature request'}
|
|
1470
|
+
]));
|
|
1471
|
+
// Reconstructing the POST body's query string uses querystring.stringify, which (unlike
|
|
1472
|
+
// the URLSearchParams object constructor) preserves multiple values for the same field
|
|
1473
|
+
// name as repeated key=value pairs rather than collapsing them into one comma-joined
|
|
1474
|
+
// value, so this exercises that multi-checkbox submission actually deletes every
|
|
1475
|
+
// selected note, not just the one whose value happens to survive the collapse.
|
|
1476
|
+
const res = await formRequest('POST', '/deleteNotesForm.html', [
|
|
1477
|
+
['authCode', 'test-auth-code'],
|
|
1478
|
+
['note', `tutorialWeb\t${encodeURIComponent('A web tutorial comment')}`],
|
|
1479
|
+
['note', `featureRequest\t${encodeURIComponent('A feature request')}`]
|
|
1480
|
+
]);
|
|
1481
|
+
assert.equal(res.statusCode, 200);
|
|
1482
|
+
const webComments = JSON.parse(await fs.readFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, 'utf8'));
|
|
1483
|
+
assert.equal(webComments.length, 0);
|
|
1484
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
1485
|
+
assert.equal(featureRequests.length, 1);
|
|
1486
|
+
assert.equal(featureRequests[0].content, 'A different feature request');
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1427
1489
|
test('GET /pruneReportsForm.html records a managerActivity failure when a report file is corrupt', {timeout: 500}, async () => {
|
|
1428
1490
|
const reportPath = path.join(fixtureDBDir, 'reports', '260101T0001-ct.json');
|
|
1429
1491
|
const backup = await fs.readFile(reportPath, 'utf8');
|
package/index.ts
CHANGED
|
@@ -42,7 +42,9 @@ import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './
|
|
|
42
42
|
import http, {type IncomingMessage, type ServerResponse} from 'node:http';
|
|
43
43
|
import https from 'node:https';
|
|
44
44
|
import path from 'node:path';
|
|
45
|
+
import querystring from 'node:querystring';
|
|
45
46
|
import {answer as ai0BalanceForm} from './web/ai0BalanceForm/index.ts';
|
|
47
|
+
import {answer as deleteNotesForm} from './web/deleteNotesForm/index.ts';
|
|
46
48
|
import {answer as enqueue} from './web/enqueue/index.ts';
|
|
47
49
|
import {answer as enqueueForm} from './web/enqueueForm/index.ts';
|
|
48
50
|
import {answer as expungeReportsForm} from './web/expungeReportsForm/index.ts';
|
|
@@ -97,6 +99,7 @@ type PageHandler = (...args: any[]) => Promise<AnswerData>;
|
|
|
97
99
|
|
|
98
100
|
const answer: {
|
|
99
101
|
ai0BalanceForm: PageHandler;
|
|
102
|
+
deleteNotesForm: PageHandler;
|
|
100
103
|
enqueue: PageHandler;
|
|
101
104
|
enqueueForm: PageHandler;
|
|
102
105
|
expungeReportsForm: PageHandler;
|
|
@@ -126,6 +129,7 @@ const answer: {
|
|
|
126
129
|
[key: string]: PageHandler | undefined;
|
|
127
130
|
} = {
|
|
128
131
|
ai0BalanceForm,
|
|
132
|
+
deleteNotesForm,
|
|
129
133
|
enqueue,
|
|
130
134
|
enqueueForm,
|
|
131
135
|
expungeReportsForm,
|
|
@@ -207,6 +211,7 @@ export const routes = {
|
|
|
207
211
|
'/api/*',
|
|
208
212
|
'/mcp',
|
|
209
213
|
'/ai0BalanceForm.html',
|
|
214
|
+
'/deleteNotesForm.html',
|
|
210
215
|
'/expungeReportsForm.html',
|
|
211
216
|
'/hideReportForm.html',
|
|
212
217
|
'/metrics.html',
|
|
@@ -240,6 +245,7 @@ const managerPages = new Set([
|
|
|
240
245
|
'unhideReportForm',
|
|
241
246
|
'ai0BalanceForm',
|
|
242
247
|
'renewWCAGForm',
|
|
248
|
+
'deleteNotesForm',
|
|
243
249
|
'metrics'
|
|
244
250
|
]);
|
|
245
251
|
// The set of manager pages (a subset of managerPages) whose single answer() function both
|
|
@@ -255,6 +261,7 @@ const selfSubmittingManagerPages = new Set([
|
|
|
255
261
|
'showHiddenReportsForm',
|
|
256
262
|
'unhideReportForm',
|
|
257
263
|
'ai0BalanceForm',
|
|
264
|
+
'deleteNotesForm',
|
|
258
265
|
'metrics'
|
|
259
266
|
]);
|
|
260
267
|
// The subset of selfSubmittingManagerPages that must never be served on a direct GET
|
|
@@ -857,6 +864,19 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
857
864
|
);
|
|
858
865
|
}
|
|
859
866
|
}
|
|
867
|
+
// Otherwise, if the request is a HEAD request: only the home page supports HEAD, since
|
|
868
|
+
// UptimeRobot's uptime check is the only known HEAD caller and it targets only that path
|
|
869
|
+
// (see "Health monitoring" in docs/SERVICE.md). Its response must match what GET / would
|
|
870
|
+
// send, minus the body, so that a monitor reading headers alone sees the real page state.
|
|
871
|
+
else if (method === 'HEAD') {
|
|
872
|
+
if (['/', '/index.html'].includes(pathname)) {
|
|
873
|
+
setHeaders('text/html', '/index.html', 'medium');
|
|
874
|
+
response.end();
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
await serveError({message: `ERROR: Invalid HEAD request (${pathname})`}, response, true);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
860
880
|
// Otherwise, if the request is a POST request:
|
|
861
881
|
else if (method === 'POST') {
|
|
862
882
|
// If the path is not authorized for POST requests:
|
|
@@ -1023,7 +1043,12 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1023
1043
|
setHeaders('text/html', pathname, 'ultra');
|
|
1024
1044
|
// Reconstruct a query string from the POST body, so the page's answer() function
|
|
1025
1045
|
// can read its submitted parameters the same way it reads a GET query string.
|
|
1026
|
-
|
|
1046
|
+
// querystring.stringify (rather than the URLSearchParams object constructor) is
|
|
1047
|
+
// required here, because postData, from querystring.parse in getPOSTData, holds
|
|
1048
|
+
// repeated form fields (e.g. multiple checked checkboxes sharing a name) as an
|
|
1049
|
+
// array; the URLSearchParams constructor would join such an array into a single
|
|
1050
|
+
// comma-separated value instead of preserving it as repeated key=value pairs.
|
|
1051
|
+
const search = `?${querystring.stringify(postData as Record<string, string | string[]>)}`;
|
|
1027
1052
|
// Get the answer data. The method is passed so the handler only processes a
|
|
1028
1053
|
// submission for POST, never for GET, regardless of what its query string contains.
|
|
1029
1054
|
const answerData = await answer[topic]!(pathTail, search, method);
|
|
@@ -1214,7 +1239,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1214
1239
|
}
|
|
1215
1240
|
}
|
|
1216
1241
|
}
|
|
1217
|
-
// Otherwise, i.e. if it is
|
|
1242
|
+
// Otherwise, i.e. if it is none of GET, HEAD, or POST:
|
|
1218
1243
|
else {
|
|
1219
1244
|
// Report its invalidity. (Caddy handles OPTIONS requests.)
|
|
1220
1245
|
await serveError({message: 'ERROR: Invalid request method'}, response, true);
|
package/linkValidation.test.ts
CHANGED
package/package.json
CHANGED
package/robots.txt
CHANGED
package/smokeTest.ts
CHANGED
|
@@ -41,6 +41,7 @@ const concretePaths: Record<string, Record<string, string>> = {
|
|
|
41
41
|
'/api/*': '/api/requestFeature',
|
|
42
42
|
'/mcp': '/mcp',
|
|
43
43
|
'/ai0BalanceForm.html': '/ai0BalanceForm.html',
|
|
44
|
+
'/deleteNotesForm.html': '/deleteNotesForm.html',
|
|
44
45
|
'/expungeReportsForm.html': '/expungeReportsForm.html',
|
|
45
46
|
'/hideReportForm.html': '/hideReportForm.html',
|
|
46
47
|
'/metrics.html': '/metrics.html',
|
|
@@ -64,6 +65,7 @@ const postBodies: Record<string, object> = {
|
|
|
64
65
|
'/api/requestFeature': {feature: 'smoke test'},
|
|
65
66
|
'/mcp': {},
|
|
66
67
|
'/ai0BalanceForm.html': {authCode: 'invalid'},
|
|
68
|
+
'/deleteNotesForm.html': {authCode: 'invalid'},
|
|
67
69
|
'/expungeReportsForm.html': {authCode: 'invalid'},
|
|
68
70
|
'/hideReportForm.html': {authCode: 'invalid'},
|
|
69
71
|
'/metrics.html': {authCode: 'invalid'},
|
|
@@ -229,20 +231,23 @@ const isMCPErrorResponse = (result: {statusCode: number | undefined, body: strin
|
|
|
229
231
|
console.log(`FAIL: POST ${mcpPath} initialize -> error: ${message}`);
|
|
230
232
|
failures++;
|
|
231
233
|
}
|
|
232
|
-
// Send a
|
|
234
|
+
// Send a POST with an Accept header the MCP SDK itself rejects (missing text/event-stream)
|
|
235
|
+
// to verify that invalid MCP requests get JSON-RPC errors end-to-end. A browser-style GET
|
|
236
|
+
// is not used here because index.ts deliberately intercepts it before handleMCP, serving a
|
|
237
|
+
// human-readable explainer page instead of a JSON-RPC error.
|
|
233
238
|
try {
|
|
234
|
-
const probe = await sendMCPRequest('
|
|
239
|
+
const probe = await sendMCPRequest('POST', 'application/json', mcpInitialize);
|
|
235
240
|
if (isMCPErrorResponse(probe)) {
|
|
236
|
-
console.log(`PASS:
|
|
241
|
+
console.log(`PASS: POST ${mcpPath} bad accept -> ${probe.statusCode} (JSON-RPC error)`);
|
|
237
242
|
}
|
|
238
243
|
else {
|
|
239
|
-
console.log(`FAIL:
|
|
244
|
+
console.log(`FAIL: POST ${mcpPath} bad accept -> ${probe.statusCode} (body: ${probe.body.slice(0, 120)})`);
|
|
240
245
|
failures++;
|
|
241
246
|
}
|
|
242
247
|
}
|
|
243
248
|
catch (error) {
|
|
244
249
|
const message = error instanceof Error ? error.message : String(error);
|
|
245
|
-
console.log(`FAIL:
|
|
250
|
+
console.log(`FAIL: POST ${mcpPath} bad accept -> error: ${message}`);
|
|
246
251
|
failures++;
|
|
247
252
|
}
|
|
248
253
|
console.log(`\n=== ${failures === 0 ? 'All checks passed' : `${failures} failure(s)`} ===`);
|
package/util.test.ts
CHANGED
|
@@ -9,6 +9,15 @@ import {test, before, after} from 'node:test';
|
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
// Blank the alert configuration unconditionally, before util.ts (which processTestRequest
|
|
14
|
+
// calls sendAlert from) is ever imported below, so this file sends no real alert emails
|
|
15
|
+
// even when run directly (e.g. `npx tsx --test util.test.ts`) rather than via `npm test`,
|
|
16
|
+
// which normally guards against this via test/setup.ts.
|
|
17
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
18
|
+
process.env[key] = '';
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
import {
|
|
13
22
|
annotateReportObject,
|
|
14
23
|
checkCommentDuplicate,
|
|
@@ -116,26 +125,18 @@ test('checkCommentDuplicate accepts a comment with no existing comments', () =>
|
|
|
116
125
|
assert.deepEqual(result, {status: 'ok'});
|
|
117
126
|
});
|
|
118
127
|
|
|
119
|
-
test('checkCommentDuplicate rejects a comment
|
|
128
|
+
test('checkCommentDuplicate rejects a comment identical to one already stored, regardless of when', () => {
|
|
120
129
|
const content = 'a'.repeat(20);
|
|
121
|
-
const comments = [{
|
|
130
|
+
const comments = [{content}];
|
|
122
131
|
const result = checkCommentDuplicate(comments, content);
|
|
123
132
|
assert.deepEqual(result, {
|
|
124
133
|
status: 'error',
|
|
125
|
-
message: 'Your comment
|
|
134
|
+
message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
126
135
|
});
|
|
127
136
|
});
|
|
128
137
|
|
|
129
|
-
test('checkCommentDuplicate accepts a comment
|
|
130
|
-
const
|
|
131
|
-
const oldTimeStamp = getTimeStamp(new Date(Date.now() - 2000000));
|
|
132
|
-
const comments = [{timeStamp: oldTimeStamp, content}];
|
|
133
|
-
const result = checkCommentDuplicate(comments, content);
|
|
134
|
-
assert.deepEqual(result, {status: 'ok'});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('checkCommentDuplicate accepts a comment that differs from a recent one', () => {
|
|
138
|
-
const comments = [{timeStamp: getNowStamp(), content: 'a'.repeat(20)}];
|
|
138
|
+
test('checkCommentDuplicate accepts a comment that differs from a stored one', () => {
|
|
139
|
+
const comments = [{content: 'a'.repeat(20)}];
|
|
139
140
|
const result = checkCommentDuplicate(comments, 'b'.repeat(20));
|
|
140
141
|
assert.deepEqual(result, {status: 'ok'});
|
|
141
142
|
});
|
package/util.ts
CHANGED
|
@@ -207,27 +207,22 @@ export const checkCommentLength = (
|
|
|
207
207
|
}
|
|
208
208
|
return {status: 'ok'};
|
|
209
209
|
};
|
|
210
|
-
// Returns whether a comment repeats, verbatim, one of the given comments
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
210
|
+
// Returns whether a comment repeats, verbatim, one of the given comments, regardless
|
|
211
|
+
// of when the earlier one was submitted: a legitimate need to resubmit exactly the
|
|
212
|
+
// same content never arises (the near-term case is a browser "Back" resubmission),
|
|
213
|
+
// so storing a second copy would only ever be redundant. The content passed in
|
|
214
|
+
// should be the content as it will be stored (e.g. after sanitization), so that it
|
|
215
|
+
// is compared on the same basis as the stored comments. Content, not any time
|
|
216
|
+
// stamp, is what this checks and what identifies a comment as unique.
|
|
216
217
|
export const checkCommentDuplicate = (
|
|
217
|
-
comments: {
|
|
218
|
+
comments: {content: string}[], content: string
|
|
218
219
|
): {status: 'ok'} | {status: 'error'; message: string} => {
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
// of comments.json.
|
|
222
|
-
const duplicates = comments
|
|
223
|
-
.filter(comment => getDateTime(comment.timeStamp)!.getTime() > Date.now() - 1000000)
|
|
224
|
-
.filter(comment => comment.content === content);
|
|
225
|
-
// If the comment duplicates a recent one:
|
|
226
|
-
if (duplicates.length) {
|
|
220
|
+
// If the comment duplicates one already stored:
|
|
221
|
+
if (comments.some(comment => comment.content === content)) {
|
|
227
222
|
// Return this.
|
|
228
223
|
return {
|
|
229
224
|
status: 'error',
|
|
230
|
-
message: 'Your comment
|
|
225
|
+
message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
231
226
|
};
|
|
232
227
|
}
|
|
233
228
|
// Otherwise, the comment is not a duplicate.
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/*
|
|
2
|
+
deleteNotesForm.test.ts
|
|
3
|
+
Unit tests for web/deleteNotesForm/index.ts.
|
|
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
|
+
|
|
13
|
+
// ENVIRONMENT
|
|
14
|
+
|
|
15
|
+
const testDir = path.join(import.meta.dirname, '../../test/fixtures/deleteNotesForm');
|
|
16
|
+
const tutorialWebPath = path.join(testDir, 'tutorialWeb.json');
|
|
17
|
+
const tutorialAIPath = path.join(testDir, 'tutorialAI.json');
|
|
18
|
+
const featureRequestsPath = path.join(testDir, 'featureRequests.json');
|
|
19
|
+
|
|
20
|
+
const savedAuthCode = process.env.AUTH_CODE;
|
|
21
|
+
const savedTutorialWebPath = process.env.TUTORIAL_WEB_COMMENTS_PATH;
|
|
22
|
+
const savedTutorialAIPath = process.env.TUTORIAL_AI_COMMENTS_PATH;
|
|
23
|
+
const savedFeatureRequestsPath = process.env.FEATURE_REQUESTS_PATH;
|
|
24
|
+
|
|
25
|
+
before(async () => {
|
|
26
|
+
process.env.AUTH_CODE = 'test-auth-code';
|
|
27
|
+
process.env.TUTORIAL_WEB_COMMENTS_PATH = tutorialWebPath;
|
|
28
|
+
process.env.TUTORIAL_AI_COMMENTS_PATH = tutorialAIPath;
|
|
29
|
+
process.env.FEATURE_REQUESTS_PATH = featureRequestsPath;
|
|
30
|
+
await fs.mkdir(testDir, {recursive: true});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
await fs.writeFile(tutorialWebPath, JSON.stringify([
|
|
35
|
+
{timeStamp: '260101T0000', content: 'A web tutorial comment'}
|
|
36
|
+
]));
|
|
37
|
+
await fs.writeFile(tutorialAIPath, JSON.stringify([
|
|
38
|
+
{timeStamp: '260102T0000', content: 'An AI tutorial comment'}
|
|
39
|
+
]));
|
|
40
|
+
await fs.writeFile(featureRequestsPath, JSON.stringify([
|
|
41
|
+
{timeStamp: '260103T0000', content: 'A feature request'}
|
|
42
|
+
]));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
import {answer} from './index.ts';
|
|
46
|
+
|
|
47
|
+
after(async () => {
|
|
48
|
+
if (savedAuthCode !== undefined) {
|
|
49
|
+
process.env.AUTH_CODE = savedAuthCode;
|
|
50
|
+
}
|
|
51
|
+
if (savedTutorialWebPath !== undefined) {
|
|
52
|
+
process.env.TUTORIAL_WEB_COMMENTS_PATH = savedTutorialWebPath;
|
|
53
|
+
} else {
|
|
54
|
+
delete process.env.TUTORIAL_WEB_COMMENTS_PATH;
|
|
55
|
+
}
|
|
56
|
+
if (savedTutorialAIPath !== undefined) {
|
|
57
|
+
process.env.TUTORIAL_AI_COMMENTS_PATH = savedTutorialAIPath;
|
|
58
|
+
} else {
|
|
59
|
+
delete process.env.TUTORIAL_AI_COMMENTS_PATH;
|
|
60
|
+
}
|
|
61
|
+
if (savedFeatureRequestsPath !== undefined) {
|
|
62
|
+
process.env.FEATURE_REQUESTS_PATH = savedFeatureRequestsPath;
|
|
63
|
+
} else {
|
|
64
|
+
delete process.env.FEATURE_REQUESTS_PATH;
|
|
65
|
+
}
|
|
66
|
+
await fs.rm(testDir, {recursive: true, force: true});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// TESTS
|
|
70
|
+
|
|
71
|
+
test('deleteNotesForm lists notes from all three sources on a GET request', async () => {
|
|
72
|
+
const result = await answer(null, '', 'GET');
|
|
73
|
+
assert.equal(result.status, 'ok');
|
|
74
|
+
assert.ok(result.answerPage!.includes('A web tutorial comment'));
|
|
75
|
+
assert.ok(result.answerPage!.includes('An AI tutorial comment'));
|
|
76
|
+
assert.ok(result.answerPage!.includes('A feature request'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('deleteNotesForm ignores a note query string on a GET request', async () => {
|
|
80
|
+
const result = await answer(
|
|
81
|
+
null,
|
|
82
|
+
`authCode=test-auth-code¬e=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
|
|
83
|
+
'GET'
|
|
84
|
+
);
|
|
85
|
+
assert.equal(result.status, 'ok');
|
|
86
|
+
// The note should NOT have been deleted by a mere GET request.
|
|
87
|
+
const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
|
|
88
|
+
assert.equal(notes.length, 1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('deleteNotesForm returns an error for an invalid auth code on a POST request', async () => {
|
|
92
|
+
const result = await answer(
|
|
93
|
+
null,
|
|
94
|
+
`authCode=wrong¬e=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
|
|
95
|
+
'POST'
|
|
96
|
+
);
|
|
97
|
+
assert.equal(result.status, 'error');
|
|
98
|
+
assert.equal(result.message, 'Invalid request');
|
|
99
|
+
const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
|
|
100
|
+
assert.equal(notes.length, 1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('deleteNotesForm deletes a selected web tutorial comment with a valid auth code', async () => {
|
|
104
|
+
const result = await answer(
|
|
105
|
+
null,
|
|
106
|
+
`authCode=test-auth-code¬e=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
|
|
107
|
+
'POST'
|
|
108
|
+
);
|
|
109
|
+
assert.equal(result.status, 'ok');
|
|
110
|
+
const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
|
|
111
|
+
assert.equal(notes.length, 0);
|
|
112
|
+
// The other sources are untouched.
|
|
113
|
+
const aiNotes = JSON.parse(await fs.readFile(tutorialAIPath, 'utf8'));
|
|
114
|
+
assert.equal(aiNotes.length, 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('deleteNotesForm deletes selected notes across multiple sources in one submission', async () => {
|
|
118
|
+
const result = await answer(
|
|
119
|
+
null,
|
|
120
|
+
'authCode=test-auth-code' +
|
|
121
|
+
`¬e=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}` +
|
|
122
|
+
`¬e=tutorialAI%09${encodeURIComponent('An AI tutorial comment')}` +
|
|
123
|
+
`¬e=featureRequest%09${encodeURIComponent('A feature request')}`,
|
|
124
|
+
'POST'
|
|
125
|
+
);
|
|
126
|
+
assert.equal(result.status, 'ok');
|
|
127
|
+
assert.equal(JSON.parse(await fs.readFile(tutorialWebPath, 'utf8')).length, 0);
|
|
128
|
+
assert.equal(JSON.parse(await fs.readFile(tutorialAIPath, 'utf8')).length, 0);
|
|
129
|
+
assert.equal(JSON.parse(await fs.readFile(featureRequestsPath, 'utf8')).length, 0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('deleteNotesForm leaves unselected notes in a source intact', async () => {
|
|
133
|
+
await fs.writeFile(tutorialWebPath, JSON.stringify([
|
|
134
|
+
{timeStamp: '260101T0000', content: 'Keep this one'},
|
|
135
|
+
{timeStamp: '260101T0001', content: 'Delete this one'}
|
|
136
|
+
]));
|
|
137
|
+
const result = await answer(
|
|
138
|
+
null,
|
|
139
|
+
`authCode=test-auth-code¬e=tutorialWeb%09${encodeURIComponent('Delete this one')}`,
|
|
140
|
+
'POST'
|
|
141
|
+
);
|
|
142
|
+
assert.equal(result.status, 'ok');
|
|
143
|
+
const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
|
|
144
|
+
assert.equal(notes.length, 1);
|
|
145
|
+
assert.equal(notes[0].content, 'Keep this one');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('deleteNotesForm reports no notes to delete when all sources are empty', async () => {
|
|
149
|
+
await fs.writeFile(tutorialWebPath, '[]');
|
|
150
|
+
await fs.writeFile(tutorialAIPath, '[]');
|
|
151
|
+
await fs.writeFile(featureRequestsPath, '[]');
|
|
152
|
+
const result = await answer(null, '', 'GET');
|
|
153
|
+
assert.equal(result.status, 'ok');
|
|
154
|
+
assert.ok(result.answerPage!.includes('There are no comments or feature requests to delete.'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('deleteNotesForm treats a missing source file as having no notes', async () => {
|
|
158
|
+
await fs.rm(featureRequestsPath, {force: true});
|
|
159
|
+
const result = await answer(null, '', 'GET');
|
|
160
|
+
assert.equal(result.status, 'ok');
|
|
161
|
+
assert.ok(result.answerPage!.includes('A web tutorial comment'));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('deleteNotesForm escapes note content to prevent HTML injection', async () => {
|
|
165
|
+
await fs.writeFile(tutorialWebPath, JSON.stringify([
|
|
166
|
+
{timeStamp: '260101T0000', content: '<script>alert(1)</script>'}
|
|
167
|
+
]));
|
|
168
|
+
const result = await answer(null, '', 'GET');
|
|
169
|
+
assert.equal(result.status, 'ok');
|
|
170
|
+
assert.ok(!result.answerPage!.includes('<script>'));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('deleteNotesForm deletes a note whose content contains a literal tab character', async () => {
|
|
174
|
+
const content = 'Line one\tLine two, after an embedded tab';
|
|
175
|
+
await fs.writeFile(tutorialWebPath, JSON.stringify([
|
|
176
|
+
{timeStamp: '260101T0000', content}
|
|
177
|
+
]));
|
|
178
|
+
const result = await answer(
|
|
179
|
+
null, `authCode=test-auth-code¬e=tutorialWeb%09${encodeURIComponent(content)}`, 'POST'
|
|
180
|
+
);
|
|
181
|
+
assert.equal(result.status, 'ok');
|
|
182
|
+
const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
|
|
183
|
+
assert.equal(notes.length, 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('deleteNotesForm renders a checkbox value that round-trips a note with a literal tab', async () => {
|
|
187
|
+
const content = 'Has\ta\ttab';
|
|
188
|
+
await fs.writeFile(tutorialWebPath, JSON.stringify([
|
|
189
|
+
{timeStamp: '260101T0000', content}
|
|
190
|
+
]));
|
|
191
|
+
const result = await answer(null, '', 'GET');
|
|
192
|
+
assert.equal(result.status, 'ok');
|
|
193
|
+
assert.ok(result.answerPage!.includes(`tutorialWeb\t${encodeURIComponent(content)}`));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('deleteNotesForm returns an error when a note file cannot be written', async () => {
|
|
197
|
+
// Point the web tutorial comments path at a location whose parent is a file, so that
|
|
198
|
+
// creating the directory for it fails.
|
|
199
|
+
const blockerFile = path.join(testDir, 'blocker');
|
|
200
|
+
const unwritablePath = path.join(blockerFile, 'tutorialWeb.json');
|
|
201
|
+
await fs.writeFile(blockerFile, 'not a directory');
|
|
202
|
+
const savedPath = process.env.TUTORIAL_WEB_COMMENTS_PATH;
|
|
203
|
+
process.env.TUTORIAL_WEB_COMMENTS_PATH = unwritablePath;
|
|
204
|
+
try {
|
|
205
|
+
const result = await answer(
|
|
206
|
+
null,
|
|
207
|
+
`authCode=test-auth-code¬e=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
|
|
208
|
+
'POST'
|
|
209
|
+
);
|
|
210
|
+
assert.equal(result.status, 'error');
|
|
211
|
+
assert.ok(result.message?.includes('Deleting notes failed'));
|
|
212
|
+
} finally {
|
|
213
|
+
process.env.TUTORIAL_WEB_COMMENTS_PATH = savedPath;
|
|
214
|
+
await fs.unlink(blockerFile).catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('deleteNotesForm falls back to default paths when environment variables are not set', async () => {
|
|
219
|
+
const saved = {
|
|
220
|
+
tutorialWeb: process.env.TUTORIAL_WEB_COMMENTS_PATH,
|
|
221
|
+
tutorialAI: process.env.TUTORIAL_AI_COMMENTS_PATH,
|
|
222
|
+
featureRequest: process.env.FEATURE_REQUESTS_PATH
|
|
223
|
+
};
|
|
224
|
+
delete process.env.TUTORIAL_WEB_COMMENTS_PATH;
|
|
225
|
+
delete process.env.TUTORIAL_AI_COMMENTS_PATH;
|
|
226
|
+
delete process.env.FEATURE_REQUESTS_PATH;
|
|
227
|
+
try {
|
|
228
|
+
// None of the default db/ files exist in this environment, so the form should
|
|
229
|
+
// still render, treating each missing source as having no notes.
|
|
230
|
+
const result = await answer(null, '', 'GET');
|
|
231
|
+
assert.equal(result.status, 'ok');
|
|
232
|
+
} finally {
|
|
233
|
+
process.env.TUTORIAL_WEB_COMMENTS_PATH = saved.tutorialWeb;
|
|
234
|
+
process.env.TUTORIAL_AI_COMMENTS_PATH = saved.tutorialAI;
|
|
235
|
+
process.env.FEATURE_REQUESTS_PATH = saved.featureRequest;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en-US">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="publisher" content="Jonathan Robert Pool">
|
|
7
|
+
<meta name="creator" content="Jonathan Robert Pool">
|
|
8
|
+
<meta name="keywords" content="report,accessibility,a11y">
|
|
9
|
+
<title>Delete notes | Kilotest</title>
|
|
10
|
+
<link rel="icon" href="/favicon.ico">
|
|
11
|
+
<link rel="stylesheet" href="/style.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<main>
|
|
15
|
+
<h1><a href="/">Kilotest</a>: Delete notes</h1>
|
|
16
|
+
<p>__intro__</p>
|
|
17
|
+
<form action="/deleteNotesForm.html" method="post">
|
|
18
|
+
<fieldset>
|
|
19
|
+
<legend>Notes</legend>
|
|
20
|
+
__notes__
|
|
21
|
+
</fieldset>
|
|
22
|
+
<p><label>
|
|
23
|
+
Authorization code: <input size="3" minLength="3" maxlength="3" name="authCode" required__disabled__>
|
|
24
|
+
</label></p>
|
|
25
|
+
<p><button type="submit"__disabled__>Submit</button></p>
|
|
26
|
+
</form>
|
|
27
|
+
</main>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/*
|
|
2
|
+
index.ts
|
|
3
|
+
Serves a form for deleting web tutorial comments, AI tutorial comments, and MCP
|
|
4
|
+
feature requests, the three kinds of notes that Kilotest records to flat JSON files
|
|
5
|
+
rather than presenting through any other manager page.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// IMPORTS
|
|
9
|
+
|
|
10
|
+
import {errorMessage, getDateTimeString, getJSON, getPlainText, isValidAuthCode, populateTemplate} from '../../util.ts';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
// TYPES
|
|
15
|
+
|
|
16
|
+
// A note (comment or feature request) as stored in one of the note files.
|
|
17
|
+
type Note = {
|
|
18
|
+
timeStamp: string;
|
|
19
|
+
content: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The source of a kind of note, identifying its file and how it is labeled.
|
|
23
|
+
type NoteSource = {
|
|
24
|
+
name: string;
|
|
25
|
+
label: string;
|
|
26
|
+
getPath: () => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// CONSTANTS
|
|
30
|
+
|
|
31
|
+
// The note sources, in the order they are listed on the form. Each source's path is
|
|
32
|
+
// read afresh on every call, via its own environment-variable override, exactly as
|
|
33
|
+
// web/tutorialWeb/index.ts and web/tutorialAI/index.ts already do for their own
|
|
34
|
+
// comment files, so that tests can point any of them at a fixture path.
|
|
35
|
+
const noteSources: NoteSource[] = [
|
|
36
|
+
{
|
|
37
|
+
name: 'tutorialWeb',
|
|
38
|
+
label: 'Web tutorial comment',
|
|
39
|
+
getPath: () => process.env.TUTORIAL_WEB_COMMENTS_PATH ||
|
|
40
|
+
path.join(import.meta.dirname, '../../db/comments/tutorialWeb.json')
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'tutorialAI',
|
|
44
|
+
label: 'AI tutorial comment',
|
|
45
|
+
getPath: () => process.env.TUTORIAL_AI_COMMENTS_PATH ||
|
|
46
|
+
path.join(import.meta.dirname, '../../db/comments/tutorialAI.json')
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'featureRequest',
|
|
50
|
+
label: 'MCP feature request',
|
|
51
|
+
getPath: () => process.env.FEATURE_REQUESTS_PATH ||
|
|
52
|
+
path.join(import.meta.dirname, '../../db/featureRequests.json')
|
|
53
|
+
}
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// FUNCTIONS (helpers)
|
|
57
|
+
|
|
58
|
+
// Returns the notes in a source's file, or an empty array if it does not exist.
|
|
59
|
+
const getNotes = async (source: NoteSource): Promise<Note[]> => {
|
|
60
|
+
try {
|
|
61
|
+
const existing = await fs.readFile(source.getPath(), 'utf8');
|
|
62
|
+
return JSON.parse(existing);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// FUNCTIONS
|
|
70
|
+
|
|
71
|
+
// Returns a form for deleting selected notes, or, on a POST request with a valid
|
|
72
|
+
// authorization code, deletes the selected notes and returns the form again. A GET
|
|
73
|
+
// request never processes a submission, regardless of its query string; only a POST
|
|
74
|
+
// request (the form's own submission) does.
|
|
75
|
+
export const answer = async (
|
|
76
|
+
_: any, search: string, method: string
|
|
77
|
+
): Promise<{status: string; message?: string; answerPage?: string}> => {
|
|
78
|
+
const searchParams = new URLSearchParams(search);
|
|
79
|
+
const authCode = searchParams?.get('authCode');
|
|
80
|
+
// Each selected checkbox's value is `${sourceName}\t${encodeURIComponent(content)}`.
|
|
81
|
+
// Content, rather than time stamp, identifies a note, because no two notes in the same
|
|
82
|
+
// source ever share content: checkCommentDuplicate rejects a submission identical to
|
|
83
|
+
// one already on file. Content is percent-encoded, and split out at the first tab
|
|
84
|
+
// only, because comment and feature-request content is not stripped of tab characters
|
|
85
|
+
// (see the sanitize functions in web/tutorialWeb/index.ts and web/tutorialAI/index.ts),
|
|
86
|
+
// so an unencoded tab within the content itself could otherwise be mistaken for the
|
|
87
|
+
// delimiter between the source name and the content.
|
|
88
|
+
const selections = searchParams?.getAll('note');
|
|
89
|
+
// If the form has been submitted and any notes are to be deleted:
|
|
90
|
+
if (method === 'POST' && selections?.length) {
|
|
91
|
+
// If the authorization code is valid:
|
|
92
|
+
if (isValidAuthCode(authCode)) {
|
|
93
|
+
try {
|
|
94
|
+
// Group the selected content by source.
|
|
95
|
+
const contentBySource = new Map<string, Set<string>>();
|
|
96
|
+
for (const selection of selections) {
|
|
97
|
+
const tabIndex = selection.indexOf('\t');
|
|
98
|
+
const sourceName = selection.slice(0, tabIndex);
|
|
99
|
+
const content = decodeURIComponent(selection.slice(tabIndex + 1));
|
|
100
|
+
if (!contentBySource.has(sourceName)) {
|
|
101
|
+
contentBySource.set(sourceName, new Set());
|
|
102
|
+
}
|
|
103
|
+
contentBySource.get(sourceName)!.add(content);
|
|
104
|
+
}
|
|
105
|
+
// For each source with any selected notes:
|
|
106
|
+
for (const source of noteSources) {
|
|
107
|
+
const contentSet = contentBySource.get(source.name);
|
|
108
|
+
if (!contentSet) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Remove the selected notes and save the rest.
|
|
112
|
+
const notes = await getNotes(source);
|
|
113
|
+
const remaining = notes.filter(note => !contentSet.has(note.content));
|
|
114
|
+
await fs.mkdir(path.dirname(source.getPath()), {recursive: true});
|
|
115
|
+
await fs.writeFile(source.getPath(), getJSON(remaining));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// If this failed:
|
|
119
|
+
catch (error: unknown) {
|
|
120
|
+
// Return why.
|
|
121
|
+
return {
|
|
122
|
+
status: 'error',
|
|
123
|
+
message: `Deleting notes failed (${errorMessage(error)})`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Otherwise, i.e. if the authorization code is invalid:
|
|
128
|
+
else {
|
|
129
|
+
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
130
|
+
// authorization code specifically (as opposed to some other part of the request) is
|
|
131
|
+
// what was wrong.
|
|
132
|
+
return {
|
|
133
|
+
status: 'error',
|
|
134
|
+
message: 'Invalid request'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const lines: string[] = [];
|
|
139
|
+
const margin = ' '.repeat(12);
|
|
140
|
+
let anyNotes = false;
|
|
141
|
+
// For each source, in display order:
|
|
142
|
+
for (const source of noteSources) {
|
|
143
|
+
const notes = await getNotes(source);
|
|
144
|
+
// For each of its notes, oldest first:
|
|
145
|
+
for (const note of notes) {
|
|
146
|
+
anyNotes = true;
|
|
147
|
+
const value = `${source.name}\t${encodeURIComponent(note.content)}`;
|
|
148
|
+
const when = getDateTimeString(note.timeStamp);
|
|
149
|
+
lines.push(
|
|
150
|
+
`${margin}<p><input type="checkbox" name="note" value="${value}"> ` +
|
|
151
|
+
`<strong>${source.label}</strong> (${when}): ${getPlainText(note.content)}</p>`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const query: Record<string, string> = {
|
|
156
|
+
notes: lines.join('\n'),
|
|
157
|
+
intro: anyNotes ?
|
|
158
|
+
'Choose the comments and feature requests to delete.' :
|
|
159
|
+
'There are no comments or feature requests to delete.',
|
|
160
|
+
disabled: anyNotes ? '' : ' disabled'
|
|
161
|
+
};
|
|
162
|
+
// Return the populated page.
|
|
163
|
+
return {
|
|
164
|
+
status: 'ok',
|
|
165
|
+
answerPage: await populateTemplate(import.meta.dirname, query)
|
|
166
|
+
};
|
|
167
|
+
};
|
package/web/manage/index.html
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<p>Kilotest managers can:</p>
|
|
17
17
|
<ul class="nav">
|
|
18
18
|
<li><a href="/enqueueForm.html">Approve or reject a test recommendation</a></li>
|
|
19
|
+
<li><a href="/deleteNotesForm.html">Delete a comment or feature request</a></li>
|
|
19
20
|
<li><a href="/reannotateForm.html">
|
|
20
21
|
Reannotate reports to incorporate newly classified and reclassified rules
|
|
21
22
|
</a></li>
|
package/web/tutorialAI/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {sendAlert} from '../../alerts.ts';
|
|
9
|
-
import {checkCommentLength, getJSON} from '../../util.ts';
|
|
9
|
+
import {checkCommentDuplicate, checkCommentLength, getJSON, getNowStamp} from '../../util.ts';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
@@ -56,18 +56,13 @@ export const handleComment = async (content: unknown) => {
|
|
|
56
56
|
return {status: 'error', message: 'Comment is empty after sanitization'};
|
|
57
57
|
}
|
|
58
58
|
// Check for duplicate submissions within the last 1000 seconds.
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (duplicates.length) {
|
|
63
|
-
return {
|
|
64
|
-
status: 'error',
|
|
65
|
-
message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
|
|
66
|
-
};
|
|
59
|
+
const duplicateCheck = checkCommentDuplicate(comments, sanitized);
|
|
60
|
+
if (duplicateCheck.status === 'error') {
|
|
61
|
+
return duplicateCheck;
|
|
67
62
|
}
|
|
68
|
-
// Add the comment to the existing ones
|
|
63
|
+
// Add the comment to the existing ones.
|
|
69
64
|
comments.push({
|
|
70
|
-
|
|
65
|
+
timeStamp: getNowStamp(),
|
|
71
66
|
content: sanitized
|
|
72
67
|
});
|
|
73
68
|
// Ensure the comments directory exists.
|
|
@@ -14,6 +14,12 @@ import path from 'node:path';
|
|
|
14
14
|
|
|
15
15
|
const testDir = path.join(import.meta.dirname, '../../test/fixtures/comments');
|
|
16
16
|
process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testDir, 'tutorialAI.json');
|
|
17
|
+
// Blank the alert configuration unconditionally, before index.ts (whose handleComment
|
|
18
|
+
// calls sendAlert for a new comment) is ever imported below, so this file sends no real
|
|
19
|
+
// alert emails even when run directly rather than via `npm test`.
|
|
20
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
21
|
+
process.env[key] = '';
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
import {answer, handleComment} from './index.ts';
|
|
19
25
|
|
|
@@ -71,7 +77,7 @@ test('handleComment returns an error for a comment longer than 1000 characters',
|
|
|
71
77
|
assert.equal(result.message, 'Your comment was longer than 1000 characters');
|
|
72
78
|
});
|
|
73
79
|
|
|
74
|
-
test('handleComment returns an error for a comment
|
|
80
|
+
test('handleComment returns an error for a comment identical to one already submitted', {timeout: 500}, async () => {
|
|
75
81
|
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
76
82
|
const content = 'This is a duplicate comment.';
|
|
77
83
|
const firstResult = await handleComment(content);
|
|
@@ -80,7 +86,7 @@ test('handleComment returns an error for a comment repeating one submitted withi
|
|
|
80
86
|
assert.equal(secondResult.status, 'error');
|
|
81
87
|
assert.equal(
|
|
82
88
|
secondResult.message,
|
|
83
|
-
'Your comment
|
|
89
|
+
'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
84
90
|
);
|
|
85
91
|
});
|
|
86
92
|
|
|
@@ -91,7 +97,7 @@ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, a
|
|
|
91
97
|
const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
|
|
92
98
|
assert.equal(comments.length, 1);
|
|
93
99
|
assert.equal(comments[0].content, 'This is a test comment.');
|
|
94
|
-
assert.ok(comments[0].
|
|
100
|
+
assert.ok(comments[0].timeStamp);
|
|
95
101
|
});
|
|
96
102
|
|
|
97
103
|
test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
|
|
@@ -14,6 +14,12 @@ import path from 'node:path';
|
|
|
14
14
|
|
|
15
15
|
const testDir = path.join(import.meta.dirname, '../../test/fixtures/comments');
|
|
16
16
|
process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testDir, 'tutorialWeb.json');
|
|
17
|
+
// Blank the alert configuration unconditionally, before index.ts (whose handleComment
|
|
18
|
+
// calls sendAlert for a new comment) is ever imported below, so this file sends no real
|
|
19
|
+
// alert emails even when run directly rather than via `npm test`.
|
|
20
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
21
|
+
process.env[key] = '';
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
import {answer, handleComment} from './index.ts';
|
|
19
25
|
|
|
@@ -73,7 +79,7 @@ test('handleComment returns an error for a comment longer than 1000 characters',
|
|
|
73
79
|
assert.equal(result.message, 'Your comment was longer than 1000 characters');
|
|
74
80
|
});
|
|
75
81
|
|
|
76
|
-
test('handleComment returns an error for a comment
|
|
82
|
+
test('handleComment returns an error for a comment identical to one already submitted', {timeout: 500}, async () => {
|
|
77
83
|
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
78
84
|
const content = 'This is a duplicate comment.';
|
|
79
85
|
const firstResult = await handleComment(content);
|
|
@@ -82,7 +88,7 @@ test('handleComment returns an error for a comment repeating one submitted withi
|
|
|
82
88
|
assert.equal(secondResult.status, 'error');
|
|
83
89
|
assert.equal(
|
|
84
90
|
secondResult.message,
|
|
85
|
-
'Your comment
|
|
91
|
+
'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
86
92
|
);
|
|
87
93
|
});
|
|
88
94
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"sessionId":"f44e6b44-375e-4007-879a-19e4e5ed34a0","pid":90042,"procStart":"Sun Sep 20 03:19:03 2026","acquiredAt":1789874948676}
|