@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.
Files changed (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/getReport.ts +3 -1
  3. package/api/requestFeature.test.ts +23 -3
  4. package/api/requestFeature.ts +19 -11
  5. package/api/requestRetest.test.ts +39 -13
  6. package/api/requestRetest.ts +11 -5
  7. package/api/requestTest.ts +6 -6
  8. package/api/schemas.ts +2 -2
  9. package/docs/BACKLOG.md +232 -12
  10. package/docs/SERVICE.md +84 -3
  11. package/index.test.ts +443 -20
  12. package/index.ts +246 -27
  13. package/linkValidation.test.ts +1 -0
  14. package/mcp.test.ts +31 -1
  15. package/mcp.ts +9 -0
  16. package/package.json +1 -1
  17. package/smokeTest.ts +16 -0
  18. package/test/dbFixture.ts +8 -0
  19. package/util.test.ts +295 -0
  20. package/util.ts +121 -0
  21. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  22. package/web/ai0BalanceForm/index.html +1 -1
  23. package/web/ai0BalanceForm/index.ts +12 -8
  24. package/web/enqueue/index.ts +33 -39
  25. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  26. package/web/expungeReportsForm/index.html +1 -1
  27. package/web/expungeReportsForm/index.ts +1 -1
  28. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  29. package/web/hideReportForm/index.html +1 -1
  30. package/web/hideReportForm/index.ts +13 -8
  31. package/web/listTopIssues/index.ts +1 -1
  32. package/web/manage/index.html +2 -1
  33. package/web/metrics/index.html +19 -0
  34. package/web/metrics/index.ts +127 -0
  35. package/web/metrics/metrics.test.ts +211 -0
  36. package/web/pruneReportsForm/index.html +1 -1
  37. package/web/pruneReportsForm/index.ts +1 -1
  38. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  39. package/web/reannotate/index.ts +7 -3
  40. package/web/reannotate/reannotate.test.ts +1 -1
  41. package/web/renewWCAG/index.ts +6 -3
  42. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  43. package/web/reportDeletion.ts +13 -7
  44. package/web/rewindReportsForm/index.html +1 -1
  45. package/web/rewindReportsForm/index.ts +1 -1
  46. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  47. package/web/showHiddenReportsForm/index.html +24 -0
  48. package/web/showHiddenReportsForm/index.ts +45 -0
  49. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  50. package/web/tutorialAI/index.ts +1 -1
  51. package/web/tutorialWeb/index.ts +1 -1
  52. package/web/unhideReportForm/index.html +1 -1
  53. package/web/unhideReportForm/index.ts +13 -8
  54. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "48.0.0",
3
+ "version": "50.0.0",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",
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 when no newBalance is submitted', async () => {
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 returns an error for an invalid auth code', async () => {
56
- const result: any = await answer(null, 'authCode=wrong&newBalance=5.00');
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 authorization code');
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
  }