@jrpool/kilotest 47.0.1 → 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.
Files changed (63) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/requestFeature.test.ts +4 -2
  3. package/api/requestFeature.ts +1 -1
  4. package/api/requestRetest.test.ts +66 -7
  5. package/api/requestRetest.ts +55 -39
  6. package/api/requestTest.test.ts +63 -7
  7. package/api/requestTest.ts +36 -26
  8. package/api/util.test.ts +6 -5
  9. package/api/util.ts +2 -24
  10. package/docs/BACKLOG.md +228 -16
  11. package/docs/SERVICE.md +84 -7
  12. package/index.test.ts +354 -25
  13. package/index.ts +174 -8
  14. package/linkValidation.test.ts +1 -0
  15. package/mcp.test.ts +30 -0
  16. package/mcp.ts +9 -0
  17. package/package.json +1 -1
  18. package/smokeTest.ts +16 -0
  19. package/test/dbFixture.ts +8 -0
  20. package/util.test.ts +426 -127
  21. package/util.ts +389 -196
  22. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  23. package/web/ai0BalanceForm/index.html +1 -1
  24. package/web/ai0BalanceForm/index.ts +10 -6
  25. package/web/enqueue/index.ts +5 -2
  26. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  27. package/web/expungeReportsForm/index.html +1 -1
  28. package/web/expungeReportsForm/index.ts +1 -1
  29. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  30. package/web/hideReportForm/index.html +1 -1
  31. package/web/hideReportForm/index.ts +9 -6
  32. package/web/listReports/index.ts +33 -12
  33. package/web/listReports/listReports.test.ts +1 -1
  34. package/web/listTopIssues/index.ts +1 -1
  35. package/web/manage/index.html +2 -1
  36. package/web/metrics/index.html +19 -0
  37. package/web/metrics/index.ts +126 -0
  38. package/web/metrics/metrics.test.ts +211 -0
  39. package/web/pruneReportsForm/index.html +1 -1
  40. package/web/pruneReportsForm/index.ts +1 -1
  41. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  42. package/web/reannotate/index.ts +7 -3
  43. package/web/reannotate/reannotate.test.ts +1 -1
  44. package/web/renewWCAG/index.ts +4 -1
  45. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  46. package/web/reportDeletion.ts +12 -7
  47. package/web/requestRetest/index.html +3 -3
  48. package/web/requestRetest/index.ts +47 -16
  49. package/web/requestRetest/requestRetest.test.ts +38 -24
  50. package/web/requestTest/index.html +4 -4
  51. package/web/requestTest/index.ts +37 -9
  52. package/web/requestTest/requestTest.test.ts +21 -6
  53. package/web/rewindReportsForm/index.html +1 -1
  54. package/web/rewindReportsForm/index.ts +1 -1
  55. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  56. package/web/showHiddenReportsForm/index.html +24 -0
  57. package/web/showHiddenReportsForm/index.ts +45 -0
  58. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  59. package/web/tutorialAI/index.ts +1 -1
  60. package/web/tutorialWeb/index.ts +1 -1
  61. package/web/unhideReportForm/index.html +1 -1
  62. package/web/unhideReportForm/index.ts +11 -8
  63. package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
package/index.test.ts CHANGED
@@ -32,6 +32,7 @@ const {requestHandler, routes, serveError, startServer, runIfMain, isPathAllowed
32
32
 
33
33
  const port = 3997;
34
34
  const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
35
+ const metricsPath = path.join(fixtureDBDir, 'metrics.json');
35
36
 
36
37
  // SETUP AND TEARDOWN
37
38
 
@@ -146,6 +147,31 @@ const jsonBody = (res: any) => {
146
147
  return null;
147
148
  }
148
149
  };
150
+ // Returns the current count for a metrics category and name, or 0 if the file or the
151
+ // entry is absent (the file does not exist until the first metric is recorded).
152
+ const getMetricCount = async (category: string, name: string): Promise<number> => {
153
+ let metricsJSON: string;
154
+ try {
155
+ metricsJSON = await fs.readFile(metricsPath, 'utf8');
156
+ }
157
+ catch {
158
+ return 0;
159
+ }
160
+ const metrics = JSON.parse(metricsJSON);
161
+ return metrics[category][name] ?? 0;
162
+ };
163
+ // Returns the current managerActivity ok/error count for a page name, or 0 if absent.
164
+ const getManagerActivityCount = async (name: string, outcome: 'ok' | 'error'): Promise<number> => {
165
+ let metricsJSON: string;
166
+ try {
167
+ metricsJSON = await fs.readFile(metricsPath, 'utf8');
168
+ }
169
+ catch {
170
+ return 0;
171
+ }
172
+ const metrics = JSON.parse(metricsJSON);
173
+ return metrics.managerActivity[name]?.[outcome] ?? 0;
174
+ };
149
175
 
