@jrpool/kilotest 46.1.2 → 47.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/.claude/settings.local.json +13 -1
  2. package/.devin/config.local.json +5 -1
  3. package/.github/workflows/ci.yml +0 -3
  4. package/.github/workflows/periodic-smoke-tests.yml +30 -0
  5. package/.github/workflows/publish-mcp.yml +4 -4
  6. package/{alerts.test.js → alerts.test.ts} +17 -29
  7. package/{alerts.js → alerts.ts} +10 -13
  8. package/api/{getReport.test.js → getReport.test.ts} +9 -10
  9. package/api/{getReport.js → getReport.ts} +23 -24
  10. package/api/{listDiagnoses.test.js → listDiagnoses.test.ts} +26 -27
  11. package/api/{listDiagnoses.js → listDiagnoses.ts} +46 -70
  12. package/api/{listIssues.test.js → listIssues.test.ts} +21 -14
  13. package/api/{listIssues.js → listIssues.ts} +75 -74
  14. package/api/{listReports.test.js → listReports.test.ts} +17 -16
  15. package/api/listReports.ts +87 -0
  16. package/api/{listViolators.test.js → listViolators.test.ts} +19 -20
  17. package/api/{listViolators.js → listViolators.ts} +45 -70
  18. package/api/requestFeature.test.ts +54 -0
  19. package/api/{requestFeature.js → requestFeature.ts} +18 -15
  20. package/api/{requestRetest.test.js → requestRetest.test.ts} +33 -28
  21. package/api/{requestRetest.js → requestRetest.ts} +32 -25
  22. package/api/{requestTest.test.js → requestTest.test.ts} +35 -30
  23. package/api/{requestTest.js → requestTest.ts} +35 -28
  24. package/api/{routes.test.js → routes.test.ts} +5 -5
  25. package/api/{routes.js → routes.ts} +6 -6
  26. package/api/{schemas.js → schemas.ts} +69 -50
  27. package/api/{util.test.js → util.test.ts} +31 -11
  28. package/api/{util.js → util.ts} +61 -36
  29. package/api/version.ts +8 -0
  30. package/{apiIntegration.test.js → apiIntegration.test.ts} +12 -13
  31. package/balances.ts +87 -0
  32. package/{checkinfo.js → checkinfo.ts} +24 -20
  33. package/docs/AI-TOOLS.md +4 -4
  34. package/docs/BACKLOG.md +356 -0
  35. package/docs/SERVICE.md +144 -17
  36. package/docs/decisions/002-coverage-tool.md +40 -0
  37. package/docs/decisions/003-adr-threshold.md +34 -0
  38. package/docs/decisions/004-docs-directory.md +23 -0
  39. package/docs/decisions/005-observability.md +28 -0
  40. package/docs/decisions/006-git-workflow.md +28 -0
  41. package/docs/decisions/007-internal-testing.md +34 -0
  42. package/docs/decisions/008-dependencies.md +28 -0
  43. package/docs/decisions/009-qai-integration.md +40 -0
  44. package/docs/decisions/QAI-HISTORICAL-CONTEXT.md +42 -0
  45. package/eslint.config.mjs +27 -0
  46. package/index.html +3 -3
  47. package/{index.test.js → index.test.ts} +380 -143
  48. package/{index.js → index.ts} +382 -283
  49. package/{indexBalances.test.js → indexBalances.test.ts} +21 -17
  50. package/{indexServe.test.js → indexServe.test.ts} +9 -9
  51. package/linkValidation.test.ts +190 -0
  52. package/{mcp.test.js → mcp.test.ts} +53 -26
  53. package/{mcp.js → mcp.ts} +29 -20
  54. package/memory/MEMORY.md +7 -0
  55. package/memory/user_maintainer_context.md +24 -0
  56. package/openapi.yaml +115 -68
  57. package/package.json +35 -15
  58. package/pm2.config.cjs +29 -0
  59. package/{routes.test.js → routes.test.ts} +8 -7
  60. package/{routing.test.js → routing.test.ts} +11 -12
  61. package/scripts/{generate-openapi.js → generate-openapi.ts} +13 -13
  62. package/serve.ts +15 -0
  63. package/smokeTest.ts +234 -0
  64. package/test/dbFixture.ts +21 -0
  65. package/test/fixtures/{buildFixtures.js → buildFixtures.ts} +14 -13
  66. package/test/{setup.cjs → setup.ts} +2 -2
  67. package/tsconfig.json +16 -0
  68. package/{util.test.js → util.test.ts} +350 -257
  69. package/util.ts +921 -0
  70. package/web/ai0BalanceForm/{ai0BalanceForm.test.js → ai0BalanceForm.test.ts} +15 -15
  71. package/web/ai0BalanceForm/{index.js → index.ts} +10 -18
  72. package/web/enqueue/index.ts +56 -0
  73. package/web/enqueueForm/index.html +9 -9
  74. package/web/enqueueForm/index.ts +43 -0
  75. package/web/expungeReportsForm/{expungeReportsForm.test.js → expungeReportsForm.test.ts} +18 -20
  76. package/web/expungeReportsForm/index.ts +18 -0
  77. package/web/hideReportForm/{hideReportForm.test.js → hideReportForm.test.ts} +14 -14
  78. package/web/hideReportForm/{index.js → index.ts} +17 -21
  79. package/web/listDiagnoses/{index.js → index.ts} +48 -58
  80. package/web/listDiagnoses/{listDiagnoses.test.js → listDiagnoses.test.ts} +39 -34
  81. package/web/listIssues/{index.js → index.ts} +67 -92
  82. package/web/listIssues/{listIssues.test.js → listIssues.test.ts} +45 -51
  83. package/web/listReports/index.html +3 -3
  84. package/web/listReports/{index.js → index.ts} +48 -53
  85. package/web/listReports/{listReports.test.js → listReports.test.ts} +41 -43
  86. package/web/listRules/{index.js → index.ts} +18 -23
  87. package/web/listRules/{listRules.test.js → listRules.test.ts} +26 -19
  88. package/web/listTopIssues/{index.js → index.ts} +36 -47
  89. package/web/listTopIssues/{listTopIssues.test.js → listTopIssues.test.ts} +26 -17
  90. package/web/listViolators/{index.js → index.ts} +51 -71
  91. package/web/listViolators/{listViolators.test.js → listViolators.test.ts} +43 -36
  92. package/web/manage/index.ts +22 -0
  93. package/web/pruneReportsForm/index.ts +18 -0
  94. package/web/pruneReportsForm/{pruneReportsForm.test.js → pruneReportsForm.test.ts} +11 -11
  95. package/web/reannotate/index.ts +70 -0
  96. package/web/reannotate/{reannotate.test.js → reannotate.test.ts} +19 -22
  97. package/web/reannotateForm/{index.js → index.ts} +41 -50
  98. package/web/reannotateForm/{reannotateForm.test.js → reannotateForm.test.ts} +13 -16
  99. package/web/renewWCAG/{index.js → index.ts} +9 -9
  100. package/web/renewWCAG/{renewWCAG.test.js → renewWCAG.test.ts} +17 -20
  101. package/web/renewWCAGForm/index.ts +22 -0
  102. package/web/{expungeReportsForm/index.js → reportDeletion.ts} +56 -38
  103. package/web/requestRetest/index.html +7 -7
  104. package/web/requestRetest/index.ts +31 -0
  105. package/web/requestRetest/requestRetest.test.ts +116 -0
  106. package/web/requestRetestForm/{index.js → index.ts} +10 -16
  107. package/web/requestRetestForm/{requestRetestForm.test.js → requestRetestForm.test.ts} +11 -12
  108. package/web/requestTest/index.html +7 -7
  109. package/web/requestTest/{index.js → index.ts} +5 -5
  110. package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +21 -21
  111. package/web/requestTestForm/index.html +4 -4
  112. package/web/requestTestForm/index.ts +21 -0
  113. package/web/requestTestForm/{requestTestForm.test.js → requestTestForm.test.ts} +9 -9
  114. package/web/rewindReportsForm/index.ts +18 -0
  115. package/web/rewindReportsForm/{rewindReportsForm.test.js → rewindReportsForm.test.ts} +11 -11
  116. package/web/tutorialAI/index.html +184 -0
  117. package/web/tutorialAI/index.ts +80 -0
  118. package/web/tutorialAI/tutorialAI.test.ts +192 -0
  119. package/web/{tutorial → tutorialWeb}/index.html +9 -8
  120. package/web/tutorialWeb/index.ts +78 -0
  121. package/web/tutorialWeb/tutorialWeb.test.ts +195 -0
  122. package/web/unhideReportForm/{index.js → index.ts} +17 -21
  123. package/web/unhideReportForm/{unhideReportForm.test.js → unhideReportForm.test.ts} +16 -16
  124. package/aceconfig.js +0 -28
  125. package/api/listReports.js +0 -89
  126. package/api/requestFeature.test.js +0 -58
  127. package/api/version.js +0 -8
  128. package/indexFallback.test.js +0 -33
  129. package/indexWorkers.test.js +0 -79
  130. package/jsconfig.json +0 -8
  131. package/pm2.config.js +0 -18
  132. package/smokeTest.js +0 -128
  133. package/util.js +0 -744
  134. package/web/enqueue/index.js +0 -67
  135. package/web/enqueueForm/index.js +0 -49
  136. package/web/manage/index.js +0 -22
  137. package/web/pruneReportsForm/index.js +0 -123
  138. package/web/reannotate/index.js +0 -58
  139. package/web/renewWCAGForm/index.js +0 -22
  140. package/web/requestRetest/index.js +0 -31
  141. package/web/requestRetest/requestRetest.test.js +0 -119
  142. package/web/requestTestForm/index.js +0 -21
  143. package/web/rewindReportsForm/index.js +0 -121
  144. package/web/tutorial/index.js +0 -63
  145. package/web/tutorial/tutorial.test.js +0 -97
  146. /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
  147. /package/web/{tutorial → tutorialWeb}/images/newsletter-form.png +0 -0
