@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.
- package/.claude/settings.local.json +13 -1
- package/.devin/config.local.json +5 -1
- package/.github/workflows/ci.yml +0 -3
- package/.github/workflows/periodic-smoke-tests.yml +30 -0
- package/.github/workflows/publish-mcp.yml +4 -4
- package/{alerts.test.js → alerts.test.ts} +17 -29
- package/{alerts.js → alerts.ts} +10 -13
- package/api/{getReport.test.js → getReport.test.ts} +9 -10
- package/api/{getReport.js → getReport.ts} +23 -24
- package/api/{listDiagnoses.test.js → listDiagnoses.test.ts} +26 -27
- package/api/{listDiagnoses.js → listDiagnoses.ts} +46 -70
- package/api/{listIssues.test.js → listIssues.test.ts} +21 -14
- package/api/{listIssues.js → listIssues.ts} +75 -74
- package/api/{listReports.test.js → listReports.test.ts} +17 -16
- package/api/listReports.ts +87 -0
- package/api/{listViolators.test.js → listViolators.test.ts} +19 -20
- package/api/{listViolators.js → listViolators.ts} +45 -70
- package/api/requestFeature.test.ts +54 -0
- package/api/{requestFeature.js → requestFeature.ts} +18 -15
- package/api/{requestRetest.test.js → requestRetest.test.ts} +33 -28
- package/api/{requestRetest.js → requestRetest.ts} +32 -25
- package/api/{requestTest.test.js → requestTest.test.ts} +35 -30
- package/api/{requestTest.js → requestTest.ts} +35 -28
- package/api/{routes.test.js → routes.test.ts} +5 -5
- package/api/{routes.js → routes.ts} +6 -6
- package/api/{schemas.js → schemas.ts} +69 -50
- package/api/{util.test.js → util.test.ts} +31 -11
- package/api/{util.js → util.ts} +61 -36
- package/api/version.ts +8 -0
- package/{apiIntegration.test.js → apiIntegration.test.ts} +12 -13
- package/balances.ts +87 -0
- package/{checkinfo.js → checkinfo.ts} +24 -20
- package/docs/AI-TOOLS.md +4 -4
- package/docs/BACKLOG.md +356 -0
- package/docs/SERVICE.md +144 -17
- package/docs/decisions/002-coverage-tool.md +40 -0
- package/docs/decisions/003-adr-threshold.md +34 -0
- package/docs/decisions/004-docs-directory.md +23 -0
- package/docs/decisions/005-observability.md +28 -0
- package/docs/decisions/006-git-workflow.md +28 -0
- package/docs/decisions/007-internal-testing.md +34 -0
- package/docs/decisions/008-dependencies.md +28 -0
- package/docs/decisions/009-qai-integration.md +40 -0
- package/docs/decisions/QAI-HISTORICAL-CONTEXT.md +42 -0
- package/eslint.config.mjs +27 -0
- package/index.html +3 -3
- package/{index.test.js → index.test.ts} +380 -143
- package/{index.js → index.ts} +382 -283
- package/{indexBalances.test.js → indexBalances.test.ts} +21 -17
- package/{indexServe.test.js → indexServe.test.ts} +9 -9
- package/linkValidation.test.ts +190 -0
- package/{mcp.test.js → mcp.test.ts} +53 -26
- package/{mcp.js → mcp.ts} +29 -20
- package/memory/MEMORY.md +7 -0
- package/memory/user_maintainer_context.md +24 -0
- package/openapi.yaml +115 -68
- package/package.json +35 -15
- package/pm2.config.cjs +29 -0
- package/{routes.test.js → routes.test.ts} +8 -7
- package/{routing.test.js → routing.test.ts} +11 -12
- package/scripts/{generate-openapi.js → generate-openapi.ts} +13 -13
- package/serve.ts +15 -0
- package/smokeTest.ts +234 -0
- package/test/dbFixture.ts +21 -0
- package/test/fixtures/{buildFixtures.js → buildFixtures.ts} +14 -13
- package/test/{setup.cjs → setup.ts} +2 -2
- package/tsconfig.json +16 -0
- package/{util.test.js → util.test.ts} +350 -257
- package/util.ts +921 -0
- package/web/ai0BalanceForm/{ai0BalanceForm.test.js → ai0BalanceForm.test.ts} +15 -15
- package/web/ai0BalanceForm/{index.js → index.ts} +10 -18
- package/web/enqueue/index.ts +56 -0
- package/web/enqueueForm/index.html +9 -9
- package/web/enqueueForm/index.ts +43 -0
- package/web/expungeReportsForm/{expungeReportsForm.test.js → expungeReportsForm.test.ts} +18 -20
- package/web/expungeReportsForm/index.ts +18 -0
- package/web/hideReportForm/{hideReportForm.test.js → hideReportForm.test.ts} +14 -14
- package/web/hideReportForm/{index.js → index.ts} +17 -21
- package/web/listDiagnoses/{index.js → index.ts} +48 -58
- package/web/listDiagnoses/{listDiagnoses.test.js → listDiagnoses.test.ts} +39 -34
- package/web/listIssues/{index.js → index.ts} +67 -92
- package/web/listIssues/{listIssues.test.js → listIssues.test.ts} +45 -51
- package/web/listReports/index.html +3 -3
- package/web/listReports/{index.js → index.ts} +48 -53
- package/web/listReports/{listReports.test.js → listReports.test.ts} +41 -43
- package/web/listRules/{index.js → index.ts} +18 -23
- package/web/listRules/{listRules.test.js → listRules.test.ts} +26 -19
- package/web/listTopIssues/{index.js → index.ts} +36 -47
- package/web/listTopIssues/{listTopIssues.test.js → listTopIssues.test.ts} +26 -17
- package/web/listViolators/{index.js → index.ts} +51 -71
- package/web/listViolators/{listViolators.test.js → listViolators.test.ts} +43 -36
- package/web/manage/index.ts +22 -0
- package/web/pruneReportsForm/index.ts +18 -0
- package/web/pruneReportsForm/{pruneReportsForm.test.js → pruneReportsForm.test.ts} +11 -11
- package/web/reannotate/index.ts +70 -0
- package/web/reannotate/{reannotate.test.js → reannotate.test.ts} +19 -22
- package/web/reannotateForm/{index.js → index.ts} +41 -50
- package/web/reannotateForm/{reannotateForm.test.js → reannotateForm.test.ts} +13 -16
- package/web/renewWCAG/{index.js → index.ts} +9 -9
- package/web/renewWCAG/{renewWCAG.test.js → renewWCAG.test.ts} +17 -20
- package/web/renewWCAGForm/index.ts +22 -0
- package/web/{expungeReportsForm/index.js → reportDeletion.ts} +56 -38
- package/web/requestRetest/index.html +7 -7
- package/web/requestRetest/index.ts +31 -0
- package/web/requestRetest/requestRetest.test.ts +116 -0
- package/web/requestRetestForm/{index.js → index.ts} +10 -16
- package/web/requestRetestForm/{requestRetestForm.test.js → requestRetestForm.test.ts} +11 -12
- package/web/requestTest/index.html +7 -7
- package/web/requestTest/{index.js → index.ts} +5 -5
- package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +21 -21
- package/web/requestTestForm/index.html +4 -4
- package/web/requestTestForm/index.ts +21 -0
- package/web/requestTestForm/{requestTestForm.test.js → requestTestForm.test.ts} +9 -9
- package/web/rewindReportsForm/index.ts +18 -0
- package/web/rewindReportsForm/{rewindReportsForm.test.js → rewindReportsForm.test.ts} +11 -11
- package/web/tutorialAI/index.html +184 -0
- package/web/tutorialAI/index.ts +80 -0
- package/web/tutorialAI/tutorialAI.test.ts +192 -0
- package/web/{tutorial → tutorialWeb}/index.html +9 -8
- package/web/tutorialWeb/index.ts +78 -0
- package/web/tutorialWeb/tutorialWeb.test.ts +195 -0
- package/web/unhideReportForm/{index.js → index.ts} +17 -21
- package/web/unhideReportForm/{unhideReportForm.test.js → unhideReportForm.test.ts} +16 -16
- package/aceconfig.js +0 -28
- package/api/listReports.js +0 -89
- package/api/requestFeature.test.js +0 -58
- package/api/version.js +0 -8
- package/indexFallback.test.js +0 -33
- package/indexWorkers.test.js +0 -79
- package/jsconfig.json +0 -8
- package/pm2.config.js +0 -18
- package/smokeTest.js +0 -128
- package/util.js +0 -744
- package/web/enqueue/index.js +0 -67
- package/web/enqueueForm/index.js +0 -49
- package/web/manage/index.js +0 -22
- package/web/pruneReportsForm/index.js +0 -123
- package/web/reannotate/index.js +0 -58
- package/web/renewWCAGForm/index.js +0 -22
- package/web/requestRetest/index.js +0 -31
- package/web/requestRetest/requestRetest.test.js +0 -119
- package/web/requestTestForm/index.js +0 -21
- package/web/rewindReportsForm/index.js +0 -121
- package/web/tutorial/index.js +0 -63
- package/web/tutorial/tutorial.test.js +0 -97
- /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
- /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="/
|
|
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 (
|
|
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="
|
|
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">
|
|
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 =
|
|
597
|
+
const remaining = 1000 - this.value.length;
|
|
597
598
|
charRemaining.textContent = remaining;
|
|
598
|
-
charCountMsg.className = remaining <=
|
|
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('/
|
|
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 = '
|
|
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.
|
|
2
|
+
index.ts
|
|
3
3
|
Serves a form for unhiding a report.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
67
|
+
if (a.description === b.description) {
|
|
68
68
|
return a.timeStamp.localeCompare(b.timeStamp);
|
|
69
69
|
}
|
|
70
|
-
return a.
|
|
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,
|
|
77
|
-
const specString = `${
|
|
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
|
-
|
|
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',
|