@jrpool/kilotest 48.0.0 → 49.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/requestFeature.test.ts +4 -2
- package/api/requestFeature.ts +1 -1
- package/docs/BACKLOG.md +220 -12
- package/docs/SERVICE.md +52 -1
- package/index.test.ts +334 -13
- package/index.ts +174 -10
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +30 -0
- 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 +231 -0
- package/util.ts +97 -0
- package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
- package/web/ai0BalanceForm/index.html +1 -1
- package/web/ai0BalanceForm/index.ts +10 -6
- package/web/enqueue/index.ts +5 -2
- 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 +9 -6
- 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 +126 -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 +5 -2
- package/web/reannotate/reannotate.test.ts +1 -1
- package/web/renewWCAG/index.ts +4 -1
- package/web/renewWCAG/renewWCAG.test.ts +1 -1
- package/web/reportDeletion.ts +10 -5
- 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 +9 -6
- package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
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
|
@@ -1127,3 +1127,234 @@ test('getMultiReportWhats returns descriptions that have multiple reports', asyn
|
|
|
1127
1127
|
const whats = await getMultiReportWhats();
|
|
1128
1128
|
assert.ok(whats.includes('Mixed Outcomes Page'));
|
|
1129
1129
|
});
|
|
1130
|
+
|
|
1131
|
+
test('getExclusionCookieValue is deterministic for a given AUTH_CODE', async () => {
|
|
1132
|
+
const savedAuthCode = process.env.AUTH_CODE;
|
|
1133
|
+
process.env.AUTH_CODE = 'test-auth-code';
|
|
1134
|
+
try {
|
|
1135
|
+
const {getExclusionCookieValue} = await import('./util.ts');
|
|
1136
|
+
const first = getExclusionCookieValue();
|
|
1137
|
+
const second = getExclusionCookieValue();
|
|
1138
|
+
assert.equal(first, second);
|
|
1139
|
+
assert.equal(typeof first, 'string');
|
|
1140
|
+
assert.ok(first.length > 0);
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
process.env.AUTH_CODE = savedAuthCode;
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
test('getExclusionCookieValue changes when AUTH_CODE changes', async () => {
|
|
1148
|
+
const savedAuthCode = process.env.AUTH_CODE;
|
|
1149
|
+
try {
|
|
1150
|
+
const {getExclusionCookieValue} = await import('./util.ts');
|
|
1151
|
+
process.env.AUTH_CODE = 'code-one';
|
|
1152
|
+
const first = getExclusionCookieValue();
|
|
1153
|
+
process.env.AUTH_CODE = 'code-two';
|
|
1154
|
+
const second = getExclusionCookieValue();
|
|
1155
|
+
assert.notEqual(first, second);
|
|
1156
|
+
}
|
|
1157
|
+
finally {
|
|
1158
|
+
process.env.AUTH_CODE = savedAuthCode;
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
test('getExclusionCookieValue does not throw when AUTH_CODE is unset', async () => {
|
|
1163
|
+
const savedAuthCode = process.env.AUTH_CODE;
|
|
1164
|
+
delete process.env.AUTH_CODE;
|
|
1165
|
+
try {
|
|
1166
|
+
const {getExclusionCookieValue} = await import('./util.ts');
|
|
1167
|
+
assert.equal(typeof getExclusionCookieValue(), 'string');
|
|
1168
|
+
}
|
|
1169
|
+
finally {
|
|
1170
|
+
process.env.AUTH_CODE = savedAuthCode;
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test('recordMetric creates the metrics file and records a first occurrence when the file is missing', async () => {
|
|
1175
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-missing-metrics-test';
|
|
1176
|
+
const fsSync = await import('node:fs');
|
|
1177
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1178
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1179
|
+
process.env.DB_DIR = tmpDir;
|
|
1180
|
+
try {
|
|
1181
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1182
|
+
await recordMetric('pageViews', 'tutorialWeb.html');
|
|
1183
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1184
|
+
assert.equal(metrics.pageViews['tutorialWeb.html'], 1);
|
|
1185
|
+
assert.equal(metrics.mcpToolCalls['tutorialWeb.html'], undefined);
|
|
1186
|
+
assert.equal(typeof metrics.since, 'string');
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
process.env.DB_DIR = savedDbDir;
|
|
1190
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test('recordMetric increments an existing count rather than overwriting it', async () => {
|
|
1195
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-existing-metrics-test';
|
|
1196
|
+
const fsSync = await import('node:fs');
|
|
1197
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1198
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1199
|
+
process.env.DB_DIR = tmpDir;
|
|
1200
|
+
try {
|
|
1201
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1202
|
+
await recordMetric('mcpToolCalls', 'listReports');
|
|
1203
|
+
await recordMetric('mcpToolCalls', 'listReports');
|
|
1204
|
+
await recordMetric('mcpToolCalls', 'getReport');
|
|
1205
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1206
|
+
assert.equal(metrics.mcpToolCalls.listReports, 2);
|
|
1207
|
+
assert.equal(metrics.mcpToolCalls.getReport, 1);
|
|
1208
|
+
}
|
|
1209
|
+
finally {
|
|
1210
|
+
process.env.DB_DIR = savedDbDir;
|
|
1211
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
test('recordMetric throws when the metrics file is not readable for a reason other than being missing', async () => {
|
|
1216
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-unreadable-test';
|
|
1217
|
+
const fsSync = await import('node:fs');
|
|
1218
|
+
// Create a directory where the metrics file should be, causing EISDIR rather than ENOENT.
|
|
1219
|
+
fsSync.mkdirSync(tmpDir + '/metrics.json', {recursive: true});
|
|
1220
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1221
|
+
process.env.DB_DIR = tmpDir;
|
|
1222
|
+
try {
|
|
1223
|
+
const {recordMetric} = await import('./util.ts');
|
|
1224
|
+
await assert.rejects(recordMetric('pageViews', 'tutorialWeb.html'), /not readable/);
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
process.env.DB_DIR = savedDbDir;
|
|
1228
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test('recordMetric throws when the metrics file is not JSON', async () => {
|
|
1233
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-not-json-test';
|
|
1234
|
+
const fsSync = await import('node:fs');
|
|
1235
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1236
|
+
fsSync.writeFileSync(tmpDir + '/metrics.json', 'not json');
|
|
1237
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1238
|
+
process.env.DB_DIR = tmpDir;
|
|
1239
|
+
try {
|
|
1240
|
+
const {recordMetric} = await import('./util.ts');
|
|
1241
|
+
await assert.rejects(recordMetric('pageViews', 'tutorialWeb.html'), /not JSON/);
|
|
1242
|
+
}
|
|
1243
|
+
finally {
|
|
1244
|
+
process.env.DB_DIR = savedDbDir;
|
|
1245
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
test('recordMetric keeps categories independent for the same name', async () => {
|
|
1250
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-categories-test';
|
|
1251
|
+
const fsSync = await import('node:fs');
|
|
1252
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1253
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1254
|
+
process.env.DB_DIR = tmpDir;
|
|
1255
|
+
try {
|
|
1256
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1257
|
+
await recordMetric('apiOperations', 'listReports');
|
|
1258
|
+
await recordMetric('mcpToolCalls', 'listReports');
|
|
1259
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1260
|
+
assert.equal(metrics.apiOperations.listReports, 1);
|
|
1261
|
+
assert.equal(metrics.mcpToolCalls.listReports, 1);
|
|
1262
|
+
}
|
|
1263
|
+
finally {
|
|
1264
|
+
process.env.DB_DIR = savedDbDir;
|
|
1265
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
test('recordMetric tracks managerActivity outcomes separately per page', async () => {
|
|
1270
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-manager-activity-test';
|
|
1271
|
+
const fsSync = await import('node:fs');
|
|
1272
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1273
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1274
|
+
process.env.DB_DIR = tmpDir;
|
|
1275
|
+
try {
|
|
1276
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1277
|
+
await recordMetric('managerActivity', 'reannotate.html', 'ok');
|
|
1278
|
+
await recordMetric('managerActivity', 'reannotate.html', 'error');
|
|
1279
|
+
await recordMetric('managerActivity', 'reannotate.html', 'error');
|
|
1280
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1281
|
+
assert.deepEqual(metrics.managerActivity['reannotate.html'], {ok: 1, error: 2});
|
|
1282
|
+
}
|
|
1283
|
+
finally {
|
|
1284
|
+
process.env.DB_DIR = savedDbDir;
|
|
1285
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
test('recordMetric keeps managerActivity independent of pageViews for the same name', async () => {
|
|
1290
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-manager-vs-pageviews-test';
|
|
1291
|
+
const fsSync = await import('node:fs');
|
|
1292
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1293
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1294
|
+
process.env.DB_DIR = tmpDir;
|
|
1295
|
+
try {
|
|
1296
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1297
|
+
await recordMetric('pageViews', 'hideReportForm.html');
|
|
1298
|
+
await recordMetric('managerActivity', 'hideReportForm.html', 'ok');
|
|
1299
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1300
|
+
assert.equal(metrics.pageViews['hideReportForm.html'], 1);
|
|
1301
|
+
assert.deepEqual(metrics.managerActivity['hideReportForm.html'], {ok: 1, error: 0});
|
|
1302
|
+
}
|
|
1303
|
+
finally {
|
|
1304
|
+
process.env.DB_DIR = savedDbDir;
|
|
1305
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test('getMetrics backfills categories missing from a metrics.json written before they existed', async () => {
|
|
1310
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-backfill-test';
|
|
1311
|
+
const fsSync = await import('node:fs');
|
|
1312
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1313
|
+
// Simulate a metrics.json written before the managerActivity category was added.
|
|
1314
|
+
fsSync.writeFileSync(tmpDir + '/metrics.json', JSON.stringify({
|
|
1315
|
+
since: '260101T0000',
|
|
1316
|
+
pageViews: {listReports: 1},
|
|
1317
|
+
mcpToolCalls: {},
|
|
1318
|
+
apiOperations: {}
|
|
1319
|
+
}));
|
|
1320
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1321
|
+
process.env.DB_DIR = tmpDir;
|
|
1322
|
+
try {
|
|
1323
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1324
|
+
// Recording a managerActivity outcome must not throw despite the file predating that category.
|
|
1325
|
+
await recordMetric('managerActivity', 'metrics', 'ok');
|
|
1326
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1327
|
+
assert.equal(metrics.since, '260101T0000');
|
|
1328
|
+
assert.equal(metrics.pageViews.listReports, 1);
|
|
1329
|
+
assert.deepEqual(metrics.managerActivity.metrics, {ok: 1, error: 0});
|
|
1330
|
+
}
|
|
1331
|
+
finally {
|
|
1332
|
+
process.env.DB_DIR = savedDbDir;
|
|
1333
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
test('getMetrics backfills every category, and generates a since stamp, from a bare empty file', async () => {
|
|
1338
|
+
const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-metrics-backfill-empty-test';
|
|
1339
|
+
const fsSync = await import('node:fs');
|
|
1340
|
+
fsSync.mkdirSync(tmpDir, {recursive: true});
|
|
1341
|
+
// Simulate a metrics.json with none of the known fields (an extreme case of an outdated schema).
|
|
1342
|
+
fsSync.writeFileSync(tmpDir + '/metrics.json', '{}');
|
|
1343
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1344
|
+
process.env.DB_DIR = tmpDir;
|
|
1345
|
+
try {
|
|
1346
|
+
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1347
|
+
await recordMetric('pageViews', 'tutorialWeb');
|
|
1348
|
+
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1349
|
+
assert.equal(typeof metrics.since, 'string');
|
|
1350
|
+
assert.ok(metrics.since.length > 0);
|
|
1351
|
+
assert.equal(metrics.pageViews.tutorialWeb, 1);
|
|
1352
|
+
assert.deepEqual(metrics.mcpToolCalls, {});
|
|
1353
|
+
assert.deepEqual(metrics.apiOperations, {});
|
|
1354
|
+
assert.deepEqual(metrics.managerActivity, {});
|
|
1355
|
+
}
|
|
1356
|
+
finally {
|
|
1357
|
+
process.env.DB_DIR = savedDbDir;
|
|
1358
|
+
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1359
|
+
}
|
|
1360
|
+
});
|
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;
|
|
@@ -1015,3 +1030,85 @@ export const processTestRequest = (
|
|
|
1015
1030
|
throw new Error('Failed to process test request', {cause: error});
|
|
1016
1031
|
}
|
|
1017
1032
|
});
|
|
1033
|
+
|
|
1034
|
+
// METRICS FUNCTIONS
|
|
1035
|
+
|
|
1036
|
+
// Name of the cookie that excludes a browser's requests from usage metrics.
|
|
1037
|
+
export const metricsExclusionCookieName = 'kilotestExclude';
|
|
1038
|
+
// Returns the value that the metrics-exclusion cookie must have to be honored: a SHA-256
|
|
1039
|
+
// hash of AUTH_CODE, not AUTH_CODE itself, so the secret is never placed in a long-lived
|
|
1040
|
+
// browser cookie, and not a fixed value, since Kilotest's source is public and a fixed
|
|
1041
|
+
// value would let anyone read it and exclude themselves from metrics at no cost.
|
|
1042
|
+
export const getExclusionCookieValue = (): string =>
|
|
1043
|
+
crypto.createHash('sha256').update(process.env.AUTH_CODE ?? '').digest('hex');
|
|
1044
|
+
// Concurrency lock for the `metrics.json` file.
|
|
1045
|
+
const metricsLock = createLock();
|
|
1046
|
+
// Returns the usage metrics, creating the file with zeroed counts if it does not yet exist.
|
|
1047
|
+
export const getMetrics = async (): Promise<Metrics> => {
|
|
1048
|
+
let metricsJSON: string;
|
|
1049
|
+
try {
|
|
1050
|
+
metricsJSON = await fs.readFile(metricsPath(), 'utf8');
|
|
1051
|
+
}
|
|
1052
|
+
catch(error: unknown) {
|
|
1053
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
1054
|
+
const metrics: Metrics = {
|
|
1055
|
+
since: getNowStamp(), pageViews: {}, mcpToolCalls: {}, apiOperations: {}, managerActivity: {}
|
|
1056
|
+
};
|
|
1057
|
+
await fs.writeFile(metricsPath(), getJSON(metrics));
|
|
1058
|
+
return metrics;
|
|
1059
|
+
}
|
|
1060
|
+
throw new Error(`Metrics file not readable (${errorMessage(error)})`, {cause: error});
|
|
1061
|
+
}
|
|
1062
|
+
let metrics: Partial<Metrics>;
|
|
1063
|
+
try {
|
|
1064
|
+
metrics = JSON.parse(metricsJSON) as Partial<Metrics>;
|
|
1065
|
+
}
|
|
1066
|
+
catch(error: unknown) {
|
|
1067
|
+
throw new Error(`Metrics file not JSON (${errorMessage(error)})`, {cause: error});
|
|
1068
|
+
}
|
|
1069
|
+
// Backfill any category absent from a file written before that category existed,
|
|
1070
|
+
// so an older metrics.json (e.g. from before a schema change) remains readable.
|
|
1071
|
+
return {
|
|
1072
|
+
since: metrics.since ?? getNowStamp(),
|
|
1073
|
+
pageViews: metrics.pageViews ?? {},
|
|
1074
|
+
mcpToolCalls: metrics.mcpToolCalls ?? {},
|
|
1075
|
+
apiOperations: metrics.apiOperations ?? {},
|
|
1076
|
+
managerActivity: metrics.managerActivity ?? {}
|
|
1077
|
+
};
|
|
1078
|
+
};
|
|
1079
|
+
// Records an occurrence of a named event (a web page view, an MCP tool call, or an API
|
|
1080
|
+
// operation call) in the usage metrics, creating the category and name if not yet present.
|
|
1081
|
+
export function recordMetric(
|
|
1082
|
+
category: 'pageViews' | 'mcpToolCalls' | 'apiOperations', name: string
|
|
1083
|
+
): Promise<void>;
|
|
1084
|
+
// Records an outcome of a manager-page visit or submission (a distinct category from
|
|
1085
|
+
// pageViews, so a spike in failed authCode attempts against a manager page is visible
|
|
1086
|
+
// as a signal of suspected abuse, rather than being folded into ordinary page views).
|
|
1087
|
+
export function recordMetric(
|
|
1088
|
+
category: 'managerActivity', name: string, outcome: 'ok' | 'error'
|
|
1089
|
+
): Promise<void>;
|
|
1090
|
+
export function recordMetric(
|
|
1091
|
+
category: 'pageViews' | 'mcpToolCalls' | 'apiOperations' | 'managerActivity',
|
|
1092
|
+
name: string,
|
|
1093
|
+
outcome?: 'ok' | 'error'
|
|
1094
|
+
): Promise<void> {
|
|
1095
|
+
return metricsLock(async (): Promise<void> => {
|
|
1096
|
+
const metrics = await getMetrics();
|
|
1097
|
+
if (category === 'managerActivity') {
|
|
1098
|
+
const entry = metrics.managerActivity[name] ??= {ok: 0, error: 0};
|
|
1099
|
+
entry[outcome!]++;
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
metrics[category][name] = (metrics[category][name] ?? 0) + 1;
|
|
1103
|
+
}
|
|
1104
|
+
await fs.writeFile(metricsPath(), getJSON(metrics));
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
// Resets all usage metrics to empty and since to now.
|
|
1108
|
+
export const clearMetrics = (): Promise<Metrics> => metricsLock(async (): Promise<Metrics> => {
|
|
1109
|
+
const metrics: Metrics = {
|
|
1110
|
+
since: getNowStamp(), pageViews: {}, mcpToolCalls: {}, apiOperations: {}, managerActivity: {}
|
|
1111
|
+
};
|
|
1112
|
+
await fs.writeFile(metricsPath(), getJSON(metrics));
|
|
1113
|
+
return metrics;
|
|
1114
|
+
});
|
|
@@ -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
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<h1><a href="/">Kilotest</a>: Record AI service 0 balance</h1>
|
|
16
16
|
<p>__oldBalance__ recorded AI service 0 balance.</p>
|
|
17
17
|
<p>The current AI service 0 balance is available on the <a href="https://platform.claude.com/settings/billing">Claude Platform Billing page</a>.</p>
|
|
18
|
-
<form action="/ai0BalanceForm.html">
|
|
18
|
+
<form action="/ai0BalanceForm.html" method="post">
|
|
19
19
|
<p><label>
|
|
20
20
|
Balance (in $):
|
|
21
21
|
<input type="number" size="5" min="0" max="99.99" step="0.01" name="newBalance" required>
|
|
@@ -11,14 +11,16 @@ import {getJSON, populateTemplate} from '../../util.ts';
|
|
|
11
11
|
|
|
12
12
|
// FUNCTIONS
|
|
13
13
|
|
|
14
|
-
// Returns a form for recording the AI service 0 balance.
|
|
15
|
-
|
|
14
|
+
// Returns a form for recording the AI service 0 balance. A GET request never processes a
|
|
15
|
+
// submission, regardless of its query string; only a POST request (the form's own
|
|
16
|
+
// submission) does.
|
|
17
|
+
export const answer = async (_: any, search: string, method: string) => {
|
|
16
18
|
const searchParams = new URLSearchParams(search);
|
|
17
19
|
const authCode = searchParams?.get('authCode');
|
|
18
20
|
const newBalanceString = searchParams?.get('newBalance');
|
|
19
21
|
let oldBalance: any;
|
|
20
|
-
// If the form
|
|
21
|
-
if (newBalanceString) {
|
|
22
|
+
// If the form has been submitted:
|
|
23
|
+
if (method === 'POST' && newBalanceString) {
|
|
22
24
|
// If the authorization code is valid:
|
|
23
25
|
if (authCode === process.env.AUTH_CODE) {
|
|
24
26
|
const newBalance = Number.parseFloat(newBalanceString);
|
|
@@ -40,10 +42,12 @@ export const answer = async (_: any, search: string) => {
|
|
|
40
42
|
}
|
|
41
43
|
// Otherwise, i.e. if the authorization code is invalid:
|
|
42
44
|
else {
|
|
43
|
-
// Report the error
|
|
45
|
+
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
46
|
+
// authorization code specifically (as opposed to some other part of the request) is
|
|
47
|
+
// what was wrong.
|
|
44
48
|
return {
|
|
45
49
|
status: 'error',
|
|
46
|
-
message: 'Invalid
|
|
50
|
+
message: 'Invalid request'
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
}
|
package/web/enqueue/index.ts
CHANGED
|
@@ -48,9 +48,12 @@ export const answer = async (url: string, description: string, authCode: string)
|
|
|
48
48
|
answerPage
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
-
// Otherwise, i.e. if the authorization code is invalid, return an error page.
|
|
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.
|
|
52
55
|
return {
|
|
53
56
|
status: 'error',
|
|
54
|
-
message: 'Invalid
|
|
57
|
+
message: 'Invalid request'
|
|
55
58
|
};
|
|
56
59
|
};
|
|
@@ -35,32 +35,40 @@ after(async () => {
|
|
|
35
35
|
|
|
36
36
|
// TESTS
|
|
37
37
|
|
|
38
|
-
test('expungeReportsForm displays a list of reports
|
|
39
|
-
const result: any = await answer(null, '');
|
|
38
|
+
test('expungeReportsForm displays a list of reports on a GET request', async () => {
|
|
39
|
+
const result: any = await answer(null, '', 'GET');
|
|
40
40
|
assert.equal(result.status, 'ok');
|
|
41
41
|
assert.ok(result.answerPage);
|
|
42
42
|
const html = parse(result.answerPage);
|
|
43
43
|
assert.ok(html.querySelector('title'));
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
test('expungeReportsForm
|
|
47
|
-
const
|
|
46
|
+
test('expungeReportsForm ignores a report query string on a GET request', async () => {
|
|
47
|
+
const reportPath = path.join(reportsPath(), '260101T0001-ct.json');
|
|
48
|
+
const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0001-ct', 'GET');
|
|
49
|
+
assert.equal(result.status, 'ok');
|
|
50
|
+
// The report should NOT have been deleted by a mere GET request.
|
|
51
|
+
await fs.access(reportPath);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('expungeReportsForm returns an error for an invalid auth code on a POST request', async () => {
|
|
55
|
+
const result: any = await answer(null, 'authCode=wrong&report=260101T0001-ct', 'POST');
|
|
48
56
|
assert.equal(result.status, 'error');
|
|
49
|
-
assert.equal(result.message, 'Invalid
|
|
57
|
+
assert.equal(result.message, 'Invalid request');
|
|
50
58
|
});
|
|
51
59
|
|
|
52
|
-
test('expungeReportsForm returns an error when deleting a nonexistent report', async () => {
|
|
53
|
-
const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
|
|
60
|
+
test('expungeReportsForm returns an error when deleting a nonexistent report on a POST request', async () => {
|
|
61
|
+
const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope', 'POST');
|
|
54
62
|
assert.equal(result.status, 'error');
|
|
55
63
|
assert.ok(result.message.includes('Deleting sole reports'));
|
|
56
64
|
});
|
|
57
65
|
|
|
58
|
-
test('expungeReportsForm deletes a sole report with valid auth code', async () => {
|
|
66
|
+
test('expungeReportsForm deletes a sole report with valid auth code on a POST request', async () => {
|
|
59
67
|
// 260101T0001-ct is the only report for its URL (canttell).
|
|
60
68
|
const reportPath = path.join(reportsPath(), '260101T0001-ct.json');
|
|
61
69
|
const backup = await fs.readFile(reportPath, 'utf8');
|
|
62
70
|
try {
|
|
63
|
-
const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0001-ct');
|
|
71
|
+
const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0001-ct', 'POST');
|
|
64
72
|
assert.equal(result.status, 'ok');
|
|
65
73
|
await fs.access(reportPath).then(
|
|
66
74
|
() => {throw new Error('Report should have been deleted');},
|
|
@@ -77,7 +85,7 @@ test('expungeReportsForm returns an error when a report file is corrupt', async
|
|
|
77
85
|
const backup = await fs.readFile(reportPath, 'utf8');
|
|
78
86
|
try {
|
|
79
87
|
await fs.writeFile(reportPath, 'not valid json');
|
|
80
|
-
const result: any = await answer(null, '');
|
|
88
|
+
const result: any = await answer(null, '', 'GET');
|
|
81
89
|
assert.equal(result.status, 'error');
|
|
82
90
|
}
|
|
83
91
|
finally {
|
|
@@ -100,7 +108,7 @@ test('expungeReportsForm shows no-deletable message when every URL has at least
|
|
|
100
108
|
process.env.DB_DIR = tmpDir;
|
|
101
109
|
try {
|
|
102
110
|
const {answer} = await import('./index.ts');
|
|
103
|
-
const result: any = await answer(null, '');
|
|
111
|
+
const result: any = await answer(null, '', 'GET');
|
|
104
112
|
assert.equal(result.status, 'ok');
|
|
105
113
|
assert.ok(result.answerPage.includes('no reports to delete'));
|
|
106
114
|
assert.ok(result.answerPage.includes('disabled'));
|
|
@@ -10,7 +10,7 @@ import {reportDeletionForm} from '../reportDeletion.ts';
|
|
|
10
10
|
// FUNCTIONS
|
|
11
11
|
|
|
12
12
|
// Returns a form for deleting sole reports.
|
|
13
|
-
export const answer = async (_: any, search: string) => reportDeletionForm(import.meta.dirname, search, {
|
|
13
|
+
export const answer = async (_: any, search: string, method: string) => reportDeletionForm(import.meta.dirname, search, method, {
|
|
14
14
|
intro: 'Choose the sole reports to delete.',
|
|
15
15
|
emptyIntro: 'Each target has at least 2 reports, so there are no reports to delete.',
|
|
16
16
|
failurePrefix: 'Deleting sole reports failed',
|
|
@@ -44,8 +44,8 @@ after(async () => {
|
|
|
44
44
|
|
|
45
45
|
// TESTS
|
|
46
46
|
|
|
47
|
-
test('hideReportForm displays a list of reports
|
|
48
|
-
const result: any = await answer(null, '');
|
|
47
|
+
test('hideReportForm displays a list of reports 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);
|
|
@@ -53,13 +53,28 @@ test('hideReportForm displays a list of reports when no submission', async () =>
|
|
|
53
53
|
assert.ok(radios.length > 0);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test('hideReportForm
|
|
57
|
-
const
|
|
56
|
+
test('hideReportForm ignores a report query string on a GET request', async () => {
|
|
57
|
+
const reportPath = path.join(reportsPath(), '260101T0009-brd.json');
|
|
58
|
+
const hiddenPath = path.join(hiddenReportsPath(), '260101T0009-brd.json');
|
|
59
|
+
try {
|
|
60
|
+
await fs.rename(hiddenPath, reportPath);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Report may already be in reports.
|
|
64
|
+
}
|
|
65
|
+
const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0009-brd', 'GET');
|
|
66
|
+
assert.equal(result.status, 'ok');
|
|
67
|
+
// The report should NOT have been hidden by a mere GET request.
|
|
68
|
+
await fs.access(reportPath);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('hideReportForm returns an error for an invalid auth code on a POST request', async () => {
|
|
72
|
+
const result: any = await answer(null, 'authCode=wrong&report=260101T0009-brd', 'POST');
|
|
58
73
|
assert.equal(result.status, 'error');
|
|
59
|
-
assert.equal(result.message, 'Invalid
|
|
74
|
+
assert.equal(result.message, 'Invalid request');
|
|
60
75
|
});
|
|
61
76
|
|
|
62
|
-
test('hideReportForm hides a report with valid auth code', async () => {
|
|
77
|
+
test('hideReportForm hides a report with valid auth code on a POST request', async () => {
|
|
63
78
|
const reportPath = path.join(reportsPath(), '260101T0009-brd.json');
|
|
64
79
|
const hiddenPath = path.join(hiddenReportsPath(), '260101T0009-brd.json');
|
|
65
80
|
// Ensure the report exists in reports and not in hidden.
|
|
@@ -69,14 +84,14 @@ test('hideReportForm hides a report with valid auth code', async () => {
|
|
|
69
84
|
catch {
|
|
70
85
|
// Report may already be in reports.
|
|
71
86
|
}
|
|
72
|
-
const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0009-brd');
|
|
87
|
+
const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0009-brd', 'POST');
|
|
73
88
|
assert.equal(result.status, 'ok');
|
|
74
89
|
// The report should now be in the hidden directory.
|
|
75
90
|
await fs.access(hiddenPath);
|
|
76
91
|
});
|
|
77
92
|
|
|
78
|
-
test('hideReportForm returns an error when hiding a nonexistent report', async () => {
|
|
79
|
-
const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
|
|
93
|
+
test('hideReportForm returns an error when hiding a nonexistent report on a POST request', async () => {
|
|
94
|
+
const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope', 'POST');
|
|
80
95
|
assert.equal(result.status, 'error');
|
|
81
96
|
assert.ok(result.message.includes('Hiding report'));
|
|
82
97
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<main>
|
|
15
15
|
<h1><a href="/">Kilotest</a>: Classify report as experimental</h1>
|
|
16
16
|
<p>Choose a report to classify as experimental.</p>
|
|
17
|
-
<form action="/hideReportForm.html">
|
|
17
|
+
<form action="/hideReportForm.html" method="post">
|
|
18
18
|
<fieldset>
|
|
19
19
|
<legend>Reports</legend>
|
|
20
20
|
__reports__
|