@jrpool/kilotest 49.0.0 → 51.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +100 -4
- package/api/requestFeature.ts +57 -13
- package/api/requestRetest.test.ts +46 -13
- package/api/requestRetest.ts +11 -5
- package/api/requestTest.test.ts +7 -0
- package/api/requestTest.ts +6 -6
- package/api/schemas.ts +2 -2
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +17 -0
- package/docs/BACKLOG.md +14 -2
- package/docs/SERVICE.md +50 -7
- package/index.test.ts +171 -7
- package/index.ts +103 -23
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +1 -1
- package/package.json +1 -1
- package/robots.txt +1 -0
- package/smokeTest.ts +10 -5
- package/util.test.ts +78 -13
- package/util.ts +35 -16
- package/web/ai0BalanceForm/index.ts +2 -2
- package/web/deleteNotesForm/deleteNotesForm.test.ts +237 -0
- package/web/deleteNotesForm/index.html +29 -0
- package/web/deleteNotesForm/index.ts +167 -0
- package/web/enqueue/index.ts +33 -42
- package/web/hideReportForm/index.ts +4 -2
- package/web/manage/index.html +1 -0
- package/web/metrics/index.ts +3 -2
- package/web/reannotate/index.ts +2 -1
- package/web/renewWCAG/index.ts +2 -2
- package/web/reportDeletion.ts +3 -2
- package/web/showHiddenReportsForm/index.ts +2 -2
- package/web/tutorialAI/index.ts +6 -11
- package/web/tutorialAI/tutorialAI.test.ts +9 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +8 -2
- package/web/unhideReportForm/index.ts +4 -2
- package/.claude/scheduled_tasks.lock +0 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/*
|
|
2
|
+
index.ts
|
|
3
|
+
Serves a form for deleting web tutorial comments, AI tutorial comments, and MCP
|
|
4
|
+
feature requests, the three kinds of notes that Kilotest records to flat JSON files
|
|
5
|
+
rather than presenting through any other manager page.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// IMPORTS
|
|
9
|
+
|
|
10
|
+
import {errorMessage, getDateTimeString, getJSON, getPlainText, isValidAuthCode, populateTemplate} from '../../util.ts';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
// TYPES
|
|
15
|
+
|
|
16
|
+
// A note (comment or feature request) as stored in one of the note files.
|
|
17
|
+
type Note = {
|
|
18
|
+
timeStamp: string;
|
|
19
|
+
content: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The source of a kind of note, identifying its file and how it is labeled.
|
|
23
|
+
type NoteSource = {
|
|
24
|
+
name: string;
|
|
25
|
+
label: string;
|
|
26
|
+
getPath: () => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// CONSTANTS
|
|
30
|
+
|
|
31
|
+
// The note sources, in the order they are listed on the form. Each source's path is
|
|
32
|
+
// read afresh on every call, via its own environment-variable override, exactly as
|
|
33
|
+
// web/tutorialWeb/index.ts and web/tutorialAI/index.ts already do for their own
|
|
34
|
+
// comment files, so that tests can point any of them at a fixture path.
|
|
35
|
+
const noteSources: NoteSource[] = [
|
|
36
|
+
{
|
|
37
|
+
name: 'tutorialWeb',
|
|
38
|
+
label: 'Web tutorial comment',
|
|
39
|
+
getPath: () => process.env.TUTORIAL_WEB_COMMENTS_PATH ||
|
|
40
|
+
path.join(import.meta.dirname, '../../db/comments/tutorialWeb.json')
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'tutorialAI',
|
|
44
|
+
label: 'AI tutorial comment',
|
|
45
|
+
getPath: () => process.env.TUTORIAL_AI_COMMENTS_PATH ||
|
|
46
|
+
path.join(import.meta.dirname, '../../db/comments/tutorialAI.json')
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'featureRequest',
|
|
50
|
+
label: 'MCP feature request',
|
|
51
|
+
getPath: () => process.env.FEATURE_REQUESTS_PATH ||
|
|
52
|
+
path.join(import.meta.dirname, '../../db/featureRequests.json')
|
|
53
|
+
}
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// FUNCTIONS (helpers)
|
|
57
|
+
|
|
58
|
+
// Returns the notes in a source's file, or an empty array if it does not exist.
|
|
59
|
+
const getNotes = async (source: NoteSource): Promise<Note[]> => {
|
|
60
|
+
try {
|
|
61
|
+
const existing = await fs.readFile(source.getPath(), 'utf8');
|
|
62
|
+
return JSON.parse(existing);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// FUNCTIONS
|
|
70
|
+
|
|
71
|
+
// Returns a form for deleting selected notes, or, on a POST request with a valid
|
|
72
|
+
// authorization code, deletes the selected notes and returns the form again. A GET
|
|
73
|
+
// request never processes a submission, regardless of its query string; only a POST
|
|
74
|
+
// request (the form's own submission) does.
|
|
75
|
+
export const answer = async (
|
|
76
|
+
_: any, search: string, method: string
|
|
77
|
+
): Promise<{status: string; message?: string; answerPage?: string}> => {
|
|
78
|
+
const searchParams = new URLSearchParams(search);
|
|
79
|
+
const authCode = searchParams?.get('authCode');
|
|
80
|
+
// Each selected checkbox's value is `${sourceName}\t${encodeURIComponent(content)}`.
|
|
81
|
+
// Content, rather than time stamp, identifies a note, because no two notes in the same
|
|
82
|
+
// source ever share content: checkCommentDuplicate rejects a submission identical to
|
|
83
|
+
// one already on file. Content is percent-encoded, and split out at the first tab
|
|
84
|
+
// only, because comment and feature-request content is not stripped of tab characters
|
|
85
|
+
// (see the sanitize functions in web/tutorialWeb/index.ts and web/tutorialAI/index.ts),
|
|
86
|
+
// so an unencoded tab within the content itself could otherwise be mistaken for the
|
|
87
|
+
// delimiter between the source name and the content.
|
|
88
|
+
const selections = searchParams?.getAll('note');
|
|
89
|
+
// If the form has been submitted and any notes are to be deleted:
|
|
90
|
+
if (method === 'POST' && selections?.length) {
|
|
91
|
+
// If the authorization code is valid:
|
|
92
|
+
if (isValidAuthCode(authCode)) {
|
|
93
|
+
try {
|
|
94
|
+
// Group the selected content by source.
|
|
95
|
+
const contentBySource = new Map<string, Set<string>>();
|
|
96
|
+
for (const selection of selections) {
|
|
97
|
+
const tabIndex = selection.indexOf('\t');
|
|
98
|
+
const sourceName = selection.slice(0, tabIndex);
|
|
99
|
+
const content = decodeURIComponent(selection.slice(tabIndex + 1));
|
|
100
|
+
if (!contentBySource.has(sourceName)) {
|
|
101
|
+
contentBySource.set(sourceName, new Set());
|
|
102
|
+
}
|
|
103
|
+
contentBySource.get(sourceName)!.add(content);
|
|
104
|
+
}
|
|
105
|
+
// For each source with any selected notes:
|
|
106
|
+
for (const source of noteSources) {
|
|
107
|
+
const contentSet = contentBySource.get(source.name);
|
|
108
|
+
if (!contentSet) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Remove the selected notes and save the rest.
|
|
112
|
+
const notes = await getNotes(source);
|
|
113
|
+
const remaining = notes.filter(note => !contentSet.has(note.content));
|
|
114
|
+
await fs.mkdir(path.dirname(source.getPath()), {recursive: true});
|
|
115
|
+
await fs.writeFile(source.getPath(), getJSON(remaining));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// If this failed:
|
|
119
|
+
catch (error: unknown) {
|
|
120
|
+
// Return why.
|
|
121
|
+
return {
|
|
122
|
+
status: 'error',
|
|
123
|
+
message: `Deleting notes failed (${errorMessage(error)})`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Otherwise, i.e. if the authorization code is invalid:
|
|
128
|
+
else {
|
|
129
|
+
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
130
|
+
// authorization code specifically (as opposed to some other part of the request) is
|
|
131
|
+
// what was wrong.
|
|
132
|
+
return {
|
|
133
|
+
status: 'error',
|
|
134
|
+
message: 'Invalid request'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const lines: string[] = [];
|
|
139
|
+
const margin = ' '.repeat(12);
|
|
140
|
+
let anyNotes = false;
|
|
141
|
+
// For each source, in display order:
|
|
142
|
+
for (const source of noteSources) {
|
|
143
|
+
const notes = await getNotes(source);
|
|
144
|
+
// For each of its notes, oldest first:
|
|
145
|
+
for (const note of notes) {
|
|
146
|
+
anyNotes = true;
|
|
147
|
+
const value = `${source.name}\t${encodeURIComponent(note.content)}`;
|
|
148
|
+
const when = getDateTimeString(note.timeStamp);
|
|
149
|
+
lines.push(
|
|
150
|
+
`${margin}<p><input type="checkbox" name="note" value="${value}"> ` +
|
|
151
|
+
`<strong>${source.label}</strong> (${when}): ${getPlainText(note.content)}</p>`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const query: Record<string, string> = {
|
|
156
|
+
notes: lines.join('\n'),
|
|
157
|
+
intro: anyNotes ?
|
|
158
|
+
'Choose the comments and feature requests to delete.' :
|
|
159
|
+
'There are no comments or feature requests to delete.',
|
|
160
|
+
disabled: anyNotes ? '' : ' disabled'
|
|
161
|
+
};
|
|
162
|
+
// Return the populated page.
|
|
163
|
+
return {
|
|
164
|
+
status: 'ok',
|
|
165
|
+
answerPage: await populateTemplate(import.meta.dirname, query)
|
|
166
|
+
};
|
|
167
|
+
};
|
package/web/enqueue/index.ts
CHANGED
|
@@ -6,54 +6,45 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
deleteTestRequests, getJSON, getNowStamp, getRandomString,
|
|
9
|
+
deleteTestRequests, getJSON, getNowStamp, getRandomString, jobsPath, populateTemplate
|
|
10
10
|
} from '../../util.ts';
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
// FUNCTIONS
|
|
15
15
|
|
|
16
|
-
// Implements a test request approval and returns a revised request page.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
status: 'ok',
|
|
48
|
-
answerPage
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
// Otherwise, i.e. if the authorization code is invalid, return an error page. The
|
|
52
|
-
// message is deliberately vague so as not to confirm to an attacker that the
|
|
53
|
-
// authorization code specifically (as opposed to some other part of the request) is
|
|
54
|
-
// what was wrong.
|
|
16
|
+
// Implements a test request approval and returns a revised request page. The caller
|
|
17
|
+
// (index.ts, this function's only caller) has already validated the URL and
|
|
18
|
+
// authorization code before invoking this function.
|
|
19
|
+
export const answer = async (url: string, description: string) => {
|
|
20
|
+
// Get the job template.
|
|
21
|
+
const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, '..', '..', 'job.json'), 'utf8');
|
|
22
|
+
const job = JSON.parse(jobTemplateJSON);
|
|
23
|
+
const nowStamp = getNowStamp();
|
|
24
|
+
// Populate the template with job properties.
|
|
25
|
+
const jobIDSuffix = getRandomString(3);
|
|
26
|
+
const jobName = `${nowStamp}-${jobIDSuffix}`;
|
|
27
|
+
job.id = jobName;
|
|
28
|
+
job.creationTimeStamp = nowStamp;
|
|
29
|
+
job.executionTimeStamp = nowStamp;
|
|
30
|
+
job.target.what = description;
|
|
31
|
+
job.target.url = url;
|
|
32
|
+
const query: Record<string, string> = {
|
|
33
|
+
target: description,
|
|
34
|
+
jobName
|
|
35
|
+
};
|
|
36
|
+
// Save the job in the queue.
|
|
37
|
+
await fs.writeFile(
|
|
38
|
+
path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job)
|
|
39
|
+
);
|
|
40
|
+
console.log(`Retest queued for ${description} as job ${jobName}`);
|
|
41
|
+
// Delete the test requests for the target.
|
|
42
|
+
await deleteTestRequests(url);
|
|
43
|
+
// Get the populated answer template.
|
|
44
|
+
const answerPage = await populateTemplate(import.meta.dirname, query);
|
|
45
|
+
// Return the populated page.
|
|
55
46
|
return {
|
|
56
|
-
status: '
|
|
57
|
-
|
|
47
|
+
status: 'ok',
|
|
48
|
+
answerPage
|
|
58
49
|
};
|
|
59
50
|
};
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getReportExtracts, hiddenReportsPath, isValidAuthCode, populateTemplate, reportsPath
|
|
10
|
+
} from '../../util.ts';
|
|
9
11
|
import fs from 'node:fs/promises';
|
|
10
12
|
import path from 'node:path';
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
20
22
|
// If the form has been submitted and a report is to be hidden:
|
|
21
23
|
if (method === 'POST' && jobName) {
|
|
22
24
|
// If the authorization code is valid:
|
|
23
|
-
if (authCode
|
|
25
|
+
if (isValidAuthCode(authCode)) {
|
|
24
26
|
const fileName = `${jobName}.json`;
|
|
25
27
|
try {
|
|
26
28
|
// Move the specified report to the directory of hidden reports.
|
package/web/manage/index.html
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<p>Kilotest managers can:</p>
|
|
17
17
|
<ul class="nav">
|
|
18
18
|
<li><a href="/enqueueForm.html">Approve or reject a test recommendation</a></li>
|
|
19
|
+
<li><a href="/deleteNotesForm.html">Delete a comment or feature request</a></li>
|
|
19
20
|
<li><a href="/reannotateForm.html">
|
|
20
21
|
Reannotate reports to incorporate newly classified and reclassified rules
|
|
21
22
|
</a></li>
|
package/web/metrics/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// IMPORTS
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics,
|
|
10
|
+
clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics, isValidAuthCode,
|
|
11
|
+
metricsExclusionCookieName, populateTemplate
|
|
11
12
|
} from '../../util.ts';
|
|
12
13
|
|
|
13
14
|
// FUNCTIONS
|
|
@@ -64,7 +65,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
64
65
|
// If the form has been submitted:
|
|
65
66
|
if (method === 'POST') {
|
|
66
67
|
// If the authorization code is invalid:
|
|
67
|
-
if (authCode
|
|
68
|
+
if (!isValidAuthCode(authCode)) {
|
|
68
69
|
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
69
70
|
// authorization code specifically (as opposed to some other part of the request) is
|
|
70
71
|
// what was wrong.
|
package/web/reannotate/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getReport,
|
|
12
12
|
getReportPath,
|
|
13
13
|
isReportError,
|
|
14
|
+
isValidAuthCode,
|
|
14
15
|
readdirOrCreate,
|
|
15
16
|
reportsPath
|
|
16
17
|
} from '../../util.ts';
|
|
@@ -22,7 +23,7 @@ import path from 'node:path';
|
|
|
22
23
|
// Implements a reannotation order and returns an acknowledgement page.
|
|
23
24
|
export const answer = async (authCode: string) => {
|
|
24
25
|
// If the authorization code is valid:
|
|
25
|
-
if (authCode
|
|
26
|
+
if (isValidAuthCode(authCode)) {
|
|
26
27
|
// Get the names of the stored report files.
|
|
27
28
|
const reportFileNames = (await readdirOrCreate(reportsPath(), 'Reports directory'))
|
|
28
29
|
.filter(fileName => fileName.endsWith('.json'));
|
package/web/renewWCAG/index.ts
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import {getJSON} from '../../util.ts';
|
|
10
|
+
import {getJSON, isValidAuthCode} from '../../util.ts';
|
|
11
11
|
|
|
12
12
|
// FUNCTIONS
|
|
13
13
|
|
|
14
14
|
// Renews the WCAG map and serves an acknowledgment.
|
|
15
15
|
export const answer = async (authCode: string) => {
|
|
16
16
|
// If the authorization code is valid:
|
|
17
|
-
if (authCode
|
|
17
|
+
if (isValidAuthCode(authCode)) {
|
|
18
18
|
// Get the map source response.
|
|
19
19
|
const mapSourceResponse = await fetch('https://www.w3.org/WAI/WCAG22/Understanding/');
|
|
20
20
|
const mapSourceStatus = mapSourceResponse.status;
|
package/web/reportDeletion.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
errorMessage, getReportData, objectSort, populateTemplate, readdirOrCreate,
|
|
9
|
+
errorMessage, getReportData, isValidAuthCode, objectSort, populateTemplate, readdirOrCreate,
|
|
10
|
+
reportsPath
|
|
10
11
|
} from '../util.ts';
|
|
11
12
|
import fs from 'node:fs/promises';
|
|
12
13
|
import path from 'node:path';
|
|
@@ -48,7 +49,7 @@ export const reportDeletionForm = async (
|
|
|
48
49
|
// If the form has been submitted and any reports are to be deleted:
|
|
49
50
|
if (method === 'POST' && jobNames?.length) {
|
|
50
51
|
// If the authorization code is valid:
|
|
51
|
-
if (authCode
|
|
52
|
+
if (isValidAuthCode(authCode)) {
|
|
52
53
|
try {
|
|
53
54
|
// For each report to be deleted:
|
|
54
55
|
for (const jobName of jobNames) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
// IMPORTS
|
|
9
9
|
|
|
10
|
-
import {populateTemplate} from '../../util.ts';
|
|
10
|
+
import {isValidAuthCode, populateTemplate} from '../../util.ts';
|
|
11
11
|
import {answer as unhideReportForm} from '../unhideReportForm/index.ts';
|
|
12
12
|
|
|
13
13
|
// FUNCTIONS
|
|
@@ -22,7 +22,7 @@ export const answer = async (pathTail: any, search: string, method: string) => {
|
|
|
22
22
|
// If the form has been submitted:
|
|
23
23
|
if (method === 'POST') {
|
|
24
24
|
// If the authorization code is invalid:
|
|
25
|
-
if (authCode
|
|
25
|
+
if (!isValidAuthCode(authCode)) {
|
|
26
26
|
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
27
27
|
// authorization code specifically (as opposed to some other part of the request) is
|
|
28
28
|
// what was wrong.
|
package/web/tutorialAI/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {sendAlert} from '../../alerts.ts';
|
|
9
|
-
import {checkCommentLength, getJSON} from '../../util.ts';
|
|
9
|
+
import {checkCommentDuplicate, checkCommentLength, getJSON, getNowStamp} from '../../util.ts';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
@@ -56,18 +56,13 @@ export const handleComment = async (content: unknown) => {
|
|
|
56
56
|
return {status: 'error', message: 'Comment is empty after sanitization'};
|
|
57
57
|
}
|
|
58
58
|
// Check for duplicate submissions within the last 1000 seconds.
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (duplicates.length) {
|
|
63
|
-
return {
|
|
64
|
-
status: 'error',
|
|
65
|
-
message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
|
|
66
|
-
};
|
|
59
|
+
const duplicateCheck = checkCommentDuplicate(comments, sanitized);
|
|
60
|
+
if (duplicateCheck.status === 'error') {
|
|
61
|
+
return duplicateCheck;
|
|
67
62
|
}
|
|
68
|
-
// Add the comment to the existing ones
|
|
63
|
+
// Add the comment to the existing ones.
|
|
69
64
|
comments.push({
|
|
70
|
-
|
|
65
|
+
timeStamp: getNowStamp(),
|
|
71
66
|
content: sanitized
|
|
72
67
|
});
|
|
73
68
|
// Ensure the comments directory exists.
|
|
@@ -14,6 +14,12 @@ import path from 'node:path';
|
|
|
14
14
|
|
|
15
15
|
const testDir = path.join(import.meta.dirname, '../../test/fixtures/comments');
|
|
16
16
|
process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testDir, 'tutorialAI.json');
|
|
17
|
+
// Blank the alert configuration unconditionally, before index.ts (whose handleComment
|
|
18
|
+
// calls sendAlert for a new comment) is ever imported below, so this file sends no real
|
|
19
|
+
// alert emails even when run directly rather than via `npm test`.
|
|
20
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
21
|
+
process.env[key] = '';
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
import {answer, handleComment} from './index.ts';
|
|
19
25
|
|
|
@@ -71,7 +77,7 @@ test('handleComment returns an error for a comment longer than 1000 characters',
|
|
|
71
77
|
assert.equal(result.message, 'Your comment was longer than 1000 characters');
|
|
72
78
|
});
|
|
73
79
|
|
|
74
|
-
test('handleComment returns an error for a comment
|
|
80
|
+
test('handleComment returns an error for a comment identical to one already submitted', {timeout: 500}, async () => {
|
|
75
81
|
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
76
82
|
const content = 'This is a duplicate comment.';
|
|
77
83
|
const firstResult = await handleComment(content);
|
|
@@ -80,7 +86,7 @@ test('handleComment returns an error for a comment repeating one submitted withi
|
|
|
80
86
|
assert.equal(secondResult.status, 'error');
|
|
81
87
|
assert.equal(
|
|
82
88
|
secondResult.message,
|
|
83
|
-
'Your comment
|
|
89
|
+
'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
84
90
|
);
|
|
85
91
|
});
|
|
86
92
|
|
|
@@ -91,7 +97,7 @@ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, a
|
|
|
91
97
|
const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
|
|
92
98
|
assert.equal(comments.length, 1);
|
|
93
99
|
assert.equal(comments[0].content, 'This is a test comment.');
|
|
94
|
-
assert.ok(comments[0].
|
|
100
|
+
assert.ok(comments[0].timeStamp);
|
|
95
101
|
});
|
|
96
102
|
|
|
97
103
|
test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
|
|
@@ -14,6 +14,12 @@ import path from 'node:path';
|
|
|
14
14
|
|
|
15
15
|
const testDir = path.join(import.meta.dirname, '../../test/fixtures/comments');
|
|
16
16
|
process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testDir, 'tutorialWeb.json');
|
|
17
|
+
// Blank the alert configuration unconditionally, before index.ts (whose handleComment
|
|
18
|
+
// calls sendAlert for a new comment) is ever imported below, so this file sends no real
|
|
19
|
+
// alert emails even when run directly rather than via `npm test`.
|
|
20
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
21
|
+
process.env[key] = '';
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
import {answer, handleComment} from './index.ts';
|
|
19
25
|
|
|
@@ -73,7 +79,7 @@ test('handleComment returns an error for a comment longer than 1000 characters',
|
|
|
73
79
|
assert.equal(result.message, 'Your comment was longer than 1000 characters');
|
|
74
80
|
});
|
|
75
81
|
|
|
76
|
-
test('handleComment returns an error for a comment
|
|
82
|
+
test('handleComment returns an error for a comment identical to one already submitted', {timeout: 500}, async () => {
|
|
77
83
|
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
78
84
|
const content = 'This is a duplicate comment.';
|
|
79
85
|
const firstResult = await handleComment(content);
|
|
@@ -82,7 +88,7 @@ test('handleComment returns an error for a comment repeating one submitted withi
|
|
|
82
88
|
assert.equal(secondResult.status, 'error');
|
|
83
89
|
assert.equal(
|
|
84
90
|
secondResult.message,
|
|
85
|
-
'Your comment
|
|
91
|
+
'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
86
92
|
);
|
|
87
93
|
});
|
|
88
94
|
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
hiddenReportsPath, isValidAuthCode, populateTemplate, readdirOrCreate, reportsPath
|
|
10
|
+
} from '../../util.ts';
|
|
9
11
|
import fs from 'node:fs/promises';
|
|
10
12
|
import path from 'node:path';
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
20
22
|
// If the form has been submitted and a report is to be unhidden:
|
|
21
23
|
if (method === 'POST' && jobName) {
|
|
22
24
|
// If the authorization code is valid:
|
|
23
|
-
if (authCode
|
|
25
|
+
if (isValidAuthCode(authCode)) {
|
|
24
26
|
const fileName = `${jobName}.json`;
|
|
25
27
|
try {
|
|
26
28
|
// Move the report to the reports directory.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"sessionId":"f44e6b44-375e-4007-879a-19e4e5ed34a0","pid":90042,"procStart":"Sun Sep 20 03:19:03 2026","acquiredAt":1789874948676}
|