@jrpool/kilotest 48.0.0 → 50.0.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/.claude/scheduled_tasks.lock +1 -0
- package/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +23 -3
- package/api/requestFeature.ts +19 -11
- package/api/requestRetest.test.ts +39 -13
- package/api/requestRetest.ts +11 -5
- package/api/requestTest.ts +6 -6
- package/api/schemas.ts +2 -2
- package/docs/BACKLOG.md +232 -12
- package/docs/SERVICE.md +84 -3
- package/index.test.ts +443 -20
- package/index.ts +246 -27
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +31 -1
- package/mcp.ts +9 -0
- package/package.json +1 -1
- package/smokeTest.ts +16 -0
- package/test/dbFixture.ts +8 -0
- package/util.test.ts +295 -0
- package/util.ts +121 -0
- package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
- package/web/ai0BalanceForm/index.html +1 -1
- package/web/ai0BalanceForm/index.ts +12 -8
- package/web/enqueue/index.ts +33 -39
- package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
- package/web/expungeReportsForm/index.html +1 -1
- package/web/expungeReportsForm/index.ts +1 -1
- package/web/hideReportForm/hideReportForm.test.ts +24 -9
- package/web/hideReportForm/index.html +1 -1
- package/web/hideReportForm/index.ts +13 -8
- package/web/listTopIssues/index.ts +1 -1
- package/web/manage/index.html +2 -1
- package/web/metrics/index.html +19 -0
- package/web/metrics/index.ts +127 -0
- package/web/metrics/metrics.test.ts +211 -0
- package/web/pruneReportsForm/index.html +1 -1
- package/web/pruneReportsForm/index.ts +1 -1
- package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
- package/web/reannotate/index.ts +7 -3
- package/web/reannotate/reannotate.test.ts +1 -1
- package/web/renewWCAG/index.ts +6 -3
- package/web/renewWCAG/renewWCAG.test.ts +1 -1
- package/web/reportDeletion.ts +13 -7
- package/web/rewindReportsForm/index.html +1 -1
- package/web/rewindReportsForm/index.ts +1 -1
- package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
- package/web/showHiddenReportsForm/index.html +24 -0
- package/web/showHiddenReportsForm/index.ts +45 -0
- package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
- package/web/tutorialAI/index.ts +1 -1
- package/web/tutorialWeb/index.ts +1 -1
- package/web/unhideReportForm/index.html +1 -1
- package/web/unhideReportForm/index.ts +13 -8
- package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
package/mcp.ts
CHANGED
|
@@ -17,6 +17,7 @@ import * as requestTestAPI from './api/requestTest.ts';
|
|
|
17
17
|
import * as requestRetestAPI from './api/requestRetest.ts';
|
|
18
18
|
import * as requestFeatureAPI from './api/requestFeature.ts';
|
|
19
19
|
import {version} from './api/version.ts';
|
|
20
|
+
import {recordMetric} from './util.ts';
|
|
20
21
|
|
|
21
22
|
import {
|
|
22
23
|
getReportSchema,
|
|
@@ -68,6 +69,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
68
69
|
},
|
|
69
70
|
async () => {
|
|
70
71
|
const result = await listReportsAPI.response();
|
|
72
|
+
await recordMetric('mcpToolCalls', 'listReports');
|
|
71
73
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
72
74
|
}
|
|
73
75
|
);
|
|
@@ -87,6 +89,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
87
89
|
},
|
|
88
90
|
async ({timeStamp, jobID}) => {
|
|
89
91
|
const result = await listIssuesAPI.response([timeStamp, jobID]);
|
|
92
|
+
await recordMetric('mcpToolCalls', 'listIssues');
|
|
90
93
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
91
94
|
}
|
|
92
95
|
);
|
|
@@ -106,6 +109,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
106
109
|
},
|
|
107
110
|
async ({issueID, timeStamp, jobID}) => {
|
|
108
111
|
const result = await listViolatorsAPI.response([issueID, timeStamp, jobID]);
|
|
112
|
+
await recordMetric('mcpToolCalls', 'listViolators');
|
|
109
113
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
110
114
|
}
|
|
111
115
|
);
|
|
@@ -125,6 +129,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
125
129
|
},
|
|
126
130
|
async ({catalogIndex, issueID, timeStamp, jobID}) => {
|
|
127
131
|
const result = await listDiagnosesAPI.response([catalogIndex, issueID, timeStamp, jobID]);
|
|
132
|
+
await recordMetric('mcpToolCalls', 'listDiagnoses');
|
|
128
133
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
129
134
|
}
|
|
130
135
|
);
|
|
@@ -144,6 +149,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
144
149
|
},
|
|
145
150
|
async ({timeStamp, jobID}) => {
|
|
146
151
|
const result = await getReportAPI.response([timeStamp, jobID]);
|
|
152
|
+
await recordMetric('mcpToolCalls', 'getReport');
|
|
147
153
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
148
154
|
}
|
|
149
155
|
);
|
|
@@ -163,6 +169,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
163
169
|
},
|
|
164
170
|
async ({description, URL, reason}) => {
|
|
165
171
|
const result = await requestTestAPI.response([description, URL, reason]);
|
|
172
|
+
await recordMetric('mcpToolCalls', 'requestTest');
|
|
166
173
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
167
174
|
}
|
|
168
175
|
);
|
|
@@ -182,6 +189,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
182
189
|
},
|
|
183
190
|
async ({timeStamp, jobID, reason}) => {
|
|
184
191
|
const result = await requestRetestAPI.response([timeStamp, jobID, reason]);
|
|
192
|
+
await recordMetric('mcpToolCalls', 'requestRetest');
|
|
185
193
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
186
194
|
}
|
|
187
195
|
);
|
|
@@ -201,6 +209,7 @@ export const createMCPServer = (): McpServer => {
|
|
|
201
209
|
},
|
|
202
210
|
async ({feature}) => {
|
|
203
211
|
const result = await requestFeatureAPI.response([feature]);
|
|
212
|
+
await recordMetric('mcpToolCalls', 'requestFeature');
|
|
204
213
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
205
214
|
}
|
|
206
215
|
);
|
package/package.json
CHANGED
package/smokeTest.ts
CHANGED
|
@@ -40,13 +40,21 @@ const concretePaths: Record<string, Record<string, string>> = {
|
|
|
40
40
|
POST: {
|
|
41
41
|
'/api/*': '/api/requestFeature',
|
|
42
42
|
'/mcp': '/mcp',
|
|
43
|
+
'/ai0BalanceForm.html': '/ai0BalanceForm.html',
|
|
44
|
+
'/expungeReportsForm.html': '/expungeReportsForm.html',
|
|
45
|
+
'/hideReportForm.html': '/hideReportForm.html',
|
|
46
|
+
'/metrics.html': '/metrics.html',
|
|
47
|
+
'/pruneReportsForm.html': '/pruneReportsForm.html',
|
|
48
|
+
'/rewindReportsForm.html': '/rewindReportsForm.html',
|
|
43
49
|
'/reannotate.html': '/reannotate.html',
|
|
44
50
|
'/requestAction.html': '/requestAction.html',
|
|
45
51
|
'/renewWCAG.html': '/renewWCAG.html',
|
|
46
52
|
'/requestRetest.html/*': '/requestRetest.html/260101T0001/ct',
|
|
47
53
|
'/requestTest.html': '/requestTest.html',
|
|
54
|
+
'/showHiddenReportsForm.html': '/showHiddenReportsForm.html',
|
|
48
55
|
'/tutorialAIComment.html': '/tutorialAIComment.html',
|
|
49
56
|
'/tutorialWebComment.html': '/tutorialWebComment.html',
|
|
57
|
+
'/unhideReportForm.html': '/unhideReportForm.html',
|
|
50
58
|
'/worker/job': '/worker/job',
|
|
51
59
|
'/worker/report': '/worker/report'
|
|
52
60
|
}
|
|
@@ -55,6 +63,12 @@ const concretePaths: Record<string, Record<string, string>> = {
|
|
|
55
63
|
const postBodies: Record<string, object> = {
|
|
56
64
|
'/api/requestFeature': {feature: 'smoke test'},
|
|
57
65
|
'/mcp': {},
|
|
66
|
+
'/ai0BalanceForm.html': {authCode: 'invalid'},
|
|
67
|
+
'/expungeReportsForm.html': {authCode: 'invalid'},
|
|
68
|
+
'/hideReportForm.html': {authCode: 'invalid'},
|
|
69
|
+
'/metrics.html': {authCode: 'invalid'},
|
|
70
|
+
'/pruneReportsForm.html': {authCode: 'invalid'},
|
|
71
|
+
'/rewindReportsForm.html': {authCode: 'invalid'},
|
|
58
72
|
'/reannotate.html': {authCode: 'invalid'},
|
|
59
73
|
'/requestAction.html': {
|
|
60
74
|
target: 'https://smoketest.example.com\tSmoke Test Page', authCode: 'invalid'
|
|
@@ -62,7 +76,9 @@ const postBodies: Record<string, object> = {
|
|
|
62
76
|
'/renewWCAG.html': {authCode: 'invalid'},
|
|
63
77
|
'/requestRetest.html/260101T0001/ct': {why: 'smoke test'},
|
|
64
78
|
'/requestTest.html': {description: 'Smoke Test Page', url: 'https://smoketest.example.com', why: 'smoke test'},
|
|
79
|
+
'/showHiddenReportsForm.html': {authCode: 'invalid'},
|
|
65
80
|
'/tutorialAIComment.html': {content: 'smoke test'},
|
|
81
|
+
'/unhideReportForm.html': {authCode: 'invalid'},
|
|
66
82
|
'/tutorialWebComment.html': {content: 'smoke test'},
|
|
67
83
|
'/worker/job': {},
|
|
68
84
|
'/worker/report': {}
|
package/test/dbFixture.ts
CHANGED
|
@@ -17,5 +17,13 @@ export const fixtureDBDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kilotest-db-'
|
|
|
17
17
|
// Copy the tracked fixture database to the temporary directory.
|
|
18
18
|
fs.cpSync(path.join(import.meta.dirname, 'fixtures', 'db'), fixtureDBDir, {recursive: true});
|
|
19
19
|
|
|
20
|
+
// Git does not track empty directories, so the job-state subdirectories are absent
|
|
21
|
+
// from a fresh checkout even though they are part of the fixture layout. Create them
|
|
22
|
+
// here rather than tracking placeholder files, which readdir-based job listing would
|
|
23
|
+
// otherwise pick up as (invalid) job files.
|
|
24
|
+
for (const category of ['queue', 'claimed', 'failed']) {
|
|
25
|
+
fs.mkdirSync(path.join(fixtureDBDir, 'jobs', category), {recursive: true});
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
// Remove the copy when the process exits.
|
|
21
29
|
process.on('exit', () => fs.rmSync(fixtureDBDir, {recursive: true, force: true}));
|
package/util.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
annotateReportObject,
|
|
14
14
|
checkCommentDuplicate,
|
|
15
15
|
checkCommentLength,
|
|
16
|
+
checkLength,
|
|
16
17
|
createLock,
|
|
17
18
|
dbPath,
|
|
18
19
|
getAgoDays,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
isReportAvailable,
|
|
46
47
|
isTimeStamp,
|
|
47
48
|
isURL,
|
|
49
|
+
isValidAuthCode,
|
|
48
50
|
jobsPath,
|
|
49
51
|
makeBreakable,
|
|
50
52
|
minifyURL,
|
|
@@ -83,6 +85,32 @@ test('checkCommentLength rejects a comment longer than 1000 characters', () => {
|
|
|
83
85
|
});
|
|
84
86
|
});
|
|
85
87
|
|
|
88
|
+
test('checkLength accepts text at the minimum bound', () => {
|
|
89
|
+
const result = checkLength('a'.repeat(5), 5, 10, 'reason');
|
|
90
|
+
assert.deepEqual(result, {status: 'ok'});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('checkLength accepts text at the maximum bound', () => {
|
|
94
|
+
const result = checkLength('a'.repeat(10), 5, 10, 'reason');
|
|
95
|
+
assert.deepEqual(result, {status: 'ok'});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('checkLength rejects text shorter than the minimum bound', () => {
|
|
99
|
+
const result = checkLength('a'.repeat(4), 5, 10, 'reason');
|
|
100
|
+
assert.deepEqual(result, {
|
|
101
|
+
status: 'error',
|
|
102
|
+
message: 'The reason must be between 5 and 10 characters long'
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('checkLength rejects text longer than the maximum bound', () => {
|
|
107
|
+
const result = checkLength('a'.repeat(11), 5, 10, 'reason');
|
|
108
|
+
assert.deepEqual(result, {
|
|
109
|
+
status: 'error',
|
|
110
|
+
message: 'The reason must be between 5 and 10 characters long'
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
86
114
|
test('checkCommentDuplicate accepts a comment with no existing comments', () => {
|
|
87
115
|
const result = checkCommentDuplicate([], 'a'.repeat(20));
|
|
88
116
|
assert.deepEqual(result, {status: 'ok'});
|
|
@@ -337,6 +365,42 @@ test('isURL returns false for an invalid URL', () => {
|
|
|
337
365
|
assert.equal(isURL('not-a-url'), false);
|
|
338
366
|
});
|
|
339
367
|
|
|
368
|
+
test('isValidAuthCode returns true when the code matches AUTH_CODE', () => {
|
|
369
|
+
const saved = process.env.AUTH_CODE;
|
|
370
|
+
process.env.AUTH_CODE = 'secret-code';
|
|
371
|
+
try {
|
|
372
|
+
assert.equal(isValidAuthCode('secret-code'), true);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
if (saved === undefined) {
|
|
376
|
+
delete process.env.AUTH_CODE;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
process.env.AUTH_CODE = saved;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('isValidAuthCode returns false when the code does not match AUTH_CODE', () => {
|
|
385
|
+
const saved = process.env.AUTH_CODE;
|
|
386
|
+
process.env.AUTH_CODE = 'secret-code';
|
|
387
|
+
try {
|
|
388
|
+
assert.equal(isValidAuthCode('wrong-code'), false);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
if (saved === undefined) {
|
|
392
|
+
delete process.env.AUTH_CODE;
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
process.env.AUTH_CODE = saved;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('isValidAuthCode returns false for an undefined code', () => {
|
|
401
|
+
assert.equal(isValidAuthCode(undefined), false);
|
|
402
|
+
});
|
|
403
|
+
|
|
340
404
|
test('makeBreakable inserts wbr before non-initial slashes', () => {
|
|
341
405
|
assert.equal(makeBreakable('/api/listReports'), '/api<wbr>/listReports');
|
|
342
406
|
});
|
|
@@ -1127,3 +1191,234 @@ test('getMultiReportWhats returns descriptions that have multiple reports', asyn
|
|
|
1127
1191
|
const whats = await getMultiReportWhats();
|
|
1128
1192
|
assert.ok(whats.includes('Mixed Outcomes Page'));
|
|
1129
1193
|
});
|
|
1194
|
+
|
|
1195
|
+
test('getExclusionCookieValue is deterministic for a given AUTH_CODE', async () => {
|
|
1196
|
+
const savedAuthCode = process.env.AUTH_CODE;
|
|
1197
|
+
process.env.AUTH_CODE = 'test-auth-code';
|
|
1198
|
+
try {
|
|
1199
|
+
const {getExclusionCookieValue} = await import('./util.ts');
|
|
1200
|
+
const first = getExclusionCookieValue();
|
|
1201
|
+
const second = getExclusionCookieValue();
|
|
1202
|
+
assert.equal(first, second);
|
|
1203
|
+
assert.equal(typeof first, 'string');
|
|
1204
|
+
assert.ok(first.length > 0);
|
|
1205
|
+
}
|
|
1206
|
+
finally {
|
|
1207
|
+
process.env.AUTH_CODE = savedAuthCode;
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test('getExclusionCookieValue changes when AUTH_CODE changes', async () => {
|
|
1212
|
+
const savedAuthCode = process.env.AUTH_CODE;
|
|
1213
|
+
try {
|
|
1214
|
+
const {getExclusionCookieValue} = await import('./util.ts');
|
|
1215
|
+
process.env.AUTH_CODE = 'code-one';
|
|
1216
|
+
const first = getExclusionCookieValue();
|
|
1217
|
+
process.env.AUTH_CODE = 'code-two';
|
|
1218
|
+
const second = getExclusionCookieValue();
|
|
1219
|
+
assert.notEqual(first, second);
|
|
1220
|
+
}
|
|
1221
|
+
finally {
|
|
1222
|
+
process.env.AUTH_CODE = savedAuthCode;
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test('getExclusionCookieValue does not throw when AUTH_CODE is unset', async () => {
|
|
1227
|
+
const savedAuthCode = process.env.AUTH_CODE;
|
|
1228
|
+
delete process.env.AUTH_CODE;
|
|
1229
|
+
try {
|
|
1230
|
+
const {getExclusionCookieValue} = await import('./util.ts');
|
|
1231
|
+
assert.equal(typeof getExclusionCookieValue(), 'string');
|
|
1232
|
+
}
|
|
1233
|
+
finally {
|
|
1234
|
+
process.env.AUTH_CODE = savedAuthCode;
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
test('recordMetric creates the metrics file and records a first occurrence when the file is missing', async () => {
|
|
1239
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-missing-metrics-test';
|
|
1240
|
+
const fsSync = await import('node:fs');
|
|
1241
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1242
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1243
|
+
process.env.DB_DIR = tmpDir;
|
|
1244
|
+
try {
|
|
1245
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1246
|
+
await recordMetric('pageViews', 'tutorialWeb.html');
|
|
1247
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1248
|
+
assert.equal(metrics.pageViews['tutorialWeb.html'], 1);
|
|
1249
|
+
assert.equal(metrics.mcpToolCalls['tutorialWeb.html'], undefined);
|
|
1250
|
+
assert.equal(typeof metrics.since, 'string');
|
|
1251
|
+
}
|
|
1252
|
+
finally {
|
|
1253
|
+
process.env.DB_DIR = savedDbDir;
|
|
1254
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
test('recordMetric increments an existing count rather than overwriting it', async () => {
|
|
1259
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-existing-metrics-test';
|
|
1260
|
+
const fsSync = await import('node:fs');
|
|
1261
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1262
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1263
|
+
process.env.DB_DIR = tmpDir;
|
|
1264
|
+
try {
|
|
1265
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1266
|
+
await recordMetric('mcpToolCalls', 'listReports');
|
|
1267
|
+
await recordMetric('mcpToolCalls', 'listReports');
|
|
1268
|
+
await recordMetric('mcpToolCalls', 'getReport');
|
|
1269
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1270
|
+
assert.equal(metrics.mcpToolCalls.listReports, 2);
|
|
1271
|
+
assert.equal(metrics.mcpToolCalls.getReport, 1);
|
|
1272
|
+
}
|
|
1273
|
+
finally {
|
|
1274
|
+
process.env.DB_DIR = savedDbDir;
|
|
1275
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test('recordMetric throws when the metrics file is not readable for a reason other than being missing', async () => {
|
|
1280
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-unreadable-test';
|
|
1281
|
+
const fsSync = await import('node:fs');
|
|
1282
|
+
// Create a directory where the metrics file should be, causing EISDIR rather than ENOENT.
|
|
1283
|
+
fsSync.mkdirSync(tmpDir + '/metrics.json', {recursive: true});
|
|
1284
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1285
|
+
process.env.DB_DIR = tmpDir;
|
|
1286
|
+
try {
|
|
1287
|
+
const {recordMetric} = await import('./util.ts');
|
|
1288
|
+
await assert.rejects(recordMetric('pageViews', 'tutorialWeb.html'), /not readable/);
|
|
1289
|
+
}
|
|
1290
|
+
finally {
|
|
1291
|
+
process.env.DB_DIR = savedDbDir;
|
|
1292
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
test('recordMetric throws when the metrics file is not JSON', async () => {
|
|
1297
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-not-json-test';
|
|
1298
|
+
const fsSync = await import('node:fs');
|
|
1299
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1300
|
+
fsSync.writeFileSync(tmpDir + '/metrics.json', 'not json');
|
|
1301
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1302
|
+
process.env.DB_DIR = tmpDir;
|
|
1303
|
+
try {
|
|
1304
|
+
const {recordMetric} = await import('./util.ts');
|
|
1305
|
+
await assert.rejects(recordMetric('pageViews', 'tutorialWeb.html'), /not JSON/);
|
|
1306
|
+
}
|
|
1307
|
+
finally {
|
|
1308
|
+
process.env.DB_DIR = savedDbDir;
|
|
1309
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
test('recordMetric keeps categories independent for the same name', async () => {
|
|
1314
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-categories-test';
|
|
1315
|
+
const fsSync = await import('node:fs');
|
|
1316
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1317
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1318
|
+
process.env.DB_DIR = tmpDir;
|
|
1319
|
+
try {
|
|
1320
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1321
|
+
await recordMetric('apiOperations', 'listReports');
|
|
1322
|
+
await recordMetric('mcpToolCalls', 'listReports');
|
|
1323
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1324
|
+
assert.equal(metrics.apiOperations.listReports, 1);
|
|
1325
|
+
assert.equal(metrics.mcpToolCalls.listReports, 1);
|
|
1326
|
+
}
|
|
1327
|
+
finally {
|
|
1328
|
+
process.env.DB_DIR = savedDbDir;
|
|
1329
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test('recordMetric tracks managerActivity outcomes separately per page', async () => {
|
|
1334
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-manager-activity-test';
|
|
1335
|
+
const fsSync = await import('node:fs');
|
|
1336
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1337
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1338
|
+
process.env.DB_DIR = tmpDir;
|
|
1339
|
+
try {
|
|
1340
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1341
|
+
await recordMetric('managerActivity', 'reannotate.html', 'ok');
|
|
1342
|
+
await recordMetric('managerActivity', 'reannotate.html', 'error');
|
|
1343
|
+
await recordMetric('managerActivity', 'reannotate.html', 'error');
|
|
1344
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1345
|
+
assert.deepEqual(metrics.managerActivity['reannotate.html'], {ok: 1, error: 2});
|
|
1346
|
+
}
|
|
1347
|
+
finally {
|
|
1348
|
+
process.env.DB_DIR = savedDbDir;
|
|
1349
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
test('recordMetric keeps managerActivity independent of pageViews for the same name', async () => {
|
|
1354
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-manager-vs-pageviews-test';
|
|
1355
|
+
const fsSync = await import('node:fs');
|
|
1356
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1357
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1358
|
+
process.env.DB_DIR = tmpDir;
|
|
1359
|
+
try {
|
|
1360
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1361
|
+
await recordMetric('pageViews', 'hideReportForm.html');
|
|
1362
|
+
await recordMetric('managerActivity', 'hideReportForm.html', 'ok');
|
|
1363
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1364
|
+
assert.equal(metrics.pageViews['hideReportForm.html'], 1);
|
|
1365
|
+
assert.deepEqual(metrics.managerActivity['hideReportForm.html'], {ok: 1, error: 0});
|
|
1366
|
+
}
|
|
1367
|
+
finally {
|
|
1368
|
+
process.env.DB_DIR = savedDbDir;
|
|
1369
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
test('getMetrics backfills categories missing from a metrics.json written before they existed', async () => {
|
|
1374
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-backfill-test';
|
|
1375
|
+
const fsSync = await import('node:fs');
|
|
1376
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1377
|
+
// Simulate a metrics.json written before the managerActivity category was added.
|
|
1378
|
+
fsSync.writeFileSync(tmpDir + '/metrics.json', JSON.stringify({
|
|
1379
|
+
since: '260101T0000',
|
|
1380
|
+
pageViews: {listReports: 1},
|
|
1381
|
+
mcpToolCalls: {},
|
|
1382
|
+
apiOperations: {}
|
|
1383
|
+
}));
|
|
1384
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1385
|
+
process.env.DB_DIR = tmpDir;
|
|
1386
|
+
try {
|
|
1387
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1388
|
+
// Recording a managerActivity outcome must not throw despite the file predating that category.
|
|
1389
|
+
await recordMetric('managerActivity', 'metrics', 'ok');
|
|
1390
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1391
|
+
assert.equal(metrics.since, '260101T0000');
|
|
1392
|
+
assert.equal(metrics.pageViews.listReports, 1);
|
|
1393
|
+
assert.deepEqual(metrics.managerActivity.metrics, {ok: 1, error: 0});
|
|
1394
|
+
}
|
|
1395
|
+
finally {
|
|
1396
|
+
process.env.DB_DIR = savedDbDir;
|
|
1397
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test('getMetrics backfills every category, and generates a since stamp, from a bare empty file', async () => {
|
|
1402
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-backfill-empty-test';
|
|
1403
|
+
const fsSync = await import('node:fs');
|
|
1404
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1405
|
+
// Simulate a metrics.json with none of the known fields (an extreme case of an outdated schema).
|
|
1406
|
+
fsSync.writeFileSync(tmpDir + '/metrics.json', '{}');
|
|
1407
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1408
|
+
process.env.DB_DIR = tmpDir;
|
|
1409
|
+
try {
|
|
1410
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1411
|
+
await recordMetric('pageViews', 'tutorialWeb');
|
|
1412
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1413
|
+
assert.equal(typeof metrics.since, 'string');
|
|
1414
|
+
assert.ok(metrics.since.length > 0);
|
|
1415
|
+
assert.equal(metrics.pageViews.tutorialWeb, 1);
|
|
1416
|
+
assert.deepEqual(metrics.mcpToolCalls, {});
|
|
1417
|
+
assert.deepEqual(metrics.apiOperations, {});
|
|
1418
|
+
assert.deepEqual(metrics.managerActivity, {});
|
|
1419
|
+
}
|
|
1420
|
+
finally {
|
|
1421
|
+
process.env.DB_DIR = savedDbDir;
|
|
1422
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1423
|
+
}
|
|
1424
|
+
});
|
package/util.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import {sendAlert} from './alerts.ts';
|
|
12
12
|
import {issues as issueSpecs, rules as ruleSpecs} from 'testaro-issues';
|
|
13
13
|
import type {Act, Catalog, Report, StandardInstance} from 'testaro';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
14
15
|
import fs from 'node:fs/promises';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
import querystring from 'node:querystring';
|
|
@@ -29,6 +30,8 @@ export const testRequestsPath = (): string => path.join(jobsPath(), 'testRequest
|
|
|
29
30
|
export const reportsPath = (): string => path.join(dbPath(), 'reports');
|
|
30
31
|
// Path of the hidden-reports directory.
|
|
31
32
|
export const hiddenReportsPath = (): string => path.join(dbPath(), 'hiddenReports');
|
|
33
|
+
// Path of the usage-metrics file.
|
|
34
|
+
export const metricsPath = (): string => path.join(dbPath(), 'metrics.json');
|
|
32
35
|
// IDs, names, and sponsors of Testaro rule engines.
|
|
33
36
|
const ruleEngines: Record<string, [string, string]> = {
|
|
34
37
|
alfa: ['Alfa', 'Siteimprove'],
|
|
@@ -50,6 +53,18 @@ export {ruleEngines};
|
|
|
50
53
|
|
|
51
54
|
// TYPES
|
|
52
55
|
|
|
56
|
+
// Usage-metrics categories, each a map from an event name (page name, MCP tool name, or
|
|
57
|
+
// API operation name) to a count of how many times it has occurred. managerActivity
|
|
58
|
+
// counts manager-only pages separately from pageViews, by outcome, since a spike in
|
|
59
|
+
// failed authCode attempts against a manager page is a signal of suspected abuse.
|
|
60
|
+
export type Metrics = {
|
|
61
|
+
since: string;
|
|
62
|
+
pageViews: Record<string, number>;
|
|
63
|
+
mcpToolCalls: Record<string, number>;
|
|
64
|
+
apiOperations: Record<string, number>;
|
|
65
|
+
managerActivity: Record<string, {ok: number; error: number}>;
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
// Test request.
|
|
54
69
|
export type TestRequest = {
|
|
55
70
|
timeStamp: string;
|
|
@@ -158,6 +173,24 @@ export type ReportData = {
|
|
|
158
173
|
const alphaCompare = (a: string, b: string) => a.localeCompare(b, 'en', {sensitivity: 'base'});
|
|
159
174
|
// Sorts strings alphabetically and case-insensitively.
|
|
160
175
|
const alphaSort = (strings: string[]) => strings.sort((a, b) => alphaCompare(a, b));
|
|
176
|
+
// Returns whether a string's length is within the given bounds. Generic length check
|
|
177
|
+
// shared by any field with a min/max character-count rule (e.g. a request description
|
|
178
|
+
// or reason), so each caller states its own bounds and label instead of hardcoding an
|
|
179
|
+
// ad hoc length comparison.
|
|
180
|
+
export const checkLength = (
|
|
181
|
+
text: string, min: number, max: number, label: string
|
|
182
|
+
): {status: 'ok'} | {status: 'error'; message: string} => {
|
|
183
|
+
const {length} = text;
|
|
184
|
+
// If the length is invalid:
|
|
185
|
+
if (length < min || length > max) {
|
|
186
|
+
// Return the applicable error message.
|
|
187
|
+
return {
|
|
188
|
+
status: 'error',
|
|
189
|
+
message: `The ${label} must be between ${min} and ${max} characters long`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {status: 'ok'};
|
|
193
|
+
};
|
|
161
194
|
// Returns whether a comment's raw length is within the 20-to-1000-character bounds.
|
|
162
195
|
export const checkCommentLength = (
|
|
163
196
|
content: string
|
|
@@ -521,6 +554,12 @@ export const isURL = (string: string): boolean => {
|
|
|
521
554
|
return false;
|
|
522
555
|
}
|
|
523
556
|
};
|
|
557
|
+
// Returns whether a string is the authorization code, the shared check every
|
|
558
|
+
// manager-facing page and action uses to gate a submission. Centralized so all call
|
|
559
|
+
// sites compare against process.env.AUTH_CODE the same way.
|
|
560
|
+
export const isValidAuthCode = (authCode: string | undefined | null): boolean => {
|
|
561
|
+
return !!authCode && authCode === process.env.AUTH_CODE;
|
|
562
|
+
};
|
|
524
563
|
// Makes a string breakable before non-initial slashes.
|
|
525
564
|
export const makeBreakable = (string: string): string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
|
|
526
565
|
// Minifies a URL for duplicate detection.
|
|
@@ -1015,3 +1054,85 @@ export const processTestRequest = (
|
|
|
1015
1054
|
throw new Error('Failed to process test request', {cause: error});
|
|
1016
1055
|
}
|
|
1017
1056
|
});
|
|
1057
|
+
|
|
1058
|
+
// METRICS FUNCTIONS
|
|
1059
|
+
|
|
1060
|
+
// Name of the cookie that excludes a browser's requests from usage metrics.
|
|
1061
|
+
export const metricsExclusionCookieName = 'kilotestExclude';
|
|
1062
|
+
// Returns the value that the metrics-exclusion cookie must have to be honored: a SHA-256
|
|
1063
|
+
// hash of AUTH_CODE, not AUTH_CODE itself, so the secret is never placed in a long-lived
|
|
1064
|
+
// browser cookie, and not a fixed value, since Kilotest's source is public and a fixed
|
|
1065
|
+
// value would let anyone read it and exclude themselves from metrics at no cost.
|
|
1066
|
+
export const getExclusionCookieValue = (): string =>
|
|
1067
|
+
crypto.createHash('sha256').update(process.env.AUTH_CODE ?? '').digest('hex');
|
|
1068
|
+
// Concurrency lock for the `metrics.json` file.
|
|
1069
|
+
const metricsLock = createLock();
|
|
1070
|
+
// Returns the usage metrics, creating the file with zeroed counts if it does not yet exist.
|
|
1071
|
+
export const getMetrics = async (): Promise<Metrics> => {
|
|
1072
|
+
let metricsJSON: string;
|
|
1073
|
+
try {
|
|
1074
|
+
metricsJSON = await fs.readFile(metricsPath(), 'utf8');
|
|
1075
|
+
}
|
|
1076
|
+
catch(error: unknown) {
|
|
1077
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
1078
|
+
const metrics: Metrics = {
|
|
1079
|
+
since: getNowStamp(), pageViews: {}, mcpToolCalls: {}, apiOperations: {}, managerActivity: {}
|
|
1080
|
+
};
|
|
1081
|
+
await fs.writeFile(metricsPath(), getJSON(metrics));
|
|
1082
|
+
return metrics;
|
|
1083
|
+
}
|
|
1084
|
+
throw new Error(`Metrics file not readable (${errorMessage(error)})`, {cause: error});
|
|
1085
|
+
}
|
|
1086
|
+
let metrics: Partial<Metrics>;
|
|
1087
|
+
try {
|
|
1088
|
+
metrics = JSON.parse(metricsJSON) as Partial<Metrics>;
|
|
1089
|
+
}
|
|
1090
|
+
catch(error: unknown) {
|
|
1091
|
+
throw new Error(`Metrics file not JSON (${errorMessage(error)})`, {cause: error});
|
|
1092
|
+
}
|
|
1093
|
+
// Backfill any category absent from a file written before that category existed,
|
|
1094
|
+
// so an older metrics.json (e.g. from before a schema change) remains readable.
|
|
1095
|
+
return {
|
|
1096
|
+
since: metrics.since ?? getNowStamp(),
|
|
1097
|
+
pageViews: metrics.pageViews ?? {},
|
|
1098
|
+
mcpToolCalls: metrics.mcpToolCalls ?? {},
|
|
1099
|
+
apiOperations: metrics.apiOperations ?? {},
|
|
1100
|
+
managerActivity: metrics.managerActivity ?? {}
|
|
1101
|
+
};
|
|
1102
|
+
};
|
|
1103
|
+
// Records an occurrence of a named event (a web page view, an MCP tool call, or an API
|
|
1104
|
+
// operation call) in the usage metrics, creating the category and name if not yet present.
|
|
1105
|
+
export function recordMetric(
|
|
1106
|
+
category: 'pageViews' | 'mcpToolCalls' | 'apiOperations', name: string
|
|
1107
|
+
): Promise<void>;
|
|
1108
|
+
// Records an outcome of a manager-page visit or submission (a distinct category from
|
|
1109
|
+
// pageViews, so a spike in failed authCode attempts against a manager page is visible
|
|
1110
|
+
// as a signal of suspected abuse, rather than being folded into ordinary page views).
|
|
1111
|
+
export function recordMetric(
|
|
1112
|
+
category: 'managerActivity', name: string, outcome: 'ok' | 'error'
|
|
1113
|
+
): Promise<void>;
|
|
1114
|
+
export function recordMetric(
|
|
1115
|
+
category: 'pageViews' | 'mcpToolCalls' | 'apiOperations' | 'managerActivity',
|
|
1116
|
+
name: string,
|
|
1117
|
+
outcome?: 'ok' | 'error'
|
|
1118
|
+
): Promise<void> {
|
|
1119
|
+
return metricsLock(async (): Promise<void> => {
|
|
1120
|
+
const metrics = await getMetrics();
|
|
1121
|
+
if (category === 'managerActivity') {
|
|
1122
|
+
const entry = metrics.managerActivity[name] ??= {ok: 0, error: 0};
|
|
1123
|
+
entry[outcome!]++;
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
metrics[category][name] = (metrics[category][name] ?? 0) + 1;
|
|
1127
|
+
}
|
|
1128
|
+
await fs.writeFile(metricsPath(), getJSON(metrics));
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
// Resets all usage metrics to empty and since to now.
|
|
1132
|
+
export const clearMetrics = (): Promise<Metrics> => metricsLock(async (): Promise<Metrics> => {
|
|
1133
|
+
const metrics: Metrics = {
|
|
1134
|
+
since: getNowStamp(), pageViews: {}, mcpToolCalls: {}, apiOperations: {}, managerActivity: {}
|
|
1135
|
+
};
|
|
1136
|
+
await fs.writeFile(metricsPath(), getJSON(metrics));
|
|
1137
|
+
return metrics;
|
|
1138
|
+
});
|
|
@@ -44,22 +44,28 @@ after(async () => {
|
|
|
44
44
|
|
|
45
45
|
// TESTS
|
|
46
46
|
|
|
47
|
-
test('ai0BalanceForm displays the current balance
|
|
48
|
-
const result: any = await answer(null, '');
|
|
47
|
+
test('ai0BalanceForm displays the current balance on a GET request', async () => {
|
|
48
|
+
const result: any = await answer(null, '', 'GET');
|
|
49
49
|
assert.equal(result.status, 'ok');
|
|
50
50
|
assert.ok(result.answerPage);
|
|
51
51
|
const html = parse(result.answerPage);
|
|
52
52
|
assert.ok(html.querySelector('title'));
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test('ai0BalanceForm
|
|
56
|
-
const result: any = await answer(null, 'authCode=
|
|
55
|
+
test('ai0BalanceForm ignores a newBalance query string on a GET request', async () => {
|
|
56
|
+
const result: any = await answer(null, 'authCode=test-auth-code&newBalance=99.00', 'GET');
|
|
57
|
+
assert.equal(result.status, 'ok');
|
|
58
|
+
assert.ok(!result.answerPage.includes('$99 is the'));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('ai0BalanceForm returns an error for an invalid auth code on a POST request', async () => {
|
|
62
|
+
const result: any = await answer(null, 'authCode=wrong&newBalance=5.00', 'POST');
|
|
57
63
|
assert.equal(result.status, 'error');
|
|
58
|
-
assert.equal(result.message, 'Invalid
|
|
64
|
+
assert.equal(result.message, 'Invalid request');
|
|
59
65
|
});
|
|
60
66
|
|
|
61
|
-
test('ai0BalanceForm records a valid new balance with valid auth code', async () => {
|
|
62
|
-
const result: any = await answer(null, 'authCode=test-auth-code&newBalance=2.50');
|
|
67
|
+
test('ai0BalanceForm records a valid new balance with valid auth code on a POST request', async () => {
|
|
68
|
+
const result: any = await answer(null, 'authCode=test-auth-code&newBalance=2.50', 'POST');
|
|
63
69
|
assert.equal(result.status, 'ok');
|
|
64
70
|
assert.ok(result.answerPage.includes('$2.5 is the'));
|
|
65
71
|
// Verify the file was written.
|
|
@@ -67,9 +73,9 @@ test('ai0BalanceForm records a valid new balance with valid auth code', async ()
|
|
|
67
73
|
assert.equal(data.balance, 2.50);
|
|
68
74
|
});
|
|
69
75
|
|
|
70
|
-
test('ai0BalanceForm does not record an invalid balance', async () => {
|
|
76
|
+
test('ai0BalanceForm does not record an invalid balance on a POST request', async () => {
|
|
71
77
|
// 150 is out of range (>= 100).
|
|
72
|
-
const result: any = await answer(null, 'authCode=test-auth-code&newBalance=150');
|
|
78
|
+
const result: any = await answer(null, 'authCode=test-auth-code&newBalance=150', 'POST');
|
|
73
79
|
assert.equal(result.status, 'ok');
|
|
74
80
|
// The oldBalance should not show the new value.
|
|
75
81
|
assert.ok(!result.answerPage.includes('$150 is the'));
|
|
@@ -78,7 +84,7 @@ test('ai0BalanceForm does not record an invalid balance', async () => {
|
|
|
78
84
|
test('ai0BalanceForm shows no-balance message when balance file is missing', async () => {
|
|
79
85
|
await fs.unlink(balancePath).catch(() => {});
|
|
80
86
|
try {
|
|
81
|
-
const result: any = await answer(null, '');
|
|
87
|
+
const result: any = await answer(null, '', 'GET');
|
|
82
88
|
assert.equal(result.status, 'ok');
|
|
83
89
|
assert.ok(result.answerPage.includes('There is no'));
|
|
84
90
|
}
|