@jrpool/kilotest 46.1.2 → 48.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 (153) hide show
  1. package/.claude/settings.local.json +13 -1
  2. package/.devin/config.local.json +5 -1
  3. package/.github/workflows/ci.yml +0 -3
  4. package/.github/workflows/periodic-smoke-tests.yml +30 -0
  5. package/.github/workflows/publish-mcp.yml +4 -4
  6. package/{alerts.test.js → alerts.test.ts} +17 -29
  7. package/{alerts.js → alerts.ts} +10 -13
  8. package/api/{getReport.test.js → getReport.test.ts} +9 -10
  9. package/api/{getReport.js → getReport.ts} +23 -24
  10. package/api/{listDiagnoses.test.js → listDiagnoses.test.ts} +26 -27
  11. package/api/{listDiagnoses.js → listDiagnoses.ts} +46 -70
  12. package/api/{listIssues.test.js → listIssues.test.ts} +21 -14
  13. package/api/{listIssues.js → listIssues.ts} +75 -74
  14. package/api/{listReports.test.js → listReports.test.ts} +17 -16
  15. package/api/listReports.ts +87 -0
  16. package/api/{listViolators.test.js → listViolators.test.ts} +19 -20
  17. package/api/{listViolators.js → listViolators.ts} +45 -70
  18. package/api/requestFeature.test.ts +54 -0
  19. package/api/{requestFeature.js → requestFeature.ts} +18 -15
  20. package/api/requestRetest.test.ts +144 -0
  21. package/api/requestRetest.ts +119 -0
  22. package/api/requestTest.test.ts +153 -0
  23. package/api/requestTest.ts +123 -0
  24. package/api/{routes.test.js → routes.test.ts} +5 -5
  25. package/api/{routes.js → routes.ts} +6 -6
  26. package/api/{schemas.js → schemas.ts} +69 -50
  27. package/api/{util.test.js → util.test.ts} +35 -14
  28. package/api/{util.js → util.ts} +54 -51
  29. package/api/version.ts +8 -0
  30. package/{apiIntegration.test.js → apiIntegration.test.ts} +12 -13
  31. package/balances.ts +87 -0
  32. package/{checkinfo.js → checkinfo.ts} +24 -20
  33. package/docs/AI-TOOLS.md +4 -4
  34. package/docs/BACKLOG.md +360 -0
  35. package/docs/SERVICE.md +170 -17
  36. package/docs/decisions/002-coverage-tool.md +40 -0
  37. package/docs/decisions/003-adr-threshold.md +34 -0
  38. package/docs/decisions/004-docs-directory.md +23 -0
  39. package/docs/decisions/005-observability.md +28 -0
  40. package/docs/decisions/006-git-workflow.md +28 -0
  41. package/docs/decisions/007-internal-testing.md +34 -0
  42. package/docs/decisions/008-dependencies.md +28 -0
  43. package/docs/decisions/009-qai-integration.md +40 -0
  44. package/docs/decisions/QAI-HISTORICAL-CONTEXT.md +42 -0
  45. package/eslint.config.mjs +27 -0
  46. package/index.html +3 -3
  47. package/{index.test.js → index.test.ts} +397 -152
  48. package/{index.js → index.ts} +386 -285
  49. package/{indexBalances.test.js → indexBalances.test.ts} +21 -17
  50. package/{indexServe.test.js → indexServe.test.ts} +9 -9
  51. package/linkValidation.test.ts +190 -0
  52. package/{mcp.test.js → mcp.test.ts} +53 -26
  53. package/{mcp.js → mcp.ts} +29 -20
  54. package/memory/MEMORY.md +7 -0
  55. package/memory/user_maintainer_context.md +24 -0
  56. package/openapi.yaml +115 -68
  57. package/package.json +35 -15
  58. package/pm2.config.cjs +29 -0
  59. package/{routes.test.js → routes.test.ts} +8 -7
  60. package/{routing.test.js → routing.test.ts} +11 -12
  61. package/scripts/{generate-openapi.js → generate-openapi.ts} +13 -13
  62. package/serve.ts +15 -0
  63. package/smokeTest.ts +234 -0
  64. package/test/dbFixture.ts +21 -0
  65. package/test/fixtures/{buildFixtures.js → buildFixtures.ts} +14 -13
  66. package/test/{setup.cjs → setup.ts} +2 -2
  67. package/tsconfig.json +16 -0
  68. package/util.test.ts +1129 -0
  69. package/util.ts +1017 -0
  70. package/web/ai0BalanceForm/{ai0BalanceForm.test.js → ai0BalanceForm.test.ts} +15 -15
  71. package/web/ai0BalanceForm/{index.js → index.ts} +10 -18
  72. package/web/enqueue/index.ts +56 -0
  73. package/web/enqueueForm/index.html +9 -9
  74. package/web/enqueueForm/index.ts +43 -0
  75. package/web/expungeReportsForm/{expungeReportsForm.test.js → expungeReportsForm.test.ts} +18 -20
  76. package/web/expungeReportsForm/index.ts +18 -0
  77. package/web/hideReportForm/{hideReportForm.test.js → hideReportForm.test.ts} +14 -14
  78. package/web/hideReportForm/{index.js → index.ts} +17 -21
  79. package/web/listDiagnoses/{index.js → index.ts} +48 -58
  80. package/web/listDiagnoses/{listDiagnoses.test.js → listDiagnoses.test.ts} +39 -34
  81. package/web/listIssues/{index.js → index.ts} +67 -92
  82. package/web/listIssues/{listIssues.test.js → listIssues.test.ts} +45 -51
  83. package/web/listReports/index.html +3 -3
  84. package/web/listReports/{index.js → index.ts} +74 -58
  85. package/web/listReports/{listReports.test.js → listReports.test.ts} +41 -43
  86. package/web/listRules/{index.js → index.ts} +18 -23
  87. package/web/listRules/{listRules.test.js → listRules.test.ts} +26 -19
  88. package/web/listTopIssues/{index.js → index.ts} +36 -47
  89. package/web/listTopIssues/{listTopIssues.test.js → listTopIssues.test.ts} +26 -17
  90. package/web/listViolators/{index.js → index.ts} +51 -71
  91. package/web/listViolators/{listViolators.test.js → listViolators.test.ts} +43 -36
  92. package/web/manage/index.ts +22 -0
  93. package/web/pruneReportsForm/index.ts +18 -0
  94. package/web/pruneReportsForm/{pruneReportsForm.test.js → pruneReportsForm.test.ts} +11 -11
  95. package/web/reannotate/index.ts +71 -0
  96. package/web/reannotate/{reannotate.test.js → reannotate.test.ts} +19 -22
  97. package/web/reannotateForm/{index.js → index.ts} +41 -50
  98. package/web/reannotateForm/{reannotateForm.test.js → reannotateForm.test.ts} +13 -16
  99. package/web/renewWCAG/{index.js → index.ts} +9 -9
  100. package/web/renewWCAG/{renewWCAG.test.js → renewWCAG.test.ts} +17 -20
  101. package/web/renewWCAGForm/index.ts +22 -0
  102. package/web/{expungeReportsForm/index.js → reportDeletion.ts} +57 -39
  103. package/web/requestRetest/index.html +7 -7
  104. package/web/requestRetest/index.ts +62 -0
  105. package/web/requestRetest/requestRetest.test.ts +130 -0
  106. package/web/requestRetestForm/{index.js → index.ts} +10 -16
  107. package/web/requestRetestForm/{requestRetestForm.test.js → requestRetestForm.test.ts} +11 -12
  108. package/web/requestTest/index.html +7 -7
  109. package/web/requestTest/index.ts +52 -0
  110. package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +39 -24
  111. package/web/requestTestForm/index.html +4 -4
  112. package/web/requestTestForm/index.ts +21 -0
  113. package/web/requestTestForm/{requestTestForm.test.js → requestTestForm.test.ts} +9 -9
  114. package/web/rewindReportsForm/index.ts +18 -0
  115. package/web/rewindReportsForm/{rewindReportsForm.test.js → rewindReportsForm.test.ts} +11 -11
  116. package/web/tutorialAI/index.html +184 -0
  117. package/web/tutorialAI/index.ts +80 -0
  118. package/web/tutorialAI/tutorialAI.test.ts +192 -0
  119. package/web/{tutorial → tutorialWeb}/index.html +9 -8
  120. package/web/tutorialWeb/index.ts +78 -0
  121. package/web/tutorialWeb/tutorialWeb.test.ts +195 -0
  122. package/web/unhideReportForm/{index.js → index.ts} +18 -22
  123. package/web/unhideReportForm/{unhideReportForm.test.js → unhideReportForm.test.ts} +16 -16
  124. package/aceconfig.js +0 -28
  125. package/api/listReports.js +0 -89
  126. package/api/requestFeature.test.js +0 -58
  127. package/api/requestRetest.js +0 -96
  128. package/api/requestRetest.test.js +0 -80
  129. package/api/requestTest.js +0 -106
  130. package/api/requestTest.test.js +0 -92
  131. package/api/version.js +0 -8
  132. package/indexFallback.test.js +0 -33
  133. package/indexWorkers.test.js +0 -79
  134. package/jsconfig.json +0 -8
  135. package/pm2.config.js +0 -18
  136. package/smokeTest.js +0 -128
  137. package/util.js +0 -744
  138. package/util.test.js +0 -968
  139. package/web/enqueue/index.js +0 -67
  140. package/web/enqueueForm/index.js +0 -49
  141. package/web/manage/index.js +0 -22
  142. package/web/pruneReportsForm/index.js +0 -123
  143. package/web/reannotate/index.js +0 -58
  144. package/web/renewWCAGForm/index.js +0 -22
  145. package/web/requestRetest/index.js +0 -31
  146. package/web/requestRetest/requestRetest.test.js +0 -119
  147. package/web/requestTest/index.js +0 -24
  148. package/web/requestTestForm/index.js +0 -21
  149. package/web/rewindReportsForm/index.js +0 -121
  150. package/web/tutorial/index.js +0 -63
  151. package/web/tutorial/tutorial.test.js +0 -97
  152. /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
  153. /package/web/{tutorial → tutorialWeb}/images/newsletter-form.png +0 -0