150
176
  // TESTS: routes table
151
177
 
@@ -267,11 +293,13 @@ test('GET /favicon.ico serves the favicon as an icon', async () => {
267
293
  assert.ok(res.headers['content-type'].includes('image/x-icon'));
268
294
  });
269
295
 
270
- test('GET /api/listReports returns JSON with report data', async () => {
296
+ test('GET /api/listReports returns JSON with report data and records an API-operation metric', async () => {
297
+ const countBefore = await getMetricCount('apiOperations', 'listReports');
271
298
  const res = await request('GET', '/api/listReports');
272
299
  assert.equal(res.statusCode, 200);
273
300
  const body = jsonBody(res);
274
301
  assert.equal(body['tool name'], 'listReports');
302
+ assert.equal(await getMetricCount('apiOperations', 'listReports'), countBefore + 1);
275
303
  });
276
304
 
277
305
  test('GET /api/listIssues/260101T0000/mix returns JSON with issue data', async () => {
@@ -466,13 +494,15 @@ test('POST /api/requestRetest/260202T0000/new with valid JSON returns a JSON res
466
494
  assert.ok(body['tool name'] || body['response content']);
467
495
  });
468
496
 
469
- test('POST /api/requestFeature with valid JSON returns a JSON response', async () => {
497
+ test('POST /api/requestFeature with valid JSON returns a JSON response and records an API-operation metric', async () => {
498
+ const countBefore = await getMetricCount('apiOperations', 'requestFeature');
470
499
  const res = await request('POST', '/api/requestFeature', {
471
500
  feature: 'Add a dark mode toggle'
472
501
  });
473
502
  assert.equal(res.statusCode, 200);
474
503
  const body = jsonBody(res);
475
504
  assert.ok(body['tool name'] || body['response content']);
505
+ assert.equal(await getMetricCount('apiOperations', 'requestFeature'), countBefore + 1);
476
506
  });
477
507
 
478
508
  test('POST /api/invalidService returns an error', async () => {
@@ -616,35 +646,41 @@ test('serveError sends HTML for human requests (isHumanUser = true)', async () =
616
646
 
617
647
  // TESTS: requestAction.html
618
648
 
619
- test('POST /requestAction.html with invalid auth code returns an error', {timeout: 500}, async () => {
649
+ test('POST /requestAction.html with invalid auth code returns an error and records a managerActivity failure', {timeout: 500}, async () => {
650
+ const countBefore = await getManagerActivityCount('requestAction.html', 'error');
620
651
  const res = await formRequest('POST', '/requestAction.html', {
621
652
  target: 'https://example.com\tTest Page',
622
653
  authCode: 'wrong-code'
623
654
  });
624
655
  assert.equal(res.statusCode, 400);
625
- assert.ok(res.body.includes('Invalid test order'));
656
+ assert.ok(res.body.includes('Invalid request'));
657
+ assert.equal(await getManagerActivityCount('requestAction.html', 'error'), countBefore + 1);
626
658
  });
627
659
 
628
- test('POST /requestAction.html with valid auth code and rejection (no description) returns HTML', async () => {
660
+ test('POST /requestAction.html with valid auth code and rejection (no description) returns HTML and records a managerActivity success', async () => {
629
661
  await formRequest('POST', '/requestTest.html', {
630
662
  description: `Reject Test Page ${uniqueStamp}`,
631
663
  url: `https://example.com/reject-${uniqueStamp}`,
632
664
  why: 'Because accessibility matters'
633
665
  });
666
+ const countBefore = await getManagerActivityCount('requestAction.html', 'ok');
634
667
  const res = await formRequest('POST', '/requestAction.html', {
635
668
  target: `https://example.com/reject-${uniqueStamp}`,
636
669
  authCode: 'test-auth-code'
637
670
  });
638
671
  assert.equal(res.statusCode, 200);
639
672
  assert.ok(res.headers['content-type'].includes('text/html'));
673
+ assert.equal(await getManagerActivityCount('requestAction.html', 'ok'), countBefore + 1);
640
674
  });
641
675
 
642
676
  // TESTS: tutorialWeb (web user tutorial)
643
677
 
644
- test('GET /tutorialWeb.html returns HTML', async () => {
678
+ test('GET /tutorialWeb.html returns HTML and records a page-view metric', async () => {
679
+ const countBefore = await getMetricCount('pageViews', 'tutorialWeb');
645
680
  const res = await request('GET', '/tutorialWeb.html');
646
681
  assert.equal(res.statusCode, 200);
647
682
  assert.ok(res.headers['content-type'].includes('text/html'));
683
+ assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), countBefore + 1);
648
684
  });
649
685
 
650
686
  test('POST /tutorialWebComment.html with content returns JSON', async () => {
@@ -678,14 +714,16 @@ test('POST /requestAction.html with valid auth code and approval returns HTML',
678
714
  assert.ok(res.headers['content-type'].includes('text/html'));
679
715
  });
680
716
 
681
- test('POST /reannotate.html with invalid auth code returns an error page', async () => {
717
+ test('POST /reannotate.html with invalid auth code returns an error page and records a managerActivity failure', async () => {
718
+ const countBefore = await getManagerActivityCount('reannotate.html', 'error');
682
719
  const res = await formRequest('POST', '/reannotate.html', {
683
720
  authCode: 'wrong-code'
684
721
  });
685
722
  assert.ok(res.headers['content-type'].includes('text/html'));
723
+ assert.equal(await getManagerActivityCount('reannotate.html', 'error'), countBefore + 1);
686
724
  });
687
725
 
688
- test('POST /reannotate.html with valid auth code serves the answer page', async () => {
726
+ test('POST /reannotate.html with valid auth code serves the answer page and records a managerActivity success', async () => {
689
727
  // Back up all fixture reports, because reannotation modifies them in place.
690
728
  const reportsDir = path.join(fixtureDBDir, 'reports');
691
729
  const reportFiles = await fs.readdir(reportsDir);
@@ -694,12 +732,14 @@ test('POST /reannotate.html with valid auth code serves the answer page', async
694
732
  backups[file] = await fs.readFile(path.join(reportsDir, file), 'utf8');
695
733
  }
696
734
  try {
735
+ const countBefore = await getManagerActivityCount('reannotate.html', 'ok');
697
736
  const res = await formRequest('POST', '/reannotate.html', {
698
737
  authCode: 'test-auth-code'
699
738
  });
700
739
  assert.equal(res.statusCode, 200);
701
740
  assert.ok(res.headers['content-type'].includes('text/html'));
702
741
  assert.ok(res.body.includes('Reannotation order'));
742
+ assert.equal(await getManagerActivityCount('reannotate.html', 'ok'), countBefore + 1);
703
743
  }
704
744
  finally {
705
745
  // Restore all fixture reports.
@@ -709,14 +749,16 @@ test('POST /reannotate.html with valid auth code serves the answer page', async
709
749
  }
710
750
  });
711
751
 
712
- test('POST /renewWCAG.html with invalid auth code returns an error page', async () => {
752
+ test('POST /renewWCAG.html with invalid auth code returns an error page and records a managerActivity failure', async () => {
753
+ const countBefore = await getManagerActivityCount('renewWCAG.html', 'error');
713
754
  const res = await formRequest('POST', '/renewWCAG.html', {
714
755
  authCode: 'wrong-code'
715
756
  });
716
757
  assert.ok(res.headers['content-type'].includes('text/html'));
758
+ assert.equal(await getManagerActivityCount('renewWCAG.html', 'error'), countBefore + 1);
717
759
  });
718
760
 
719
- test('POST /renewWCAG.html with valid auth code serves the answer page', async () => {
761
+ test('POST /renewWCAG.html with valid auth code serves the answer page and records a managerActivity success', async () => {
720
762
  // Mock fetch to avoid a network dependency.
721
763
  const originalFetch = global.fetch;
722
764
  const wcagMapPath = path.join(import.meta.dirname, 'wcagMap.json');
@@ -727,12 +769,14 @@ test('POST /renewWCAG.html with valid auth code serves the answer page', async (
727
769
  text: async () => '<a href="understanding/contrast-minimum"><span class="secno">1.4.3 </span>'
728
770
  });
729
771
  try {
772
+ const countBefore = await getManagerActivityCount('renewWCAG.html', 'ok');
730
773
  const res = await formRequest('POST', '/renewWCAG.html', {
731
774
  authCode: 'test-auth-code'
732
775
  });
733
776
  assert.equal(res.statusCode, 200);
734
777
  assert.ok(res.headers['content-type'].includes('text/html'));
735
778
  assert.ok(res.body.includes('WCAG map renewed'));
779
+ assert.equal(await getManagerActivityCount('renewWCAG.html', 'ok'), countBefore + 1);
736
780
  }
737
781
  finally {
738
782
  global.fetch = originalFetch;
@@ -902,6 +946,101 @@ test('POST /worker/job with a claimed job assigned to the worker returns an erro
902
946
  await fs.unlink(failedJobPath).catch(() => {});
903
947
  });
904
948
 
949
+ test('POST /worker/job skips a claimed job file that disappears between the directory listing and the read', {timeout: 500}, async (t) => {
950
+ // Create a claimed job assigned to a different worker, so it does not match this
951
+ // worker and would otherwise just be skipped normally, then make the read of that
952
+ // specific file (not the directory listing) throw ENOENT, simulating the file
953
+ // vanishing between getJobNames listing the claimed directory and this loop's read
954
+ // of the file it found there (e.g. because a concurrent report submission completed
955
+ // the job just after the listing).
956
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
957
+ const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
958
+ for (const dir of [claimedDir, queueDir]) {
959
+ await fs.mkdir(dir, {recursive: true});
960
+ const files = await fs.readdir(dir).catch(() => []);
961
+ for (const file of files) {
962
+ await fs.unlink(path.join(dir, file)).catch(() => {});
963
+ }
964
+ }
965
+ const jobFile = '260101T0000-mix.json';
966
+ const claimedJobPath = path.join(claimedDir, jobFile);
967
+ await fs.writeFile(claimedJobPath, JSON.stringify({
968
+ id: '260101T0000-mix',
969
+ target: {what: 'Test', url: 'https://example.com/test'},
970
+ sources: {worker: 'Worker Two'}
971
+ }));
972
+ const originalReadFile = fs.readFile;
973
+ t.mock.method(fs, 'readFile', async (filePath: any, ...rest: any[]) => {
974
+ if (String(filePath) === claimedJobPath) {
975
+ await fs.unlink(claimedJobPath);
976
+ const error: any = new Error('ENOENT: no such file or directory');
977
+ error.code = 'ENOENT';
978
+ throw error;
979
+ }
980
+ return (originalReadFile as any)(filePath, ...rest);
981
+ });
982
+ try {
983
+ const auth = Buffer.from('worker1:secret1').toString('base64');
984
+ const res = await request('POST', '/worker/job', {}, {
985
+ authorization: `Basic ${auth}`
986
+ });
987
+ // With no queued jobs and the claimed job having vanished mid-read, the worker
988
+ // should just get told there is no job, rather than a 500 error from an
989
+ // unhandled ENOENT.
990
+ assert.equal(res.statusCode, 200);
991
+ const body = jsonBody(res);
992
+ assert.ok(!body.error);
993
+ }
994
+ finally {
995
+ t.mock.reset();
996
+ await fs.unlink(claimedJobPath).catch(() => {});
997
+ }
998
+ });
999
+
1000
+ test('POST /worker/job propagates a non-ENOENT failure to read a claimed job file', {timeout: 500}, async (t) => {
1001
+ // The catch in processJobRequest's claimed-job loop rethrows any failure other than
1002
+ // the file having vanished (ENOENT). This confirms that a different failure, such as
1003
+ // a permissions error, is not swallowed alongside the expected race condition.
1004
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
1005
+ const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
1006
+ for (const dir of [claimedDir, queueDir]) {
1007
+ await fs.mkdir(dir, {recursive: true});
1008
+ const files = await fs.readdir(dir).catch(() => []);
1009
+ for (const file of files) {
1010
+ await fs.unlink(path.join(dir, file)).catch(() => {});
1011
+ }
1012
+ }
1013
+ const jobFile = '260101T0000-mix.json';
1014
+ const claimedJobPath = path.join(claimedDir, jobFile);
1015
+ await fs.writeFile(claimedJobPath, JSON.stringify({
1016
+ id: '260101T0000-mix',
1017
+ target: {what: 'Test', url: 'https://example.com/test'},
1018
+ sources: {worker: 'Worker Two'}
1019
+ }));
1020
+ const originalReadFile = fs.readFile;
1021
+ t.mock.method(fs, 'readFile', async (filePath: any, ...rest: any[]) => {
1022
+ if (String(filePath) === claimedJobPath) {
1023
+ const error: any = new Error('EACCES: permission denied');
1024
+ error.code = 'EACCES';
1025
+ throw error;
1026
+ }
1027
+ return (originalReadFile as any)(filePath, ...rest);
1028
+ });
1029
+ try {
1030
+ const auth = Buffer.from('worker1:secret1').toString('base64');
1031
+ const res = await request('POST', '/worker/job', {}, {
1032
+ authorization: `Basic ${auth}`
1033
+ });
1034
+ // The request should fail loudly rather than silently proceeding as if there
1035
+ // were no claimed job.
1036
+ assert.equal(res.statusCode, 500);
1037
+ }
1038
+ finally {
1039
+ t.mock.reset();
1040
+ await fs.unlink(claimedJobPath).catch(() => {});
1041
+ }
1042
+ });
1043
+
905
1044
  test('POST /worker/job with a queued job assigns it to the worker', {timeout: 500}, async () => {
906
1045
  // Clean up claimed and queue directories.
907
1046
  const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
@@ -1036,16 +1175,20 @@ test('GET /style.css with a read error returns an error page', async () => {
1036
1175
  }
1037
1176
  });
1038
1177
 
1039
- test('GET /requestTest.html returns an error page when called without arguments', async () => {
1178
+ // requestTest.html and requestRetest.html are POST-only routes (see the
1179
+ // `routes.POST` list above). They also match the generic '*.html*' GET pattern,
1180
+ // but the generic `pageName.endsWith('.html')` branch of the GET dispatcher
1181
+ // excludes any path that is POST-only, since such a path's answer handler
1182
+ // expects POST's argument list and performs no GET-appropriate rendering.
1183
+
1184
+ test('GET /requestTest.html without arguments is rejected as an invalid GET request', async () => {
1040
1185
  const res = await request('GET', '/requestTest.html');
1041
1186
  assert.equal(res.statusCode, 400);
1042
- assert.ok(res.headers['content-type'].includes('text/html'));
1043
1187
  });
1044
1188
 
1045
- test('GET /requestRetest.html/260202T0000/new returns an error page when called as GET', async () => {
1189
+ test('GET /requestRetest.html/260202T0000/new is rejected as an invalid GET request', async () => {
1046
1190
  const res = await request('GET', '/requestRetest.html/260202T0000/new');
1047
1191
  assert.equal(res.statusCode, 400);
1048
- assert.ok(res.headers['content-type'].includes('text/html'));
1049
1192
  });
1050
1193
 
1051
1194
  // TESTS: GET HTML page routing
@@ -1065,11 +1208,12 @@ const htmlPagePaths = [
1065
1208
  '/reannotateForm.html',
1066
1209
  '/renewWCAGForm.html',
1067
1210
  '/hideReportForm.html',
1068
- '/unhideReportForm.html',
1211
+ '/showHiddenReportsForm.html',
1069
1212
  '/expungeReportsForm.html',
1070
1213
  '/pruneReportsForm.html',
1071
1214
  '/rewindReportsForm.html',
1072
- '/ai0BalanceForm.html'
1215
+ '/ai0BalanceForm.html',
1216
+ '/metrics.html'
1073
1217
  ];
1074
1218
 
1075
1219
  for (const pagePath of htmlPagePaths) {
@@ -1080,6 +1224,187 @@ for (const pagePath of htmlPagePaths) {
1080
1224
  });
1081
1225
  }
1082
1226
 
1227
+ test('POST /metrics.html with a valid authCode serves the usage-metrics table and records a managerActivity success', async () => {
1228
+ const countBefore = await getManagerActivityCount('metrics', 'ok');
1229
+ const res = await formRequest('POST', '/metrics.html', {authCode: 'test-auth-code'});
1230
+ assert.equal(res.statusCode, 200);
1231
+ assert.ok(res.headers['content-type'].includes('text/html'));
1232
+ assert.equal(await getManagerActivityCount('metrics', 'ok'), countBefore + 1);
1233
+ });
1234
+
1235
+ test('POST /metrics.html with a valid authCode sets a metrics-exclusion cookie', async () => {
1236
+ const res = await formRequest('POST', '/metrics.html', {authCode: 'test-auth-code'});
1237
+ const setCookie = res.headers['set-cookie']?.[0] ?? res.headers['set-cookie'];
1238
+ assert.ok(setCookie);
1239
+ assert.ok(setCookie.startsWith('kilotestExclude='));
1240
+ assert.ok(setCookie.includes('Max-Age=2592000'));
1241
+ assert.ok(setCookie.includes('HttpOnly'));
1242
+ });
1243
+
1244
+ test('GET /metrics.html with a valid authCode in the query string does not set a cookie or show the counts', async () => {
1245
+ const res = await request('GET', '/metrics.html?authCode=test-auth-code');
1246
+ assert.equal(res.statusCode, 200);
1247
+ assert.equal(res.headers['set-cookie'], undefined);
1248
+ assert.ok(res.body.includes('Authorization code'));
1249
+ assert.ok(!res.body.includes('Web page views'));
1250
+ });
1251
+
1252
+ test('a request carrying the metrics-exclusion cookie does not record a pageViews or apiOperations metric', async () => {
1253
+ const {getExclusionCookieValue} = await import('./util.ts');
1254
+ const cookieHeader = `kilotestExclude=${getExclusionCookieValue()}`;
1255
+ const pageViewCountBefore = await getMetricCount('pageViews', 'tutorialWeb');
1256
+ const apiOperationCountBefore = await getMetricCount('apiOperations', 'listReports');
1257
+ await request('GET', '/tutorialWeb.html', null, {cookie: cookieHeader});
1258
+ await request('GET', '/api/listReports', null, {cookie: cookieHeader});
1259
+ assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), pageViewCountBefore);
1260
+ assert.equal(await getMetricCount('apiOperations', 'listReports'), apiOperationCountBefore);
1261
+ });
1262
+
1263
+ test('a request carrying an invalid exclusion cookie still records metrics normally', async () => {
1264
+ const pageViewCountBefore = await getMetricCount('pageViews', 'tutorialWeb');
1265
+ await request('GET', '/tutorialWeb.html', null, {cookie: 'kilotestExclude=wrong-value'});
1266
+ assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), pageViewCountBefore + 1);
1267
+ });
1268
+
1269
+ test('a request with an unrelated cookie still records metrics normally', async () => {
1270
+ const pageViewCountBefore = await getMetricCount('pageViews', 'tutorialWeb');
1271
+ await request('GET', '/tutorialWeb.html', null, {cookie: 'someOtherCookie=1'});
1272
+ assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), pageViewCountBefore + 1);
1273
+ });
1274
+
1275
+ test('GET / and GET /index.html record a pageViews metric under "index"', async () => {
1276
+ const countBefore = await getMetricCount('pageViews', 'index');
1277
+ await request('GET', '/');
1278
+ await request('GET', '/index.html');
1279
+ assert.equal(await getMetricCount('pageViews', 'index'), countBefore + 2);
1280
+ });
1281
+
1282
+ test('POST /metrics.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
1283
+ const countBefore = await getManagerActivityCount('metrics', 'error');
1284
+ const res = await formRequest('POST', '/metrics.html', {authCode: 'wrong'});
1285
+ assert.equal(res.statusCode, 400);
1286
+ assert.equal(await getManagerActivityCount('metrics', 'error'), countBefore + 1);
1287
+ });
1288
+
1289
+ test('GET /hideReportForm.html records managerActivity, not a pageViews entry', async () => {
1290
+ const managerCountBefore = await getManagerActivityCount('hideReportForm', 'ok');
1291
+ const pageViewCountBefore = await getMetricCount('pageViews', 'hideReportForm');
1292
+ const res = await request('GET', '/hideReportForm.html');
1293
+ assert.equal(res.statusCode, 200);
1294
+ assert.equal(await getManagerActivityCount('hideReportForm', 'ok'), managerCountBefore + 1);
1295
+ assert.equal(await getMetricCount('pageViews', 'hideReportForm'), pageViewCountBefore);
1296
+ });
1297
+
1298
+ // TESTS: hidden report list is never served on a direct GET, only via showHiddenReports
1299
+
1300
+ test('GET /unhideReportForm.html is rejected, since the hidden-report list must not be disclosed without an authorization code', async () => {
1301
+ const res = await request('GET', '/unhideReportForm.html');
1302
+ assert.equal(res.statusCode, 400);
1303
+ assert.ok(!res.body.includes('260101T0007-hid'));
1304
+ });
1305
+
1306
+ test('GET /showHiddenReportsForm.html displays a bare authCode form, disclosing no report names', async () => {
1307
+ const res = await request('GET', '/showHiddenReportsForm.html');
1308
+ assert.equal(res.statusCode, 200);
1309
+ assert.ok(res.body.includes('Authorization code'));
1310
+ assert.ok(!res.body.includes('260101T0007-hid'));
1311
+ });
1312
+
1313
+ test('POST /showHiddenReportsForm.html with a valid authCode serves the hidden-report list and records a managerActivity success', async () => {
1314
+ const countBefore = await getManagerActivityCount('showHiddenReportsForm', 'ok');
1315
+ const res = await formRequest('POST', '/showHiddenReportsForm.html', {authCode: 'test-auth-code'});
1316
+ assert.equal(res.statusCode, 200);
1317
+ assert.ok(res.body.includes('type="radio"'));
1318
+ assert.equal(await getManagerActivityCount('showHiddenReportsForm', 'ok'), countBefore + 1);
1319
+ });
1320
+
1321
+ test('POST /showHiddenReportsForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
1322
+ const countBefore = await getManagerActivityCount('showHiddenReportsForm', 'error');
1323
+ const res = await formRequest('POST', '/showHiddenReportsForm.html', {authCode: 'wrong'});
1324
+ assert.equal(res.statusCode, 400);
1325
+ assert.equal(await getManagerActivityCount('showHiddenReportsForm', 'error'), countBefore + 1);
1326
+ });
1327
+
1328
+ // TESTS: self-submitting manager pages now POST their own submissions (formerly GET)
1329
+
1330
+ test('POST /ai0BalanceForm.html with a valid authCode records the balance and records a managerActivity success', async () => {
1331
+ const countBefore = await getManagerActivityCount('ai0BalanceForm', 'ok');
1332
+ const res = await formRequest('POST', '/ai0BalanceForm.html', {
1333
+ newBalance: '42.50',
1334
+ authCode: 'test-auth-code'
1335
+ });
1336
+ assert.equal(res.statusCode, 200);
1337
+ assert.ok(res.headers['content-type'].includes('text/html'));
1338
+ assert.ok(res.body.includes('$42.5'));
1339
+ assert.equal(await getManagerActivityCount('ai0BalanceForm', 'ok'), countBefore + 1);
1340
+ });
1341
+
1342
+ test('POST /ai0BalanceForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
1343
+ const countBefore = await getManagerActivityCount('ai0BalanceForm', 'error');
1344
+ const res = await formRequest('POST', '/ai0BalanceForm.html', {
1345
+ newBalance: '10',
1346
+ authCode: 'wrong-code'
1347
+ });
1348
+ assert.equal(res.statusCode, 400);
1349
+ assert.equal(await getManagerActivityCount('ai0BalanceForm', 'error'), countBefore + 1);
1350
+ });
1351
+
1352
+ test('POST /hideReportForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
1353
+ const countBefore = await getManagerActivityCount('hideReportForm', 'error');
1354
+ const res = await formRequest('POST', '/hideReportForm.html', {
1355
+ report: '260101T0009-brd',
1356
+ authCode: 'wrong-code'
1357
+ });
1358
+ assert.equal(res.statusCode, 400);
1359
+ assert.equal(await getManagerActivityCount('hideReportForm', 'error'), countBefore + 1);
1360
+ });
1361
+
1362
+ test('POST /unhideReportForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
1363
+ const countBefore = await getManagerActivityCount('unhideReportForm', 'error');
1364
+ const res = await formRequest('POST', '/unhideReportForm.html', {
1365
+ report: '260101T0009-brd',
1366
+ authCode: 'wrong-code'
1367
+ });
1368
+ assert.equal(res.statusCode, 400);
1369
+ assert.equal(await getManagerActivityCount('unhideReportForm', 'error'), countBefore + 1);
1370
+ });
1371
+
1372
+ test('POST /expungeReportsForm.html with no selections shows the form and records a managerActivity success', async () => {
1373
+ const countBefore = await getManagerActivityCount('expungeReportsForm', 'ok');
1374
+ const res = await formRequest('POST', '/expungeReportsForm.html', {});
1375
+ assert.equal(res.statusCode, 200);
1376
+ assert.equal(await getManagerActivityCount('expungeReportsForm', 'ok'), countBefore + 1);
1377
+ });
1378
+
1379
+ test('POST /pruneReportsForm.html with no selections shows the form and records a managerActivity success', async () => {
1380
+ const countBefore = await getManagerActivityCount('pruneReportsForm', 'ok');
1381
+ const res = await formRequest('POST', '/pruneReportsForm.html', {});
1382
+ assert.equal(res.statusCode, 200);
1383
+ assert.equal(await getManagerActivityCount('pruneReportsForm', 'ok'), countBefore + 1);
1384
+ });
1385
+
1386
+ test('POST /rewindReportsForm.html with no selections shows the form and records a managerActivity success', async () => {
1387
+ const countBefore = await getManagerActivityCount('rewindReportsForm', 'ok');
1388
+ const res = await formRequest('POST', '/rewindReportsForm.html', {});
1389
+ assert.equal(res.statusCode, 200);
1390
+ assert.equal(await getManagerActivityCount('rewindReportsForm', 'ok'), countBefore + 1);
1391
+ });
1392
+
1393
+ test('GET /pruneReportsForm.html records a managerActivity failure when a report file is corrupt', {timeout: 500}, async () => {
1394
+ const reportPath = path.join(fixtureDBDir, 'reports', '260101T0001-ct.json');
1395
+ const backup = await fs.readFile(reportPath, 'utf8');
1396
+ const countBefore = await getManagerActivityCount('pruneReportsForm', 'error');
1397
+ try {
1398
+ await fs.writeFile(reportPath, 'not valid json');
1399
+ const res = await request('GET', '/pruneReportsForm.html');
1400
+ assert.equal(res.statusCode, 400);
1401
+ assert.equal(await getManagerActivityCount('pruneReportsForm', 'error'), countBefore + 1);
1402
+ }
1403
+ finally {
1404
+ await fs.writeFile(reportPath, backup);
1405
+ }
1406
+ });
1407
+
1083
1408
  test('GET /enqueueForm.html shows requests when testRequests.json has entries', async () => {
1084
1409
  await fs.writeFile(testRequestsPath, JSON.stringify({
1085
1410
  'https://example.com/enqueue-test': [
@@ -1186,34 +1511,38 @@ test('POST /mcp with a valid initialize request returns server info via SSE', as
1186
1511
 
1187
1512
  test('POST /requestTest.html with valid format but duplicate URL returns an answer error', {timeout: 500}, async () => {
1188
1513
  await fs.writeFile(testRequestsPath, '{}\n');
1189
- // First request to create the request.
1514
+ // First request to create the request. processTestRequest treats a request as a
1515
+ // duplicate only when description, URL, AND reason all match an existing pending
1516
+ // request, so the reason must be identical between the two requests here.
1190
1517
  await formRequest('POST', '/requestTest.html', {
1191
1518
  description: `Dup Test Page ${uniqueStamp}`,
1192
1519
  url: `https://example.com/dup-${uniqueStamp}`,
1193
1520
  why: 'Because accessibility matters'
1194
1521
  });
1195
- // Second request with the same URL should get a duplicate error.
1522
+ // Second, identical request should get a duplicate error.
1196
1523
  const res = await formRequest('POST', '/requestTest.html', {
1197
1524
  description: `Dup Test Page ${uniqueStamp}`,
1198
1525
  url: `https://example.com/dup-${uniqueStamp}`,
1199
- why: 'Because accessibility matters again'
1526
+ why: 'Because accessibility matters'
1200
1527
  });
1201
1528
  assert.equal(res.statusCode, 400);
1202
- assert.ok(res.body.includes('Duplicate request'));
1529
+ assert.ok(res.body.includes('Test request duplicates an already submitted request'));
1203
1530
  });
1204
1531
 
1205
1532
  test('POST /requestRetest.html with valid format but duplicate retest returns an answer error', {timeout: 500}, async () => {
1206
1533
  await fs.writeFile(testRequestsPath, '{}\n');
1207
- // First retest to create the recommendation.
1534
+ // First retest to create the recommendation. As above, the reason must match
1535
+ // exactly between the two requests for processTestRequest to treat the second as
1536
+ // a duplicate rather than a second, independent (and thus also 'ok') request.
1208
1537
  await formRequest('POST', '/requestRetest.html/260202T0000/new', {
1209
1538
  why: 'Because the report is obsolete and needs refreshing'
1210
1539
  });
1211
- // Second retest with the same report should get a duplicate error.
1540
+ // Second, identical retest of the same report should get a duplicate error.
1212
1541
  const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
1213
- why: 'Because the report is obsolete and needs refreshing again'
1542
+ why: 'Because the report is obsolete and needs refreshing'
1214
1543
  });
1215
1544
  assert.equal(res.statusCode, 400);
1216
- assert.ok(res.body.includes('Duplicate request'));
1545
+ assert.ok(res.body.includes('Test request duplicates an already submitted request'));
1217
1546
  });
1218
1547
 
1219
1548
  test('POST /requestAction.html with valid auth code and approval of a duplicate returns an error', {timeout: 500}, async () => {
@@ -1250,7 +1579,7 @@ test('POST /requestAction.html with valid auth code and approval of an invalid U
1250
1579
  what: 'yes'
1251
1580
  });
1252
1581
  assert.equal(res.statusCode, 400);
1253
- assert.ok(res.body.includes('Invalid authorization code'));
1582
+ assert.ok(res.body.includes('Invalid request'));
1254
1583
  });
1255
1584
 
1256
1585
  // UNIT TESTS FOR serveError