@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/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
@@ -52,8 +52,7 @@ import {
52
52
  processTestRequest,
53
53
  testRequestsLock,
54
54
  testRequestsPath,
55
- reportsPath,
56
- addTestRequest
55
+ reportsPath
57
56
  } from './util.ts';
58
57
 
59
58
  // TESTS
@@ -366,9 +365,8 @@ test('objectSort sorts objects numerically descending', () => {
366
365
 
367
366
  // TESTS FOR processTestRequest AND isReportAvailable
368
367
 
369
- // These tests use the fixture database directory and the real web/requestTest template.
368
+ // These tests use the fixture database directory.
370
369
  import {fixtureDBDir as fixtureDbDir} from './test/dbFixture.ts';
371
- const requestTestDir = path.join(import.meta.dirname, 'web', 'requestTest');
372
370
  const savedDbDir = process.env.DB_DIR;
373
371
 
374
372
  before(() => {
@@ -428,44 +426,15 @@ test('getPageDataStrings uses provided pageData instead of reading the report',
428
426
  assert.ok(strings.testInfo.includes('1 day ago'));
429
427
  });
430
428
 
431
- test('processTestRequest returns an error for an invalid test type', async () => {
432
- const result: any = await processTestRequest('invalid', requestTestDir, 'Page', 'https://example.com', 'because');
433
- assert.equal(result.status, 'error');
434
- assert.equal(result.message, 'Invalid request');
435
- });
436
-
437
- test('processTestRequest returns an error for an invalid URL', async () => {
438
- const result: any = await processTestRequest('test', requestTestDir, 'Page', 'not-a-url', 'because');
439
- assert.equal(result.status, 'error');
440
- assert.equal(result.message, 'Invalid request');
441
- });
442
-
443
- test('processTestRequest returns an error for a short reason', async () => {
444
- const result: any = await processTestRequest('test', requestTestDir, 'Page', 'https://example.com', 'abc');
445
- assert.equal(result.status, 'error');
446
- assert.equal(result.message, 'Invalid request');
447
- });
448
-
449
- test('processTestRequest returns an error for a missing description', async () => {
450
- const result: any = await processTestRequest('test', requestTestDir, '', 'https://example.com', 'because');
451
- assert.equal(result.status, 'error');
452
- assert.equal(result.message, 'Invalid request');
453
- });
454
-
455
- test('processTestRequest returns an error for a mismatched directory name', async () => {
456
- const result: any = await processTestRequest('test', '/tmp/wrongDir', 'Page', 'https://example.com', 'because');
457
- assert.equal(result.status, 'error');
458
- assert.equal(result.message, 'Invalid request');
459
- });
460
-
461
-
462
- test('processTestRequest succeeds and populates the template for a valid request', {timeout: 500}, async () => {
429
+ test('processTestRequest succeeds for a valid new-test request', {timeout: 500}, async () => {
463
430
  // Reset testRequests.json to empty before the test.
464
431
  await fs.writeFile(testRequestsPath(), '{}\n');
465
- const result: any = await processTestRequest('test', requestTestDir, 'Example Page', 'https://example.com', 'because accessibility');
466
- assert.equal(result.status, 'ok');
467
- assert.ok(result.answerPage.includes('Example Page'));
468
- assert.ok(result.answerPage.includes('because accessibility'));
432
+ const {result} = await processTestRequest(
433
+ 'because accessibility', {description: 'Example Page', url: 'https://example.com'}
434
+ );
435
+ assert.equal(result, 'ok');
436
+ const testRequests = JSON.parse(await fs.readFile(testRequestsPath(), 'utf8'));
437
+ assert.equal(testRequests['https://example.com'][0].description, 'Example Page');
469
438
  // Clean up testRequests.json.
470
439
  await fs.writeFile(testRequestsPath(), '{}\n');
471
440
  });
@@ -473,15 +442,23 @@ test('processTestRequest succeeds and populates the template for a valid request
473
442
  test('processTestRequest returns a duplicate error for a repeated request', {timeout: 500}, async () => {
474
443
  // Reset testRequests.json to empty, then make a successful request.
475
444
  await fs.writeFile(testRequestsPath(), '{}\n');
476
- await processTestRequest('test', requestTestDir, 'Example Page', 'https://example.com', 'because accessibility');
477
- // Repeat the same request.
478
- const result: any = await processTestRequest('test', requestTestDir, 'Example Page', 'https://example.com', 'another reason');
479
- assert.equal(result.status, 'error');
480
- assert.equal(result.message, 'Duplicate request');
445
+ const target = {description: 'Example Page', url: 'https://example.com'};
446
+ await processTestRequest('because accessibility', target);
447
+ // Repeat the same request (same description, URL, and reason).
448
+ const {result} = await processTestRequest('because accessibility', target);
449
+ assert.equal(result, 'duplicate');
481
450
  // Clean up testRequests.json.
482
451
  await fs.writeFile(testRequestsPath(), '{}\n');
483
452
  });
484
453
 
454
+ test('processTestRequest returns a retest error when a report already exists for the page', async () => {
455
+ // The fixture database already has a report for this description and URL.
456
+ const {result} = await processTestRequest(
457
+ 'because accessibility', {description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}
458
+ );
459
+ assert.equal(result, 'retest');
460
+ });
461
+
485
462
  test('annotateReportObject annotates a report object in place without reading or writing a file', async () => {
486
463
  // A report object that was never written to disk (e.g. one just received from a worker,
487
464
  // as in index.ts's worker/report handler, which annotates before the first write).
@@ -558,6 +535,92 @@ test('isReportAvailable returns false for an unknown page and URL', async () =>
558
535
  assert.equal(result, false);
559
536
  });
560
537
 
538
+ // TESTS FOR processTestRequest's claimed/queued-job detection
539
+
540
+ test('processTestRequest returns "description" for a page matching a claimed job by description', async () => {
541
+ const claimedPath = path.join(jobsPath(), 'claimed', 'clm.json');
542
+ await fs.writeFile(claimedPath, getJSON({
543
+ target: {what: 'Claimed Page', url: 'https://example.com/claimed-job'}
544
+ }));
545
+ try {
546
+ const {result} = await processTestRequest(
547
+ 'because', {description: 'Claimed Page', url: 'https://example.com/some-other-url'}
548
+ );
549
+ assert.equal(result, 'description');
550
+ }
551
+ finally {
552
+ await fs.unlink(claimedPath);
553
+ }
554
+ });
555
+
556
+ test('processTestRequest returns "url" for a page matching a claimed job by URL', async () => {
557
+ const claimedPath = path.join(jobsPath(), 'claimed', 'clm.json');
558
+ await fs.writeFile(claimedPath, getJSON({
559
+ target: {what: 'Some Claimed Page', url: 'https://example.com/claimed-url'}
560
+ }));
561
+ try {
562
+ const {result} = await processTestRequest(
563
+ 'because', {description: 'A Different Page', url: 'https://example.com/claimed-url'}
564
+ );
565
+ assert.equal(result, 'url');
566
+ }
567
+ finally {
568
+ await fs.unlink(claimedPath);
569
+ }
570
+ });
571
+
572
+ test('processTestRequest returns "description" for a page matching a queued job by description', async () => {
573
+ const queuedPath = path.join(jobsPath(), 'queue', 'que.json');
574
+ await fs.writeFile(queuedPath, getJSON({
575
+ target: {what: 'Queued Page', url: 'https://example.com/queued-job'}
576
+ }));
577
+ try {
578
+ const {result} = await processTestRequest(
579
+ 'because', {description: 'Queued Page', url: 'https://example.com/some-other-url'}
580
+ );
581
+ assert.equal(result, 'description');
582
+ }
583
+ finally {
584
+ await fs.unlink(queuedPath);
585
+ }
586
+ });
587
+
588
+ test('processTestRequest returns "url" for a page matching a queued job by URL', async () => {
589
+ const queuedPath = path.join(jobsPath(), 'queue', 'que.json');
590
+ await fs.writeFile(queuedPath, getJSON({
591
+ target: {what: 'Some Queued Page', url: 'https://example.com/queued-url'}
592
+ }));
593
+ try {
594
+ const {result} = await processTestRequest(
595
+ 'because', {description: 'A Different Page', url: 'https://example.com/queued-url'}
596
+ );
597
+ assert.equal(result, 'url');
598
+ }
599
+ finally {
600
+ await fs.unlink(queuedPath);
601
+ }
602
+ });
603
+
604
+ test('processTestRequest returns "nonreport" when the cited report does not exist', async () => {
605
+ // Cite a timeStamp/jobID that does not correspond to any available report.
606
+ const {result} = await processTestRequest('because', {timeStamp: '999999T9999', jobID: 'xxx'});
607
+ assert.equal(result, 'nonreport');
608
+ });
609
+
610
+ test('processTestRequest throws when a job file in a category directory is defective', async () => {
611
+ const claimedPath = path.join(jobsPath(), 'claimed', 'bad.json');
612
+ await fs.writeFile(claimedPath, 'not valid json');
613
+ try {
614
+ await assert.rejects(
615
+ processTestRequest('because', {description: 'Any Page', url: 'https://example.com/any'}),
616
+ /Failed to process test request/
617
+ );
618
+ }
619
+ finally {
620
+ await fs.unlink(claimedPath);
621
+ }
622
+ });
623
+
561
624
  // TESTS FOR REMAINING BRANCH COVERAGE
562
625
 
563
626
  test('getJobNames creates missing job directories and returns empty arrays', async () => {
@@ -605,6 +668,93 @@ test('getJobNames throws when a job directory is a file, not a directory', async
605
668
  }
606
669
  });
607
670
 
671
+ test('getJobsData returns an empty array, instead of throwing ENOENT, when its category directory does not yet exist', async () => {
672
+ // Regression test: on a fresh deployment, jobs/claimed and jobs/queue may never
673
+ // have been created yet (no job has ever been claimed or queued there). getJobsData
674
+ // must tolerate that the same way getJobNames does, rather than reading the
675
+ // category directory directly and throwing ENOENT.
676
+ const os = await import('node:os');
677
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-jobsdata-'));
678
+ const savedDbDir = process.env.DB_DIR;
679
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
680
+ try {
681
+ const {getJobsData} = await import('./util.ts');
682
+ const result = await getJobsData('claimed');
683
+ assert.deepEqual(result, []);
684
+ }
685
+ finally {
686
+ process.env.DB_DIR = savedDbDir;
687
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
688
+ }
689
+ });
690
+
691
+ test('getJobsData skips a job file that is removed between the directory listing and its read', async (t) => {
692
+ // Regression test: on the deployed server, a worker can complete or reclaim a
693
+ // claimed job (moving or deleting its file) between getJobsData listing the
694
+ // claimed directory and reading a specific file it found there. That race must
695
+ // result in the vanished job simply being skipped, not an uncaught ENOENT.
696
+ const claimedPath = path.join(jobsPath(), 'claimed', 'raced.json');
697
+ await fs.writeFile(claimedPath, getJSON({
698
+ target: {what: 'Raced Page', url: 'https://example.com/raced'}
699
+ }));
700
+ const originalReadFile = fs.readFile;
701
+ t.mock.method(fs, 'readFile', async (filePath: any, ...rest: any[]) => {
702
+ // Simulate the file having been removed by a worker just before this read.
703
+ if (String(filePath) === claimedPath) {
704
+ await fs.unlink(claimedPath);
705
+ const error: any = new Error('ENOENT: no such file or directory');
706
+ error.code = 'ENOENT';
707
+ throw error;
708
+ }
709
+ return (originalReadFile as any)(filePath, ...rest);
710
+ });
711
+ try {
712
+ const {getJobsData} = await import('./util.ts');
713
+ const result = await getJobsData('claimed');
714
+ assert.ok(!result.some(job => job.description === 'Raced Page'));
715
+ }
716
+ finally {
717
+ t.mock.reset();
718
+ await fs.unlink(claimedPath).catch(() => {});
719
+ }
720
+ });
721
+
722
+ test('getJobsData throws when a job file cannot be read for a reason other than its removal', async () => {
723
+ // A directory where a job file is expected causes fs.readFile to fail with
724
+ // EISDIR, not ENOENT, so getJobsData must still treat it as defective (unlike
725
+ // the ENOENT case above, which is skipped as a normal race with a worker).
726
+ const claimedDirAsFile = path.join(jobsPath(), 'claimed', 'notAFile.json');
727
+ await fs.mkdir(claimedDirAsFile);
728
+ try {
729
+ const {getJobsData} = await import('./util.ts');
730
+ await assert.rejects(getJobsData('claimed'), /defective/);
731
+ }
732
+ finally {
733
+ await fs.rm(claimedDirAsFile, {recursive: true}).catch(() => {});
734
+ }
735
+ });
736
+
737
+ test('processTestRequest succeeds on a fresh deployment with no job directories yet', async () => {
738
+ // Regression test for the same ENOENT scenario, exercised through the actual
739
+ // public interface: a brand-new DB_DIR with no jobs/claimed or jobs/queue
740
+ // directories must not prevent a new-test request from being approved.
741
+ const os = await import('node:os');
742
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-freshdeploy-'));
743
+ const savedDbDir = process.env.DB_DIR;
744
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
745
+ try {
746
+ const {processTestRequest: freshProcessTestRequest} = await import('./util.ts');
747
+ const {result} = await freshProcessTestRequest(
748
+ 'because accessibility', {description: 'Fresh Deploy Page', url: 'https://example.com/fresh'}
749
+ );
750
+ assert.equal(result, 'ok');
751
+ }
752
+ finally {
753
+ process.env.DB_DIR = savedDbDir;
754
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
755
+ }
756
+ });
757
+
608
758
  test('getObject throws for a file that is not valid JSON', async () => {
609
759
  const tmpFile = path.join((await import('node:os')).tmpdir(), 'kilotest-test-invalid.json');
610
760
  (await import('node:fs')).writeFileSync(tmpFile, 'not json');
@@ -683,69 +833,6 @@ test('getPOSTData resolves with parsed query for form-urlencoded requests', asyn
683
833
  assert.equal(result.why, 'Because');
684
834
  });
685
835
 
686
- test('getRequestability returns "claimed" for a URL in a claimed job', async () => {
687
- const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-requestability-test';
688
- const fsSync = await import('node:fs');
689
- fsSync.mkdirSync(tmpDir + '/jobs/claimed', {recursive: true});
690
- fsSync.mkdirSync(tmpDir + '/jobs/queue', {recursive: true});
691
- fsSync.mkdirSync(tmpDir + '/jobs/failed', {recursive: true});
692
- fsSync.writeFileSync(tmpDir + '/jobs/claimed/job1.json', JSON.stringify({
693
- target: {url: 'https://example.com/test', what: 'Test Page'}
694
- }));
695
- const savedDbDir = process.env.DB_DIR;
696
- process.env.DB_DIR = tmpDir;
697
- try {
698
- const {getRequestability} = await import('./util.ts');
699
- const result = await getRequestability('https://example.com/test');
700
- assert.equal(result, 'claimed');
701
- }
702
- finally {
703
- process.env.DB_DIR = savedDbDir;
704
- fsSync.rmSync(tmpDir, {recursive: true});
705
- }
706
- });
707
-
708
- test('getRequestability returns "queued" for a URL in a queued job', async () => {
709
- const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-requestability-test';
710
- const fsSync = await import('node:fs');
711
- fsSync.mkdirSync(tmpDir + '/jobs/claimed', {recursive: true});
712
- fsSync.mkdirSync(tmpDir + '/jobs/queue', {recursive: true});
713
- fsSync.mkdirSync(tmpDir + '/jobs/failed', {recursive: true});
714
- fsSync.writeFileSync(tmpDir + '/jobs/queue/job1.json', JSON.stringify({
715
- target: {url: 'https://example.com/test', what: 'Test Page'}
716
- }));
717
- const savedDbDir = process.env.DB_DIR;
718
- process.env.DB_DIR = tmpDir;
719
- try {
720
- const {getRequestability} = await import('./util.ts');
721
- const result = await getRequestability('https://example.com/test');
722
- assert.equal(result, 'queued');
723
- }
724
- finally {
725
- process.env.DB_DIR = savedDbDir;
726
- fsSync.rmSync(tmpDir, {recursive: true});
727
- }
728
- });
729
-
730
- test('getRequestability returns empty string for a URL with no matching jobs', async () => {
731
- const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-requestability-test';
732
- const fsSync = await import('node:fs');
733
- fsSync.mkdirSync(tmpDir + '/jobs/claimed', {recursive: true});
734
- fsSync.mkdirSync(tmpDir + '/jobs/queue', {recursive: true});
735
- fsSync.mkdirSync(tmpDir + '/jobs/failed', {recursive: true});
736
- const savedDbDir = process.env.DB_DIR;
737
- process.env.DB_DIR = tmpDir;
738
- try {
739
- const {getRequestability} = await import('./util.ts');
740
- const result = await getRequestability('https://example.com/no-match');
741
- assert.equal(result, '');
742
- }
743
- finally {
744
- process.env.DB_DIR = savedDbDir;
745
- fsSync.rmSync(tmpDir, {recursive: true});
746
- }
747
- });
748
-
749
836
  test('isURL returns false for a malformed URL', () => {
750
837
  assert.equal(isURL('https://[invalid'), false);
751
838
  });
@@ -988,25 +1075,6 @@ test('testRequestsLock is a function (the lock returned by createLock)', () => {
988
1075
  assert.equal(typeof testRequestsLock, 'function');
989
1076
  });
990
1077
 
991
- test('addTestRequest adds a request and returns success', async () => {
992
- await fs.writeFile(testRequestsPath(), '{}\n');
993
- const result = await addTestRequest('Test Page', 'https://example.com/test', 'because');
994
- assert.equal(result.error, undefined);
995
- const testRequests = JSON.parse(await fs.readFile(testRequestsPath(), 'utf8'));
996
- assert.ok(testRequests['https://example.com/test']);
997
- assert.equal(testRequests['https://example.com/test'].length, 1);
998
- assert.equal(testRequests['https://example.com/test'][0].description, 'Test Page');
999
- await fs.writeFile(testRequestsPath(), '{}\n');
1000
- });
1001
-
1002
- test('addTestRequest returns a duplicate error for a repeated request', async () => {
1003
- await fs.writeFile(testRequestsPath(), '{}\n');
1004
- await addTestRequest('Test Page', 'https://example.com/test', 'because');
1005
- const result = await addTestRequest('Test Page', 'https://example.com/test', 'another reason');
1006
- assert.equal(result.error, 'duplicate');
1007
- await fs.writeFile(testRequestsPath(), '{}\n');
1008
- });
1009
-
1010
1078
  test('getReportPath returns the path of a report file', () => {
1011
1079
  const result = getReportPath('260101T0000', 'mix');
1012
1080
  assert.ok(result.endsWith('260101T0000-mix.json'));
@@ -1059,3 +1127,234 @@ test('getMultiReportWhats returns descriptions that have multiple reports', asyn
1059
1127
  const whats = await getMultiReportWhats();
1060
1128
  assert.ok(whats.includes('Mixed Outcomes Page'));
1061
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
+ });