@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/robots.txt CHANGED
@@ -2,6 +2,7 @@
2
2
  User-agent: *
3
3
  Allow: /
4
4
  Disallow: /ai0BalanceForm.html
5
+ Disallow: /deleteNotesForm.html
5
6
  Disallow: /enqueueForm.html
6
7
  Disallow: /recAction.html
7
8
  Disallow: /reannotateForm.html
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 browser-style GET to verify that invalid MCP requests get JSON-RPC errors end-to-end.
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('GET', 'text/html');
239
+ const probe = await sendMCPRequest('POST', 'application/json', mcpInitialize);
235
240
  if (isMCPErrorResponse(probe)) {
236
- console.log(`PASS: GET ${mcpPath} browser -> ${probe.statusCode} (JSON-RPC error)`);
241
+ console.log(`PASS: POST ${mcpPath} bad accept -> ${probe.statusCode} (JSON-RPC error)`);
237
242
  }
238
243
  else {
239
- console.log(`FAIL: GET ${mcpPath} browser -> ${probe.statusCode} (body: ${probe.body.slice(0, 120)})`);
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: GET ${mcpPath} browser -> error: ${message}`);
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,10 +9,20 @@ 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,
15
24
  checkCommentLength,
25
+ checkLength,
16
26
  createLock,
17
27
  dbPath,
18
28
  getAgoDays,
@@ -45,6 +55,7 @@ import {
45
55
  isReportAvailable,
46
56
  isTimeStamp,
47
57
  isURL,
58
+ isValidAuthCode,
48
59
  jobsPath,
49
60
  makeBreakable,
50
61
  minifyURL,
@@ -83,31 +94,49 @@ test('checkCommentLength rejects a comment longer than 1000 characters', () => {
83
94
  });
84
95
  });
85
96
 
86
- test('checkCommentDuplicate accepts a comment with no existing comments', () => {
87
- const result = checkCommentDuplicate([], 'a'.repeat(20));
97
+ test('checkLength accepts text at the minimum bound', () => {
98
+ const result = checkLength('a'.repeat(5), 5, 10, 'reason');
88
99
  assert.deepEqual(result, {status: 'ok'});
89
100
  });
90
101
 
91
- test('checkCommentDuplicate rejects a comment repeating one submitted within the last 1000 seconds', () => {
92
- const content = 'a'.repeat(20);
93
- const comments = [{timeStamp: getNowStamp(), content}];
94
- const result = checkCommentDuplicate(comments, content);
102
+ test('checkLength accepts text at the maximum bound', () => {
103
+ const result = checkLength('a'.repeat(10), 5, 10, 'reason');
104
+ assert.deepEqual(result, {status: 'ok'});
105
+ });
106
+
107
+ test('checkLength rejects text shorter than the minimum bound', () => {
108
+ const result = checkLength('a'.repeat(4), 5, 10, 'reason');
109
+ assert.deepEqual(result, {
110
+ status: 'error',
111
+ message: 'The reason must be between 5 and 10 characters long'
112
+ });
113
+ });
114
+
115
+ test('checkLength rejects text longer than the maximum bound', () => {
116
+ const result = checkLength('a'.repeat(11), 5, 10, 'reason');
95
117
  assert.deepEqual(result, {
96
118
  status: 'error',
97
- message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
119
+ message: 'The reason must be between 5 and 10 characters long'
98
120
  });
99
121
  });
100
122
 
101
- test('checkCommentDuplicate accepts a comment repeating one submitted more than 1000 seconds ago', () => {
123
+ test('checkCommentDuplicate accepts a comment with no existing comments', () => {
124
+ const result = checkCommentDuplicate([], 'a'.repeat(20));
125
+ assert.deepEqual(result, {status: 'ok'});
126
+ });
127
+
128
+ test('checkCommentDuplicate rejects a comment identical to one already stored, regardless of when', () => {
102
129
  const content = 'a'.repeat(20);
103
- const oldTimeStamp = getTimeStamp(new Date(Date.now() - 2000000));
104
- const comments = [{timeStamp: oldTimeStamp, content}];
130
+ const comments = [{content}];
105
131
  const result = checkCommentDuplicate(comments, content);
106
- assert.deepEqual(result, {status: 'ok'});
132
+ assert.deepEqual(result, {
133
+ status: 'error',
134
+ message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
135
+ });
107
136
  });
108
137
 
109
- test('checkCommentDuplicate accepts a comment that differs from a recent one', () => {
110
- 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)}];
111
140
  const result = checkCommentDuplicate(comments, 'b'.repeat(20));
112
141
  assert.deepEqual(result, {status: 'ok'});
113
142
  });
@@ -337,6 +366,42 @@ test('isURL returns false for an invalid URL', () => {
337
366
  assert.equal(isURL('not-a-url'), false);
338
367
  });
339
368
 
369
+ test('isValidAuthCode returns true when the code matches AUTH_CODE', () => {
370
+ const saved = process.env.AUTH_CODE;
371
+ process.env.AUTH_CODE = 'secret-code';
372
+ try {
373
+ assert.equal(isValidAuthCode('secret-code'), true);
374
+ }
375
+ finally {
376
+ if (saved === undefined) {
377
+ delete process.env.AUTH_CODE;
378
+ }
379
+ else {
380
+ process.env.AUTH_CODE = saved;
381
+ }
382
+ }
383
+ });
384
+
385
+ test('isValidAuthCode returns false when the code does not match AUTH_CODE', () => {
386
+ const saved = process.env.AUTH_CODE;
387
+ process.env.AUTH_CODE = 'secret-code';
388
+ try {
389
+ assert.equal(isValidAuthCode('wrong-code'), false);
390
+ }
391
+ finally {
392
+ if (saved === undefined) {
393
+ delete process.env.AUTH_CODE;
394
+ }
395
+ else {
396
+ process.env.AUTH_CODE = saved;
397
+ }
398
+ }
399
+ });
400
+
401
+ test('isValidAuthCode returns false for an undefined code', () => {
402
+ assert.equal(isValidAuthCode(undefined), false);
403
+ });
404
+
340
405
  test('makeBreakable inserts wbr before non-initial slashes', () => {
341
406
  assert.equal(makeBreakable('/api/listReports'), '/api<wbr>/listReports');
342
407
  });
package/util.ts CHANGED
@@ -173,6 +173,24 @@ export type ReportData = {
173
173
  const alphaCompare = (a: string, b: string) => a.localeCompare(b, 'en', {sensitivity: 'base'});
174
174
  // Sorts strings alphabetically and case-insensitively.
175
175
  const alphaSort = (strings: string[]) => strings.sort((a, b) => alphaCompare(a, b));
176
+ // Returns whether a string's length is within the given bounds. Generic length check
177
+ // shared by any field with a min/max character-count rule (e.g. a request description
178
+ // or reason), so each caller states its own bounds and label instead of hardcoding an
179
+ // ad hoc length comparison.
180
+ export const checkLength = (
181
+ text: string, min: number, max: number, label: string
182
+ ): {status: 'ok'} | {status: 'error'; message: string} => {
183
+ const {length} = text;
184
+ // If the length is invalid:
185
+ if (length < min || length > max) {
186
+ // Return the applicable error message.
187
+ return {
188
+ status: 'error',
189
+ message: `The ${label} must be between ${min} and ${max} characters long`
190
+ };
191
+ }
192
+ return {status: 'ok'};
193
+ };
176
194
  // Returns whether a comment's raw length is within the 20-to-1000-character bounds.
177
195
  export const checkCommentLength = (
178
196
  content: string
@@ -189,27 +207,22 @@ export const checkCommentLength = (
189
207
  }
190
208
  return {status: 'ok'};
191
209
  };
192
- // Returns whether a comment repeats, verbatim, one of the given comments submitted
193
- // within the last 1000 seconds. Comment time stamps use Kilotest's own timeStamp
194
- // format (see getTimeStamp), not an ISO date-time string, so this integrates with
195
- // the same comments.json shape that web/tutorial/index.ts already uses. The content
196
- // passed in should be the content as it will be stored (e.g. after sanitization),
197
- // so that it is compared on the same basis as the stored comments.
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.
198
217
  export const checkCommentDuplicate = (
199
- comments: {timeStamp: string; content: string}[], content: string
218
+ comments: {content: string}[], content: string
200
219
  ): {status: 'ok'} | {status: 'error'; message: string} => {
201
- // Get the comments submitted within the last 1000 seconds with the same content.
202
- // Comment time stamps are always valid, because this codebase is the sole writer
203
- // of comments.json.
204
- const duplicates = comments
205
- .filter(comment => getDateTime(comment.timeStamp)!.getTime() > Date.now() - 1000000)
206
- .filter(comment => comment.content === content);
207
- // If the comment duplicates a recent one:
208
- if (duplicates.length) {
220
+ // If the comment duplicates one already stored:
221
+ if (comments.some(comment => comment.content === content)) {
209
222
  // Return this.
210
223
  return {
211
224
  status: 'error',
212
- message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
225
+ message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
213
226
  };
214
227
  }
215
228
  // Otherwise, the comment is not a duplicate.
@@ -536,6 +549,12 @@ export const isURL = (string: string): boolean => {
536
549
  return false;
537
550
  }
538
551
  };
552
+ // Returns whether a string is the authorization code, the shared check every
553
+ // manager-facing page and action uses to gate a submission. Centralized so all call
554
+ // sites compare against process.env.AUTH_CODE the same way.
555
+ export const isValidAuthCode = (authCode: string | undefined | null): boolean => {
556
+ return !!authCode && authCode === process.env.AUTH_CODE;
557
+ };
539
558
  // Makes a string breakable before non-initial slashes.
540
559
  export const makeBreakable = (string: string): string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
541
560
  // Minifies a URL for duplicate detection.
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from 'node:fs/promises';
9
9
  import {balancePath} from '../../balances.ts';
10
- import {getJSON, populateTemplate} from '../../util.ts';
10
+ import {getJSON, isValidAuthCode, populateTemplate} from '../../util.ts';
11
11
 
12
12
  // FUNCTIONS
13
13
 
@@ -22,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
22
22
  // If the form has been submitted:
23
23
  if (method === 'POST' && newBalanceString) {
24
24
  // If the authorization code is valid:
25
- if (authCode === process.env.AUTH_CODE) {
25
+ if (isValidAuthCode(authCode)) {
26
26
  const newBalance = Number.parseFloat(newBalanceString);
27
27
  // If the new balance is valid:
28
28
  if (
@@ -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&note=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&note=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&note=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
+ `&note=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}` +
122
+ `&note=tutorialAI%09${encodeURIComponent('An AI tutorial comment')}` +
123
+ `&note=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&note=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&note=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&note=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>