@@ -0,0 +1,192 @@
1
+ /*
2
+ tutorialAI.test.ts
3
+ Unit tests for web/tutorialAI/index.ts, covering the answer and handleComment exports.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, 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/comments');
16
+ process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testDir, 'tutorialAI.json');
17
+
18
+ import {answer, handleComment} from './index.ts';
19
+
20
+ // CONSTANTS
21
+
22
+ const getCommentsPath = () => process.env.TUTORIAL_AI_COMMENTS_PATH || path.join(import.meta.dirname, '../../db/comments/tutorialAI.json');
23
+
24
+ // SETUP AND TEARDOWN
25
+
26
+ before(async () => {
27
+ await fs.mkdir(testDir, {recursive: true});
28
+ await fs.writeFile(getCommentsPath(), '[]\n');
29
+ });
30
+
31
+ after(async () => {
32
+ // Cleanup is handled by index.test.ts since multiple test files use this shared directory.
33
+ });
34
+
35
+ // TESTS
36
+
37
+ test('answer returns the AI tutorial page with status ok', async () => {
38
+ const result = await answer();
39
+ assert.equal(result.status, 'ok');
40
+ assert.ok(result.answerPage);
41
+ assert.ok(result.answerPage.length > 0);
42
+ });
43
+
44
+ test('handleComment returns an error for empty content', async () => {
45
+ const result = await handleComment('');
46
+ assert.equal(result.status, 'error');
47
+ assert.equal(result.message, 'No content provided');
48
+ });
49
+
50
+ test('handleComment returns an error for non-string content', async () => {
51
+ const result = await handleComment(null);
52
+ assert.equal(result.status, 'error');
53
+ assert.equal(result.message, 'No content provided');
54
+ });
55
+
56
+ test('handleComment returns an error for content that is empty after sanitization', async () => {
57
+ const result = await handleComment(`${' '.repeat(10)}<script></script>${' '.repeat(10)}`);
58
+ assert.equal(result.status, 'error');
59
+ assert.equal(result.message, 'Comment is empty after sanitization');
60
+ });
61
+
62
+ test('handleComment returns an error for a comment shorter than 20 characters', async () => {
63
+ const result = await handleComment('short');
64
+ assert.equal(result.status, 'error');
65
+ assert.equal(result.message, 'Your comment was shorter than 20 characters');
66
+ });
67
+
68
+ test('handleComment returns an error for a comment longer than 1000 characters', async () => {
69
+ const result = await handleComment('x'.repeat(1001));
70
+ assert.equal(result.status, 'error');
71
+ assert.equal(result.message, 'Your comment was longer than 1000 characters');
72
+ });
73
+
74
+ test('handleComment returns an error for a comment repeating one submitted within the last 1000 seconds', {timeout: 500}, async () => {
75
+ await fs.writeFile(getCommentsPath(), '[]\n');
76
+ const content = 'This is a duplicate comment.';
77
+ const firstResult = await handleComment(content);
78
+ assert.equal(firstResult.status, 'ok');
79
+ const secondResult = await handleComment(content);
80
+ assert.equal(secondResult.status, 'error');
81
+ assert.equal(
82
+ secondResult.message,
83
+ 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
84
+ );
85
+ });
86
+
87
+ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, async () => {
88
+ await fs.writeFile(getCommentsPath(), '[]\n');
89
+ const result = await handleComment('This is a <b>test</b> comment.');
90
+ assert.equal(result.status, 'ok');
91
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
92
+ assert.equal(comments.length, 1);
93
+ assert.equal(comments[0].content, 'This is a test comment.');
94
+ assert.ok(comments[0].dateTime);
95
+ });
96
+
97
+ test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
98
+ await fs.writeFile(getCommentsPath(), '[]\n');
99
+ const result = await handleComment('<img src=x onerror=alert(1)>\x00\x07Hello');
100
+ assert.equal(result.status, 'ok');
101
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
102
+ assert.equal(comments[0].content, 'Hello');
103
+ });
104
+
105
+ test('handleComment accepts a comment of exactly 1000 characters', {timeout: 500}, async () => {
106
+ await fs.writeFile(getCommentsPath(), '[]\n');
107
+ const maxComment = 'x'.repeat(1000);
108
+ const result = await handleComment(maxComment);
109
+ assert.equal(result.status, 'ok');
110
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
111
+ assert.equal(comments[0].content.length, 1000);
112
+ });
113
+
114
+ test('handleComment creates comments.json when it does not exist', {timeout: 500}, async () => {
115
+ await fs.unlink(getCommentsPath()).catch(() => {});
116
+ const result = await handleComment('Test comment for missing file');
117
+ assert.equal(result.status, 'ok');
118
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
119
+ assert.equal(comments.length, 1);
120
+ assert.equal(comments[0].content, 'Test comment for missing file');
121
+ });
122
+
123
+ test('handleComment creates the comments directory when it does not exist', {timeout: 500}, async () => {
124
+ const savedEnv = process.env.TUTORIAL_AI_COMMENTS_PATH;
125
+ const missingDirPath = path.join(testDir, 'new-subdir', 'tutorialAI.json');
126
+ process.env.TUTORIAL_AI_COMMENTS_PATH = missingDirPath;
127
+ try {
128
+ const result = await handleComment('Test comment for missing directory');
129
+ assert.equal(result.status, 'ok');
130
+ const comments = JSON.parse(await fs.readFile(missingDirPath, 'utf8'));
131
+ assert.equal(comments[0].content, 'Test comment for missing directory');
132
+ } finally {
133
+ process.env.TUTORIAL_AI_COMMENTS_PATH = savedEnv;
134
+ await fs.rm(path.dirname(missingDirPath), {recursive: true, force: true});
135
+ }
136
+ });
137
+
138
+ test('getCommentsPath uses environment variable when set', {timeout: 500}, async () => {
139
+ const envValue = process.env.TUTORIAL_AI_COMMENTS_PATH;
140
+ assert.ok(envValue);
141
+ assert.ok(getCommentsPath().includes('test/fixtures/comments'));
142
+ });
143
+
144
+ test('getCommentsPath falls back to default path when environment variable is not set', {timeout: 500}, async () => {
145
+ const savedEnv = process.env.TUTORIAL_AI_COMMENTS_PATH;
146
+ delete process.env.TUTORIAL_AI_COMMENTS_PATH;
147
+ try {
148
+ const path = getCommentsPath();
149
+ assert.ok(path.includes('db/comments'));
150
+ assert.ok(path.includes('tutorialAI.json'));
151
+ } finally {
152
+ process.env.TUTORIAL_AI_COMMENTS_PATH = savedEnv;
153
+ }
154
+ });
155
+
156
+ test('handleComment uses fallback path when environment variable is not set', {timeout: 500}, async () => {
157
+ const savedEnv = process.env.TUTORIAL_AI_COMMENTS_PATH;
158
+ const defaultPath = path.join(import.meta.dirname, '../../db/comments/tutorialAI.json');
159
+ const backupPath = defaultPath + '.backup';
160
+
161
+ // Backup the default comments file if it exists.
162
+ let hadDefault = false;
163
+ try {
164
+ const content = await fs.readFile(defaultPath, 'utf8');
165
+ await fs.writeFile(backupPath, content);
166
+ hadDefault = true;
167
+ } catch {
168
+ // File doesn't exist, that's fine.
169
+ }
170
+
171
+ delete process.env.TUTORIAL_AI_COMMENTS_PATH;
172
+ try {
173
+ // Write a test file to the default location.
174
+ await fs.mkdir(path.dirname(defaultPath), {recursive: true});
175
+ await fs.writeFile(defaultPath, '[]\n');
176
+ const result = await handleComment('Test with fallback path');
177
+ assert.equal(result.status, 'ok');
178
+ const comments = JSON.parse(await fs.readFile(defaultPath, 'utf8'));
179
+ assert.equal(comments.length, 1);
180
+ assert.equal(comments[0].content, 'Test with fallback path');
181
+ } finally {
182
+ // Restore the original state.
183
+ if (hadDefault) {
184
+ const backup = await fs.readFile(backupPath, 'utf8');
185
+ await fs.writeFile(defaultPath, backup);
186
+ await fs.unlink(backupPath);
187
+ } else {
188
+ await fs.unlink(defaultPath).catch(() => {});
189
+ }
190
+ process.env.TUTORIAL_AI_COMMENTS_PATH = savedEnv;
191
+ }
192
+ });
@@ -314,7 +314,7 @@
314
314
  <p>We shall look for this single issue on a single web page. It is the home page of a JavaScript-rendered WordPress site belonging to a nonprofit organization. It was tested in May 2026.</p>
315
315
  <p>The page contains a form for requesting a guide published by the organization. This form is not present in the raw HTML that the server delivers, but is injected into the page by a JavaScript plugin after the browser has loaded and executed the page scripts. The form contains one visible user-facing input field. Here is a screenshot of the form:</p>
316
316
  <img
317
- src="/tutorial/images/newsletter-form.png"
317
+ src="/tutorialWeb/images/newsletter-form.png"
318
318
  alt="Career-guide request form containing an input with placeholder text Enter your email and a button labelled Get the career guide"
319
319
  width="602"
320
320
  height="57"
@@ -444,17 +444,18 @@
444
444
  <h2>Suggest improvements</h2>
445
445
  <p>Please submit suggestions for improving this tutorial below. The Kilotest managers will review them.</p>
446
446
  <form id="comment-form" novalidate>
447
- <p id="comment-label">Your suggestions (up to 500 characters):</p>
447
+ <p id="comment-label">Your suggestions (20–1000 characters):</p>
448
448
  <textarea
449
449
  id="comment-text"
450
450
  name="content"
451
- maxlength="500"
451
+ maxlength="1000"
452
+ minlength="20"
452
453
  rows="5"
453
454
  autocomplete="off"
454
455
  aria-labelledby="comment-label"
455
456
  aria-describedby="char-count-msg"
456
457
  ></textarea>
457
- <p class="char-count" id="char-count-msg"><span id="char-remaining">500</span> characters remaining</p>
458
+ <p class="char-count" id="char-count-msg"><span id="char-remaining">1000</span> characters remaining</p>
458
459
  <button type="submit" id="comment-submit">Submit suggestions</button>
459
460
  </form>
460
461
  <div role="status" id="comment-diagnosis" aria-live="polite" aria-atomic="true" class="comment-diagnosis"></div>
@@ -593,9 +594,9 @@
593
594
  const commentSubmit = document.getElementById('comment-submit');
594
595
 
595
596
  commentText.addEventListener('input', function () {
596
- const remaining = 500 - this.value.length;
597
+ const remaining = 1000 - this.value.length;
597
598
  charRemaining.textContent = remaining;
598
- charCountMsg.className = remaining <= 50 ? 'char-count low' : 'char-count';
599
+ charCountMsg.className = remaining <= 100 ? 'char-count low' : 'char-count';
599
600
  });
600
601
 
601
602
  document.getElementById('comment-form').addEventListener('submit', async function (e) {
@@ -610,7 +611,7 @@
610
611
  }
611
612
  commentSubmit.disabled = true;
612
613
  try {
613
- const response = await fetch('/tutorialComment.html', {
614
+ const response = await fetch('/tutorialWebComment.html', {
614
615
  method: 'POST',
615
616
  headers: {'Content-Type': 'application/json'},
616
617
  body: JSON.stringify({content})
@@ -621,7 +622,7 @@
621
622
  commentFeedback.textContent =
622
623
  'Thank you! Your suggestion has been saved and will be reviewed by the tutorial authors.';
623
624
  this.reset();
624
- charRemaining.textContent = '500';
625
+ charRemaining.textContent = '1000';
625
626
  charCountMsg.className = 'char-count';
626
627
  } else {
627
628
  commentFeedback.className = 'comment-diagnosis error';
@@ -0,0 +1,78 @@
1
+ /*
2
+ index.ts
3
+ Serves the web tutorial and saves web tutorial comments.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {sendAlert} from '../../alerts.ts';
9
+ import {checkCommentDuplicate, checkCommentLength, getJSON, getNowStamp} from '../../util.ts';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+
13
+ // FUNCTIONS (helpers)
14
+
15
+ const getCommentsPath = () => process.env.TUTORIAL_WEB_COMMENTS_PATH || path.join(import.meta.dirname, '../../db/comments/tutorialWeb.json');
16
+
17
+ // FUNCTIONS
18
+
19
+ // Strips HTML tags and control characters, trims, and limits to 1000 characters.
20
+ const sanitize = (str: string) => str
21
+ .replace(/<[^>]*>/g, '')
22
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
23
+ .trim()
24
+ .slice(0, 1000);
25
+
26
+ // Returns the tutorial page.
27
+ export const answer = async () => {
28
+ const answerPage = await fs.readFile(path.join(import.meta.dirname, 'index.html'), 'utf8');
29
+ return {
30
+ status: 'ok',
31
+ answerPage
32
+ };
33
+ };
34
+ // Sanitizes and saves a tutorial comment to comments.json.
35
+ export const handleComment = async (content: unknown) => {
36
+ if (!content || typeof content !== 'string') {
37
+ return {status: 'error', message: 'No content provided'};
38
+ }
39
+ // If the raw comment's length is invalid:
40
+ const lengthCheck = checkCommentLength(content);
41
+ if (lengthCheck.status === 'error') {
42
+ // Report this.
43
+ return lengthCheck;
44
+ }
45
+ const commentsPath = getCommentsPath();
46
+ let comments: any[] = [];
47
+ try {
48
+ // Get the existing comments.
49
+ const existing = await fs.readFile(commentsPath, 'utf8');
50
+ comments = JSON.parse(existing);
51
+ }
52
+ // If there are none:
53
+ catch {
54
+ // Initialize a comments array.
55
+ }
56
+ const sanitized = sanitize(content);
57
+ if (!sanitized) {
58
+ return {status: 'error', message: 'Comment is empty after sanitization'};
59
+ }
60
+ // If the sanitized comment duplicates one recently stored:
61
+ const duplicateCheck = checkCommentDuplicate(comments, sanitized);
62
+ if (duplicateCheck.status === 'error') {
63
+ // Report this.
64
+ return duplicateCheck;
65
+ }
66
+ // Add the comment to the existing ones.
67
+ comments.push({
68
+ timeStamp: getNowStamp(),
69
+ content: sanitized
70
+ });
71
+ // Ensure the comments directory exists.
72
+ await fs.mkdir(path.dirname(commentsPath), {recursive: true});
73
+ // Save the revised comments.
74
+ await fs.writeFile(commentsPath, getJSON(comments));
75
+ // Send an alert to the manager.
76
+ await sendAlert('New web tutorial comment received', 'A new web tutorial comment has been received.');
77
+ return {status: 'ok'};
78
+ };
@@ -0,0 +1,195 @@
1
+ /*
2
+ tutorial.test.ts
3
+ Unit tests for web/tutorial/index.ts, covering the answer and handleComment exports.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, 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/comments');
16
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testDir, 'tutorialWeb.json');
17
+
18
+ import {answer, handleComment} from './index.ts';
19
+
20
+ // CONSTANTS
21
+
22
+ const getCommentsPath = () => process.env.TUTORIAL_WEB_COMMENTS_PATH || path.join(import.meta.dirname, '../../db/comments/tutorialWeb.json');
23
+
24
+ // SETUP AND TEARDOWN
25
+
26
+ before(async () => {
27
+ await fs.mkdir(testDir, {recursive: true});
28
+ await fs.writeFile(getCommentsPath(), '[]\n');
29
+ });
30
+
31
+ after(async () => {
32
+ // Cleanup is handled by index.test.ts since multiple test files use this shared directory.
33
+ });
34
+
35
+ // TESTS
36
+
37
+ test('answer returns the tutorial page with status ok', async () => {
38
+ const result = await answer();
39
+ assert.equal(result.status, 'ok');
40
+ assert.ok(result.answerPage);
41
+ assert.ok(result.answerPage.length > 0);
42
+ });
43
+
44
+ test('handleComment returns an error for empty content', async () => {
45
+ const result = await handleComment('');
46
+ assert.equal(result.status, 'error');
47
+ assert.equal(result.message, 'No content provided');
48
+ });
49
+
50
+ test('handleComment returns an error for non-string content', async () => {
51
+ const result = await handleComment(null);
52
+ assert.equal(result.status, 'error');
53
+ assert.equal(result.message, 'No content provided');
54
+ });
55
+
56
+ test('handleComment returns an error for content that is empty after sanitization', async () => {
57
+ // Padded with spaces (stripped by sanitize's trim) to clear the 20-character length check
58
+ // on the raw content, isolating the empty-after-sanitization path from the length check.
59
+ const result = await handleComment(`${' '.repeat(10)}<script></script>${' '.repeat(10)}`);
60
+ assert.equal(result.status, 'error');
61
+ assert.equal(result.message, 'Comment is empty after sanitization');
62
+ });
63
+
64
+ test('handleComment returns an error for a comment shorter than 20 characters', async () => {
65
+ const result = await handleComment('short');
66
+ assert.equal(result.status, 'error');
67
+ assert.equal(result.message, 'Your comment was shorter than 20 characters');
68
+ });
69
+
70
+ test('handleComment returns an error for a comment longer than 1000 characters', async () => {
71
+ const result = await handleComment('x'.repeat(1001));
72
+ assert.equal(result.status, 'error');
73
+ assert.equal(result.message, 'Your comment was longer than 1000 characters');
74
+ });
75
+
76
+ test('handleComment returns an error for a comment repeating one submitted within the last 1000 seconds', {timeout: 500}, async () => {
77
+ await fs.writeFile(getCommentsPath(), '[]\n');
78
+ const content = 'This is a duplicate comment.';
79
+ const firstResult = await handleComment(content);
80
+ assert.equal(firstResult.status, 'ok');
81
+ const secondResult = await handleComment(content);
82
+ assert.equal(secondResult.status, 'error');
83
+ assert.equal(
84
+ secondResult.message,
85
+ 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
86
+ );
87
+ });
88
+
89
+ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, async () => {
90
+ // Replace comments with an empty array for a clean test.
91
+ await fs.writeFile(getCommentsPath(), '[]\n');
92
+ const result = await handleComment('This is a <b>test</b> comment.');
93
+ assert.equal(result.status, 'ok');
94
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
95
+ assert.equal(comments.length, 1);
96
+ assert.equal(comments[0].content, 'This is a test comment.');
97
+ assert.ok(comments[0].timeStamp);
98
+ });
99
+
100
+ test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
101
+ await fs.writeFile(getCommentsPath(), '[]\n');
102
+ const result = await handleComment('<img src=x onerror=alert(1)>\x00\x07Hello');
103
+ assert.equal(result.status, 'ok');
104
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
105
+ assert.equal(comments[0].content, 'Hello');
106
+ });
107
+
108
+ test('handleComment accepts a comment of exactly 1000 characters', {timeout: 500}, async () => {
109
+ await fs.writeFile(getCommentsPath(), '[]\n');
110
+ const maxComment = 'x'.repeat(1000);
111
+ const result = await handleComment(maxComment);
112
+ assert.equal(result.status, 'ok');
113
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
114
+ assert.equal(comments[0].content.length, 1000);
115
+ });
116
+
117
+ test('handleComment creates comments.json when it does not exist', {timeout: 500}, async () => {
118
+ await fs.unlink(getCommentsPath()).catch(() => {});
119
+ const result = await handleComment('Test comment for missing file');
120
+ assert.equal(result.status, 'ok');
121
+ const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
122
+ assert.equal(comments.length, 1);
123
+ assert.equal(comments[0].content, 'Test comment for missing file');
124
+ });
125
+
126
+ test('handleComment creates the comments directory when it does not exist', {timeout: 500}, async () => {
127
+ const savedEnv = process.env.TUTORIAL_WEB_COMMENTS_PATH;
128
+ const missingDirPath = path.join(testDir, 'new-subdir', 'tutorialWeb.json');
129
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = missingDirPath;
130
+ try {
131
+ const result = await handleComment('Test comment for missing directory');
132
+ assert.equal(result.status, 'ok');
133
+ const comments = JSON.parse(await fs.readFile(missingDirPath, 'utf8'));
134
+ assert.equal(comments[0].content, 'Test comment for missing directory');
135
+ } finally {
136
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = savedEnv;
137
+ await fs.rm(path.dirname(missingDirPath), {recursive: true, force: true});
138
+ }
139
+ });
140
+
141
+ test('getCommentsPath uses environment variable when set', {timeout: 500}, async () => {
142
+ const envValue = process.env.TUTORIAL_WEB_COMMENTS_PATH;
143
+ assert.ok(envValue);
144
+ assert.ok(getCommentsPath().includes('test/fixtures/comments'));
145
+ });
146
+
147
+ test('getCommentsPath falls back to default path when environment variable is not set', {timeout: 500}, async () => {
148
+ const savedEnv = process.env.TUTORIAL_WEB_COMMENTS_PATH;
149
+ delete process.env.TUTORIAL_WEB_COMMENTS_PATH;
150
+ try {
151
+ const path = getCommentsPath();
152
+ assert.ok(path.includes('db/comments'));
153
+ assert.ok(path.includes('tutorialWeb.json'));
154
+ } finally {
155
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = savedEnv;
156
+ }
157
+ });
158
+
159
+ test('handleComment uses fallback path when environment variable is not set', {timeout: 500}, async () => {
160
+ const savedEnv = process.env.TUTORIAL_WEB_COMMENTS_PATH;
161
+ const defaultPath = path.join(import.meta.dirname, '../../db/comments/tutorialWeb.json');
162
+ const backupPath = defaultPath + '.backup';
163
+
164
+ // Backup the default comments file if it exists.
165
+ let hadDefault = false;
166
+ try {
167
+ const content = await fs.readFile(defaultPath, 'utf8');
168
+ await fs.writeFile(backupPath, content);
169
+ hadDefault = true;
170
+ } catch {
171
+ // File doesn't exist, that's fine.
172
+ }
173
+
174
+ delete process.env.TUTORIAL_WEB_COMMENTS_PATH;
175
+ try {
176
+ // Write a test file to the default location.
177
+ await fs.mkdir(path.dirname(defaultPath), {recursive: true});
178
+ await fs.writeFile(defaultPath, '[]\n');
179
+ const result = await handleComment('Test with fallback path');
180
+ assert.equal(result.status, 'ok');
181
+ const comments = JSON.parse(await fs.readFile(defaultPath, 'utf8'));
182
+ assert.equal(comments.length, 1);
183
+ assert.equal(comments[0].content, 'Test with fallback path');
184
+ } finally {
185
+ // Restore the original state.
186
+ if (hadDefault) {
187
+ const backup = await fs.readFile(backupPath, 'utf8');
188
+ await fs.writeFile(defaultPath, backup);
189
+ await fs.unlink(backupPath);
190
+ } else {
191
+ await fs.unlink(defaultPath).catch(() => {});
192
+ }
193
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = savedEnv;
194
+ }
195
+ });
@@ -1,18 +1,18 @@
1
1
  /*
2
- index.js
2
+ index.ts
3
3
  Serves a form for unhiding a report.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {hiddenReportsPath, reportsPath} = require('../../util');
9
- const fs = require('fs/promises');
10
- const path = require('path');
8
+ import {hiddenReportsPath, populateTemplate, reportsPath} from '../../util.ts';
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
11
 
12
12
  // FUNCTIONS
13
13
 
14
14
  // Returns a form for unhiding a report.
15
- exports.answer = async (_, search) => {
15
+ export const answer = async (_: any, search: string) => {
16
16
  const searchParams = new URLSearchParams(search);
17
17
  const authCode = searchParams?.get('authCode');
18
18
  const jobName = searchParams?.get('report');
@@ -25,7 +25,7 @@ exports.answer = async (_, search) => {
25
25
  // Move the report to the reports directory.
26
26
  await fs.rename(path.join(hiddenReportsPath(), fileName), path.join(reportsPath(), fileName));
27
27
  }
28
- catch (error) {
28
+ catch (error: any) {
29
29
  // Return why.
30
30
  return {
31
31
  status: 'error',
@@ -43,7 +43,7 @@ exports.answer = async (_, search) => {
43
43
  }
44
44
  }
45
45
  // Initialize an array of data on reports to be unhidden.
46
- const reportsData = [];
46
+ const reportsData: any[] = [];
47
47
  // Get the names of the hidden report files.
48
48
  const hiddenReportFileNames = await fs.readdir(hiddenReportsPath());
49
49
  // For each hidden report:
@@ -54,41 +54,37 @@ exports.answer = async (_, search) => {
54
54
  const report = JSON.parse(reportJSON);
55
55
  const {id, target} = report;
56
56
  const [timeStamp, jobID] = id.split('-');
57
- const {what} = target;
57
+ const {what: description} = target;
58
58
  // Add its data to the array.
59
59
  reportsData.push({
60
- what,
60
+ description,
61
61
  timeStamp,
62
62
  jobID
63
63
  });
64
64
  }
65
65
  // Sort the data by page name and then by time stamp.
66
66
  reportsData.sort((a, b) => {
67
- if (a.what === b.what) {
67
+ if (a.description === b.description) {
68
68
  return a.timeStamp.localeCompare(b.timeStamp);
69
69
  }
70
- return a.what.localeCompare(b.what, 'en', {sensitivity: 'base'});
70
+ return a.description.localeCompare(b.description, 'en', {sensitivity: 'base'});
71
71
  });
72
- const lines = [];
72
+ const lines: string[] = [];
73
73
  const margin = ' '.repeat(12);
74
74
  // For each report:
75
75
  reportsData.forEach(data => {
76
- const {jobID, timeStamp, what} = data;
77
- const specString = `${what} (job <code>${jobID}</code> at ${timeStamp})`;
76
+ const {jobID, timeStamp, description} = data;
77
+ const specString = `${description} (job <code>${jobID}</code> at ${timeStamp})`;
78
78
  // Add a line with a radio button to unhide it.
79
79
  lines.push(
80
80
  `${margin}<p><input type="radio" name="report" value="${timeStamp}-${jobID}"> ${specString}</p>`
81
81
  );
82
82
  });
83
- const query = {
83
+ const query: Record<string, string> = {
84
84
  reports: lines.join('\n'),
85
85
  };
86
- // Get the unhiding form template.
87
- let answerPage = await fs.readFile(path.join(__dirname, 'index.html'), 'utf8');
88
- // Replace its placeholders.
89
- Object.keys(query).forEach(param => {
90
- answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), query[param]);
91
- });
86
+ // Get the populated unhiding form template.
87
+ const answerPage = await populateTemplate(import.meta.dirname, query);
92
88
  // Return the populated page.
93
89
  return {
94
90
  status: 'ok',