@@ -1,21 +1,20 @@
1
1
  /*
2
- listViolators.test.js
3
- Tests for api/listViolators.js using the fixture corpus, with emphasis on outcome handling.
2
+ listViolators.test.ts
3
+ Tests for api/listViolators.ts using the fixture corpus, with emphasis on outcome handling.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test, before, after} = require('node:test');
9
- const assert = require('node:assert/strict');
10
- const path = require('node:path');
11
- const {response} = require('./listViolators');
8
+ import {test, before, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {response} from './listViolators.ts';
12
11
 
13
12
  // SETUP AND TEARDOWN
14
13
 
15
14
  const savedDBDir = process.env.DB_DIR;
16
15
 
17
- before(() => {
18
- process.env.DB_DIR = path.join(__dirname, '..', 'test', 'fixtures', 'db');
16
+ before(async () => {
17
+ process.env.DB_DIR = (await import('../test/dbFixture.ts')).fixtureDBDir;
19
18
  });
20
19
 
21
20
  after(() => {
@@ -31,7 +30,7 @@ after(() => {
31
30
 
32
31
  test('listViolators returns 1 violator for linkNoText in the mixed report, excluding cantTell', async () => {
33
32
  const body = await response(['linkNoText', '260101T0000', 'mix']);
34
- const violators = body['response content']['basics about all elements exhibiting the issue'];
33
+ const violators = body['response content']['basics about all elements exhibiting the issue'] as any;
35
34
  assert.equal(violators.length, 1);
36
35
  assert.equal(violators[0].identifier, '0');
37
36
  assert.equal(violators[0]['tag name'], 'A');
@@ -41,40 +40,40 @@ test('listViolators returns 1 violator for linkNoText in the mixed report, exclu
41
40
 
42
41
  test('listViolators returns 0 violators for an issue when all instances are cantTell', async () => {
43
42
  const body = await response(['focusIndicationBad', '260101T0001', 'ct']);
44
- const violators = body['response content']['basics about all elements exhibiting the issue'];
43
+ const violators = body['response content']['basics about all elements exhibiting the issue'] as any;
45
44
  assert.equal(violators.length, 0);
46
45
  });
47
46
 
48
47
  test('listViolators treats missing outcome as a violation', async () => {
49
48
  const body = await response(['linkNoText', '260101T0002', 'no']);
50
- const violators = body['response content']['basics about all elements exhibiting the issue'];
49
+ const violators = body['response content']['basics about all elements exhibiting the issue'] as any;
51
50
  assert.equal(violators.length, 1);
52
51
  assert.equal(violators[0].identifier, '0');
53
52
  });
54
53
 
55
54
  test('listViolators returns an error for an unknown issue ID', async () => {
56
55
  const body = await response(['nonexistentIssue', '260101T0000', 'mix']);
57
- const basics = body['response content']['basics about the issue'];
56
+ const basics = body['response content']['basics about the issue'] as any;
58
57
  assert.ok(basics.error);
59
58
  });
60
59
 
61
60
  test('listViolators returns an error for a nonexistent report', async () => {
62
61
  const body = await response(['linkNoText', '999999T9999', 'xyz']);
63
- const basics = body['response content']['basics about the report'];
62
+ const basics = body['response content']['basics about the report'] as any;
64
63
  assert.ok(basics.error);
65
64
  });
66
65
 
67
66
  test('listViolators includes reporter facts for the issue', async () => {
68
67
  const body = await response(['linkNoText', '260101T0000', 'mix']);
69
- const details = body['response content']['details about the issue'];
68
+ const details = body['response content']['details about the issue'] as any;
70
69
  const reporters = details['rule engines reporting violations belonging to the issue'];
71
- const names = reporters.map(r => r.name).sort();
70
+ const names = reporters.map((r: any) => r.name).sort();
72
71
  assert.deepEqual(names, ['Alfa', 'Axe']);
73
72
  });
74
73
 
75
74
  test('listViolators sorts by catalogIndex when two violators have the same reporter count', async () => {
76
75
  const body = await response(['linkNoText', '260101T0008', 'mul']);
77
- const violators = body['response content']['basics about all elements exhibiting the issue'];
76
+ const violators = body['response content']['basics about all elements exhibiting the issue'] as any;
78
77
  assert.equal(violators.length, 3);
79
78
  // The violator with 2 reporters (catalogIndex 2) sorts first.
80
79
  assert.equal(violators[0].identifier, '2');
@@ -88,14 +87,14 @@ test('listViolators sorts by catalogIndex when two violators have the same repor
88
87
 
89
88
  test('listViolators returns guideline layer for an issue with a short WCAG code', async () => {
90
89
  const body = await response(['duplicateID', '260101T0009', 'brd']);
91
- const issueBasics = body['response content']['basics about the issue'];
90
+ const issueBasics = body['response content']['basics about the issue'] as any;
92
91
  assert.equal(issueBasics['related WCAG standard'].layer, 'guideline');
93
92
  });
94
93
 
95
94
  test('listViolators returns null tag name and text for a violator not in the catalog', async () => {
96
95
  const body = await response(['duplicateID', '260101T0009', 'brd']);
97
- const violators = body['response content']['basics about all elements exhibiting the issue'];
98
- const orphan = violators.find(v => v.identifier === '3');
96
+ const violators = body['response content']['basics about all elements exhibiting the issue'] as any;
97
+ const orphan = violators.find((v: any) => v.identifier === '3');
99
98
  assert.ok(orphan);
100
99
  assert.equal(orphan['tag name'], null);
101
100
  assert.equal(orphan['inner text'], null);
@@ -103,6 +102,6 @@ test('listViolators returns null tag name and text for a violator not in the cat
103
102
 
104
103
  test('listViolators returns an error for a falsy issue ID', async () => {
105
104
  const body = await response(['', '260101T0009', 'brd']);
106
- const issueBasics = body['response content']['basics about the issue'];
105
+ const issueBasics = body['response content']['basics about the issue'] as any;
107
106
  assert.ok(issueBasics.error);
108
107
  });
@@ -1,104 +1,79 @@
1
1
  /*
2
- listViolators.js
2
+ listViolators.ts
3
3
  Returns details about one issue in one report and basics about all the violators of the issue.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {
8
+ import {z} from 'zod';
9
+ import {
9
10
  getIssueSpec,
10
11
  getReportBasics,
11
12
  getResponseMetadata,
12
13
  getRuleEnginesFacts,
13
14
  getToolsFacts,
14
- thisHost
15
- } = require('./util');
16
- const {getReport} = require('../util');
15
+ getThisHost
16
+ } from './util.ts';
17
+ import {getReport, getTestActInstances, isReportError} from '../util.ts';
18
+ import {listViolatorsResponseSchema} from './schemas.ts';
19
+
20
+ // TYPES
21
+
22
+ // The response content defined by the response schema.
23
+ type ResponseContent = z.infer<typeof listViolatorsResponseSchema>['response content'];
17
24
 
18
25
  // FUNCTIONS
19
26
 
20
27
  // Returns the response body.
21
- exports.response = async args => {
28
+ export const response = async (args: string[]) => {
22
29
  const [issueID = '', timeStamp = '', jobID = ''] = args;
30
+ const thisHost = getThisHost();
31
+ // Get the report.
32
+ const report = await getReport(timeStamp, jobID);
23
33
  // Initialize the response content.
24
- const responseContent = {
25
- 'basics about the report': null,
26
- 'basics about the issue': null,
34
+ const responseContent: ResponseContent = {
35
+ 'basics about the report': isReportError(report) ? report : await getReportBasics(timeStamp, jobID),
36
+ 'basics about the issue': {
37
+ 'error': 'No information about the specified issue is available'
38
+ },
27
39
  'details about the issue': null,
28
40
  'basics about all elements exhibiting the issue': null
29
41
  };
30
- // Get the report.
31
- const report = await getReport(timeStamp, jobID);
32
- // If this failed:
33
- if (report.error) {
34
- // Add this to the response content.
35
- responseContent['basics about the report'] = report;
36
- }
37
- // Otherwise, i.e. if it succeeded:
38
- else {
39
- // Get the basics about the report (which may be only an error message).
40
- const reportBasics = await getReportBasics(timeStamp, jobID);
41
- // Add them to the response content.
42
- responseContent['basics about the report'] = reportBasics;
43
- }
44
42
  // Get the specification of the issue.
45
43
  const issueSpec = issueID ? getIssueSpec(issueID) : null;
46
- // If the issue is ignorable or not fully classified:
47
- if (!issueSpec) {
48
- // Add this to the response content.
49
- responseContent['basics about the issue'] = {
50
- 'error': 'No information about the specified issue is available'
51
- };
52
- }
53
- // Otherwise, i.e. if it is non-ignorable and fully classified:
54
- else {
44
+ // If the issue is non-ignorable and fully classified:
45
+ if (issueSpec) {
55
46
  const {summary, wcag, weight, why} = issueSpec;
56
- // Initialize the basics and details about it.
57
- const issueBasics = {
47
+ // Add the basics about it to the response content.
48
+ responseContent['basics about the issue'] = {
58
49
  'identifier': issueID,
59
50
  summary,
60
51
  'impact on a user': why,
61
52
  'related WCAG standard': {
62
- 'layer': (wcag.length > 4 ? 'success criterion' : 'guideline'),
53
+ 'layer': (wcag.length > 4 ? 'success criterion' : 'guideline') as 'success criterion' | 'guideline',
63
54
  'identifier': wcag
64
55
  },
65
- priority: ['lowest', 'low', 'high', 'highest'][weight - 1]
56
+ priority: ['lowest', 'low', 'high', 'highest'][weight - 1] as 'lowest' | 'low' | 'high' | 'highest'
66
57
  };
67
- // Add the basics about the issue to the response content.
68
- responseContent['basics about the issue'] = issueBasics;
69
58
  // If the report is available:
70
- if (!report.error) {
59
+ if (!isReportError(report)) {
71
60
  // Initialize data about the instances of the issue.
72
- const reporterIDs = new Set();
73
- const violators = {};
74
- // For each act in the report:
75
- report.acts.forEach(act => {
76
- const {result, type, which} = act;
77
- const instances = result?.standardResult?.instances || [];
78
- // If the act is a test act with a specified rule engine:
79
- if (type === 'test' && which) {
80
- // For each standard instance of the act:
81
- instances.forEach(instance => {
82
- // If the instance has the issue ID:
83
- if (instance.issueID === issueID) {
84
- const {catalogIndex, outcome} = instance;
85
- // If the instance reports a violation:
86
- if (outcome !== 'cantTell') {
87
- // Ensure the rule-engine ID is in the reporters data.
88
- reporterIDs.add(which);
89
- // If the instance has a catalog index:
90
- if (catalogIndex) {
91
- // Ensure the catalog index is in the violators data.
92
- violators[catalogIndex] ??= {
93
- catalogIndex,
94
- reporters: new Set()
95
- };
96
- // Ensure the reporter ID is in the data about the violator.
97
- violators[catalogIndex].reporters.add(which);
98
- }
99
- }
100
- }
101
- });
61
+ const reporterIDs: Set<string> = new Set();
62
+ const violators: Record<string, {catalogIndex: string | number; reporters: Set<string>}> = {};
63
+ // For each violating standard instance of the issue in the report:
64
+ getTestActInstances(report, {violationsOnly: true, issueID}).forEach(({act, instance}) => {
65
+ const {catalogIndex} = instance;
66
+ // Ensure the rule-engine ID is in the reporters data.
67
+ reporterIDs.add(act.which!);
68
+ // If the instance has a catalog index:
69
+ if (catalogIndex) {
70
+ // Ensure the catalog index is in the violators data.
71
+ violators[catalogIndex] ??= {
72
+ catalogIndex,
73
+ reporters: new Set()
74
+ };
75
+ // Ensure the reporter ID is in the data about the violator.
76
+ violators[catalogIndex].reporters.add(act.which!);
102
77
  }
103
78
  });
104
79
  // Get details about the reporters of the issue.
@@ -131,7 +106,7 @@ exports.response = async args => {
131
106
  'count of rule engines reporting that the element exhibited the issue': reporters.size,
132
107
  'how to get details about the element': {
133
108
  URL: `${thisHost}/api/listDiagnoses/${catalogIndex}/${issueID}/${timeStamp}/${jobID}`,
134
- 'request method': 'GET'
109
+ 'request method': 'GET' as const
135
110
  },
136
111
  'web users can get details about the element at': `${thisHost}/listDiagnoses.html/${issueID}/${timeStamp}/${jobID}/${catalogIndex}`
137
112
  };
@@ -0,0 +1,54 @@
1
+ /*
2
+ requestFeature.test.ts
3
+ Tests for api/requestFeature.ts with mocked side effects.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, beforeEach, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+
11
+ // SETUP AND TEARDOWN
12
+
13
+ let logged: any[] = [];
14
+ const originalLog = console.log;
15
+
16
+ // Capture console.log so the alert sendAlert emits can be observed. The alert
17
+ // configuration is empty in tests, so sendAlert logs a WARNING instead of sending.
18
+ beforeEach(() => {
19
+ logged = [];
20
+ console.log = (...args) => logged.push(args.join(' '));
21
+ });
22
+
23
+ import {response} from './requestFeature.ts';
24
+
25
+ after(() => {
26
+ console.log = originalLog;
27
+ });
28
+
29
+ // TESTS
30
+
31
+ test('requestFeature rejects an empty feature request', async () => {
32
+ const body = await response(['']);
33
+ const details = body['response content']['details about your request'] as any;
34
+ assert.ok(details.error);
35
+ assert.ok(!logged.some(line => line.startsWith('WARNING (MCP feature request received)')));
36
+ });
37
+
38
+ test('requestFeature accepts a non-empty feature request and notifies the manager', async () => {
39
+ const body = await response(['Add a dark mode toggle']);
40
+ const details = body['response content']['details about your request'] as any;
41
+ assert.equal(details.error, undefined);
42
+ assert.ok(details['date and time received']);
43
+ assert.equal(details.disposition, 'received and logged; manager notified');
44
+ assert.ok(logged.some(line =>
45
+ line.startsWith('WARNING (MCP feature request received)')
46
+ && line.includes('Add a dark mode toggle')
47
+ ));
48
+ });
49
+
50
+ test('requestFeature includes tool name and metadata', async () => {
51
+ const body = await response(['Some feature']);
52
+ assert.equal(body['tool name'], 'requestFeature');
53
+ assert.ok(body['response metadata'].identifier);
54
+ });
@@ -1,31 +1,34 @@
1
1
  /*
2
- requestFeature.js
2
+ requestFeature.ts
3
3
  Processes a request to add or improve a feature and returns an acknowledgement.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getResponseMetadata, getToolsFacts, thisHost} = require('./util');
9
- const {sendAlert} = require('../alerts');
8
+ import {z} from 'zod';
9
+ import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
+ import {sendAlert} from '../alerts.ts';
11
+ import {requestFeatureResponseSchema} from './schemas.ts';
12
+
13
+ // TYPES
14
+
15
+ // The response content defined by the response schema.
16
+ type ResponseContent = z.infer<typeof requestFeatureResponseSchema>['response content'];
10
17
 
11
18
  // FUNCTIONS
12
19
 
13
20
  // Returns the response body.
14
- exports.response = async args => {
21
+ export const response = async (args: string[]) => {
15
22
  const [feature = ''] = args;
23
+ const thisHost = getThisHost();
16
24
  // Initialize the response content.
17
- const responseContent = {
18
- 'details about your request': null
19
- };
20
- // If the requested feature or improvement is empty:
21
- if (!feature) {
22
- // Add this to the response content.
23
- responseContent['details about your request'] = {
25
+ const responseContent: ResponseContent = {
26
+ 'details about your request': {
24
27
  error: 'request invalid: request is empty'
25
- };
26
- }
27
- // Otherwise, i.e. if it exists:
28
- else {
28
+ }
29
+ };
30
+ // If the requested feature or improvement exists:
31
+ if (feature) {
29
32
  // Notify the manager.
30
33
  await sendAlert('MCP feature request received', feature);
31
34
  // Add the disposition to the response content.
@@ -0,0 +1,144 @@
1
+ /*
2
+ requestRetest.test.ts
3
+ Tests for api/requestRetest.ts using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, beforeEach, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {fixtureDBDir} from '../test/dbFixture.ts';
13
+
14
+ // SETUP AND TEARDOWN
15
+
16
+ const savedDBDir = process.env.DB_DIR;
17
+ const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
18
+ let logged: any[] = [];
19
+ const originalLog = console.log;
20
+
21
+ before(() => {
22
+ process.env.DB_DIR = fixtureDBDir;
23
+ });
24
+
25
+ // Reset the test-requests file and capture console.log, so the alert that
26
+ // processTestRequest emits can be observed and tests are order-independent.
27
+ beforeEach(async () => {
28
+ await fs.writeFile(testRequestsPath, '{}\n');
29
+ logged = [];
30
+ console.log = (...args) => logged.push(args.join(' '));
31
+ });
32
+
33
+ import {response} from './requestRetest.ts';
34
+
35
+ after(() => {
36
+ console.log = originalLog;
37
+ if (savedDBDir !== undefined) {
38
+ process.env.DB_DIR = savedDBDir;
39
+ }
40
+ else {
41
+ delete process.env.DB_DIR;
42
+ }
43
+ });
44
+
45
+ // TESTS
46
+
47
+ test('requestRetest rejects a nonexistent report', async () => {
48
+ const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
49
+ const details = body['response content']['details about your request'] as any;
50
+ assert.ok(details.error.includes('does not exist'));
51
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
52
+ });
53
+
54
+ test('requestRetest rejects a superseded report', async () => {
55
+ // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
56
+ // so it is superseded and processTestRequest returns 'superseded', reported via the
57
+ // disposition rather than details.error.
58
+ const body = await response(['260101T0000', 'mix', 'A reason that is long enough.']);
59
+ const details = body['response content']['details about your request'] as any;
60
+ assert.equal(details.error, undefined);
61
+ const disposition = body['response content']['disposition of your request'] as any;
62
+ assert.ok(
63
+ disposition['what happens next'].includes('a later report about a page with the same description is available.')
64
+ );
65
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
66
+ });
67
+
68
+ test('requestRetest rejects a reason shorter than 20 characters', async () => {
69
+ const body = await response(['260101T0001', 'ct', 'short']);
70
+ const details = body['response content']['details about your request'] as any;
71
+ assert.ok(details.error.includes('reason'));
72
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
73
+ });
74
+
75
+ test('requestRetest rejects a reason longer than 100 characters', async () => {
76
+ const longReason = 'x'.repeat(101);
77
+ const body = await response(['260101T0001', 'ct', longReason]);
78
+ const details = body['response content']['details about your request'] as any;
79
+ assert.ok(details.error.includes('reason'));
80
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
81
+ });
82
+
83
+ test('requestRetest rejects a report matching a claimed job by description', async () => {
84
+ // 260101T0001-ct is the "All CantTell Page" report.
85
+ const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
86
+ await fs.writeFile(claimedPath, JSON.stringify({
87
+ target: {what: 'All CantTell Page', url: 'https://example.com/unrelated'}
88
+ }));
89
+ try {
90
+ const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
91
+ const details = body['response content']['details about your request'] as any;
92
+ assert.equal(details.error, undefined);
93
+ const disposition = body['response content']['disposition of your request'] as any;
94
+ assert.ok(
95
+ disposition['what happens next']
96
+ .includes('a request to test a page with the same description is already approved.')
97
+ );
98
+ }
99
+ finally {
100
+ await fs.unlink(claimedPath);
101
+ }
102
+ });
103
+
104
+ test('requestRetest rejects a report matching a queued job by URL', async () => {
105
+ // 260101T0001-ct's URL is https://example.com/canttell.
106
+ const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
107
+ await fs.writeFile(queuedPath, JSON.stringify({
108
+ target: {what: 'Some Other Page', url: 'https://example.com/canttell'}
109
+ }));
110
+ try {
111
+ const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
112
+ const details = body['response content']['details about your request'] as any;
113
+ assert.equal(details.error, undefined);
114
+ const disposition = body['response content']['disposition of your request'] as any;
115
+ assert.ok(
116
+ disposition['what happens next']
117
+ .includes('a request to test a page with the same URL is already approved.')
118
+ );
119
+ }
120
+ finally {
121
+ await fs.unlink(queuedPath);
122
+ }
123
+ });
124
+
125
+ test('requestRetest rejects a duplicate request', async () => {
126
+ await response(['260101T0001', 'ct', 'A reason that is long enough.']);
127
+ const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
128
+ const details = body['response content']['details about your request'] as any;
129
+ assert.equal(details.error, undefined);
130
+ const disposition = body['response content']['disposition of your request'] as any;
131
+ assert.ok(disposition['what happens next'].includes('an identical request is already awaiting approval.'));
132
+ });
133
+
134
+ test('requestRetest accepts a valid retest request for the latest report of a page', async () => {
135
+ const body = await response(['260202T0000', 'new', 'A reason that is long enough.']);
136
+ const details = body['response content']['details about your request'] as any;
137
+ assert.equal(details.error, undefined);
138
+ assert.equal(details['page to be retested'].description, 'Mixed Outcomes Page');
139
+ assert.ok(logged.some(line =>
140
+ line.startsWith('WARNING (Kilotest: new retest request awaits approval)')
141
+ && line.includes('Mixed Outcomes Page')
142
+ && line.includes('https://example.com/mixed')
143
+ ));
144
+ });
@@ -0,0 +1,119 @@
1
+ /*
2
+ requestRetest.ts
3
+ Processes a request to retest a page and returns an acknowledgement.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {z} from 'zod';
9
+ import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
+ import {processTestRequest} from '../util.ts';
11
+ import {requestRetestResponseSchema} from './schemas.ts';
12
+
13
+ // TYPES
14
+
15
+ // The response content defined by the response schema.
16
+ type ResponseContent = z.infer<typeof requestRetestResponseSchema>['response content'];
17
+
18
+ // FUNCTIONS
19
+
20
+ // Returns the response body.
21
+ export const response = async (args: string[]) => {
22
+ const [timeStamp = '', jobID = '', reason = ''] = args;
23
+ const thisHost = getThisHost();
24
+ const reasonLength = reason.length;
25
+ // Initialize the response-content properties.
26
+ let requestDetails: ResponseContent['details about your request'];
27
+ let requestDisposition: ResponseContent['disposition of your request'] = null;
28
+ // If the encoded reason is too short or too long:
29
+ if (reasonLength < 20 || reasonLength > 100) {
30
+ requestDetails = {
31
+ error: 'request invalid: your reason is not between 20 and 100 characters long'
32
+ }
33
+ }
34
+ // Otherwise, i.e. if the request is facially valid:
35
+ else {
36
+ // Process the request. This resolves the cited report's description and URL itself.
37
+ const processResult = await processTestRequest(reason, {timeStamp, jobID});
38
+ // If the cited report does not exist:
39
+ if (processResult.result === 'nonreport') {
40
+ requestDetails = {
41
+ error: 'request invalid: the specified report does not exist'
42
+ };
43
+ }
44
+ // Otherwise, i.e. if the cited report was found:
45
+ else {
46
+ const {result: requestResult, description, url} = processResult;
47
+ // Add details about the request.
48
+ requestDetails = {
49
+ 'date and time received': new Date().toISOString(),
50
+ 'page to be retested': {
51
+ 'description': description,
52
+ 'URL': url
53
+ },
54
+ 'reason why the page should be retested': reason
55
+ };
56
+ // Add information about the disposition of the request.
57
+ if (requestResult === 'ok') {
58
+ requestDisposition = {
59
+ 'what happens next': 'Your request is likely to be approved and processed within 1 hour to 1 day.',
60
+ 'how you can check for completion': 'You can call the listReports tool to learn whether the page has been retested and a new report is available.',
61
+ 'how a web user can check for completion': `A web user can visit ${thisHost}/listReports.html to learn whether the page has been retested and a new report is available.`
62
+ };
63
+ }
64
+ else {
65
+ const failureFact = 'Your request will not be processed, because ';
66
+ let failureReason: string;
67
+ if (requestResult === 'description') {
68
+ failureReason = 'a request to test a page with the same description is already approved.'
69
+ }
70
+ else if (requestResult === 'url') {
71
+ failureReason = 'a request to test a page with the same URL is already approved.'
72
+ }
73
+ else if (requestResult === 'superseded') {
74
+ failureReason = 'a later report about a page with the same description is available.'
75
+ }
76
+ else {
77
+ failureReason = 'an identical request is already awaiting approval.'
78
+ }
79
+ requestDisposition = {
80
+ 'what happens next': `${failureFact}${failureReason}`,
81
+ 'how you can check for completion': 'Not applicable.',
82
+ 'how a web user can check for completion': 'Not applicable.'
83
+ }
84
+ }
85
+ }
86
+ }
87
+ // Create the response content.
88
+ const responseContent: ResponseContent = {
89
+ 'details about your request': requestDetails,
90
+ 'disposition of your request': requestDisposition
91
+ };
92
+ // Create a response body.
93
+ const body = {
94
+ 'tool collection': getToolsFacts(),
95
+ 'tool name': 'requestRetest',
96
+ 'this request': {
97
+ description: 'Process my request to retest a page. The timeStamp and jobID parameters identify the latest available report about the page. Those parameters were in the response to my earlier listIssues request. The reason property of the request body is the reason why the page should be retested.',
98
+ method: 'POST',
99
+ URL: `${thisHost}/api/requestRetest/${timeStamp}/${jobID}`,
100
+ body: {
101
+ reason
102
+ },
103
+ 'closest ancestor request': {
104
+ 'tool name': 'listIssues',
105
+ description: 'Provide details about one report, including basics about the issues reported in it.',
106
+ method: 'GET',
107
+ URL: `${thisHost}/api/listIssues/${timeStamp}/${jobID}`
108
+ }
109
+ },
110
+ 'URLs of similar requests for web users': {
111
+ 'this request': `${thisHost}/requestRetestForm.html/${timeStamp}/${jobID}`,
112
+ 'closest ancestor request': `${thisHost}/listIssues.html/${timeStamp}/${jobID}`
113
+ },
114
+ 'response metadata': getResponseMetadata(),
115
+ 'response content': responseContent
116
+ };
117
+ // Return it.
118
+ return body;
119
+ };