@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
@@ -0,0 +1,153 @@
1
+ /*
2
+ requestTest.test.ts
3
+ Tests for api/requestTest.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 './requestTest.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('requestTest rejects an empty description', async () => {
48
+ const body = await response(['', 'https://example.com/test', 'A reason that is long enough.']);
49
+ assert.ok((body['response content']['details about your request'] as any).error);
50
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
51
+ });
52
+
53
+ test('requestTest rejects a description longer than 100 characters', async () => {
54
+ const longWhat = 'x'.repeat(101);
55
+ const body = await response([longWhat, 'https://example.com/test', 'A reason that is long enough.']);
56
+ assert.ok((body['response content']['details about your request'] as any).error);
57
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
58
+ });
59
+
60
+ test('requestTest rejects a URL shorter than 12 characters', async () => {
61
+ const body = await response(['Test Page', 'short', 'A reason that is long enough.']);
62
+ assert.ok((body['response content']['details about your request'] as any).error);
63
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
64
+ });
65
+
66
+ test('requestTest rejects a syntactically invalid URL with the correct length', async () => {
67
+ const body = await response(['Test Page', 'not-a-valid-url', 'A reason that is long enough.']);
68
+ const details = body['response content']['details about your request'] as any;
69
+ assert.ok(details.error.includes('invalid URL'));
70
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
71
+ });
72
+
73
+ test('requestTest rejects an already-tested page', async () => {
74
+ const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
75
+ const details = body['response content']['details about your request'] as any;
76
+ // A report for this description and URL already exists, so processTestRequest
77
+ // returns 'retest' and the failure is reported via the disposition, not details.error.
78
+ assert.equal(details.error, undefined);
79
+ const disposition = body['response content']['disposition of your request'] as any;
80
+ assert.ok(
81
+ disposition['what happens next']
82
+ .includes('a report about a page with the same description and URL is available.')
83
+ );
84
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
85
+ });
86
+
87
+ test('requestTest rejects a page matching a claimed job by description', async () => {
88
+ const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
89
+ await fs.writeFile(claimedPath, JSON.stringify({
90
+ target: {what: 'Claimed Test Page', url: 'https://example.com/claimed-job'}
91
+ }));
92
+ try {
93
+ const body = await response(['Claimed Test Page', 'https://example.com/other-url', 'A reason that is long enough.']);
94
+ const details = body['response content']['details about your request'] as any;
95
+ assert.equal(details.error, undefined);
96
+ const disposition = body['response content']['disposition of your request'] as any;
97
+ assert.ok(
98
+ disposition['what happens next']
99
+ .includes('a request to test a page with the same description is already approved.')
100
+ );
101
+ }
102
+ finally {
103
+ await fs.unlink(claimedPath);
104
+ }
105
+ });
106
+
107
+ test('requestTest rejects a page matching a queued job by URL', async () => {
108
+ const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
109
+ await fs.writeFile(queuedPath, JSON.stringify({
110
+ target: {what: 'Some Other Page', url: 'https://example.com/queued-url'}
111
+ }));
112
+ try {
113
+ const body = await response(['A Different Page', 'https://example.com/queued-url', 'A reason that is long enough.']);
114
+ const details = body['response content']['details about your request'] as any;
115
+ assert.equal(details.error, undefined);
116
+ const disposition = body['response content']['disposition of your request'] as any;
117
+ assert.ok(
118
+ disposition['what happens next']
119
+ .includes('a request to test a page with the same URL is already approved.')
120
+ );
121
+ }
122
+ finally {
123
+ await fs.unlink(queuedPath);
124
+ }
125
+ });
126
+
127
+ test('requestTest rejects a duplicate request', async () => {
128
+ await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
129
+ const body = await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
130
+ const details = body['response content']['details about your request'] as any;
131
+ assert.equal(details.error, undefined);
132
+ const disposition = body['response content']['disposition of your request'] as any;
133
+ assert.ok(disposition['what happens next'].includes('an identical request is already awaiting approval.'));
134
+ });
135
+
136
+ test('requestTest accepts a valid new page request', async () => {
137
+ const body = await response(['Brand New Page', 'https://example.com/brandnew', 'A reason that is long enough.']);
138
+ const details = body['response content']['details about your request'] as any;
139
+ assert.equal(details.error, undefined);
140
+ assert.equal(details['page to be tested'].description, 'Brand New Page');
141
+ assert.ok(logged.some(line =>
142
+ line.startsWith('WARNING (Kilotest: new test request awaits approval)')
143
+ && line.includes('Brand New Page')
144
+ && line.includes('https://example.com/brandnew')
145
+ ));
146
+ });
147
+
148
+ test('requestTest includes disposition information for a valid request', async () => {
149
+ const body = await response(['Brand New Page', 'https://example.com/brandnew', 'A reason that is long enough.']);
150
+ const disposition = body['response content']['disposition of your request'];
151
+ assert.ok(disposition);
152
+ assert.ok(disposition['what happens next']);
153
+ });
@@ -0,0 +1,123 @@
1
+ /*
2
+ requestTest.ts
3
+ Processes a request to test an untested 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 {isURL, processTestRequest} from '../util.ts';
11
+ import {requestTestResponseSchema} from './schemas.ts';
12
+
13
+ // TYPES
14
+
15
+ // The response content defined by the response schema.
16
+ type ResponseContent = z.infer<typeof requestTestResponseSchema>['response content'];
17
+
18
+ // FUNCTIONS
19
+
20
+ // Returns the response body.
21
+ export const response = async (args: string[]) => {
22
+ const [description = '', url = '', reason = ''] = args;
23
+ const thisHost = getThisHost();
24
+ // Initialize the response-content properties.
25
+ let requestDetails: ResponseContent['details about your request'];
26
+ let requestDisposition: ResponseContent['disposition of your request'] = null;
27
+ const descriptionLength = description.length;
28
+ // If the description is empty or too long:
29
+ if (!descriptionLength || descriptionLength > 100) {
30
+ requestDetails = {
31
+ error: 'request invalid: your description of the page is not between 1 and 100 characters long'
32
+ };
33
+ }
34
+ // Otherwise, i.e. if the URL is too short or too long::
35
+ else if (url.length < 12 || url.length > 300) {
36
+ requestDetails = {
37
+ error: 'request invalid: you specified a URL for the page that is not between 12 and 300 characters long'
38
+ };
39
+ }
40
+ // Otherwise, i.e. if the URL is invalid:
41
+ else if (!isURL(url)) {
42
+ requestDetails = {
43
+ error: 'request invalid: you specified an invalid URL for the page'
44
+ };
45
+ }
46
+ // Otherwise, i.e. if the description and URL are valid:
47
+ else {
48
+ // Process the request.
49
+ const {result: requestResult} = await processTestRequest(reason, {description, url});
50
+ // Add details about the request.
51
+ requestDetails = {
52
+ 'date and time received': new Date().toISOString(),
53
+ 'page to be tested': {
54
+ description,
55
+ URL: url
56
+ },
57
+ 'reason why the page should be tested': reason
58
+ };
59
+ // Add information about the disposition of the request.
60
+ if (requestResult === 'ok') {
61
+ requestDisposition = {
62
+ 'what happens next': 'Your request is likely to be approved and processed within 1 hour to 1 day.',
63
+ 'how you can check for completion': 'You can call the listReports tool to learn whether the page has been tested and a report is available.',
64
+ 'how a web user can check for completion': `A web user can visit ${thisHost}/listReports.html to learn whether the page has been tested and a report is available.`
65
+ };
66
+ }
67
+ else {
68
+ const failureFact = 'Your request will not be processed, because ';
69
+ let failureReason: string;
70
+ if (requestResult === 'description') {
71
+ failureReason = 'a request to test a page with the same description is already approved.'
72
+ }
73
+ else if (requestResult === 'url') {
74
+ failureReason = 'a request to test a page with the same URL is already approved.'
75
+ }
76
+ else if (requestResult === 'retest') {
77
+ failureReason = 'a report about a page with the same description and URL is available.'
78
+ }
79
+ else {
80
+ failureReason = 'an identical request is already awaiting approval.'
81
+ }
82
+ requestDisposition = {
83
+ 'what happens next': `${failureFact}${failureReason}`,
84
+ 'how you can check for completion': 'Not applicable.',
85
+ 'how a web user can check for completion': 'Not applicable.'
86
+ }
87
+ }
88
+ }
89
+ // Create the response content.
90
+ const responseContent: ResponseContent = {
91
+ 'details about your request': requestDetails,
92
+ 'disposition of your request': requestDisposition
93
+ };
94
+ // Create a response body.
95
+ const body = {
96
+ 'tool collection': getToolsFacts(),
97
+ 'tool name': 'requestTest',
98
+ 'this request': {
99
+ description: 'Process my request to test a page about which no report is available yet. I have provided a description and the URL of the page and a reason why it should be tested.',
100
+ method: 'POST',
101
+ URL: `${thisHost}/api/requestTest`,
102
+ body: {
103
+ description,
104
+ URL: url,
105
+ reason
106
+ },
107
+ 'closest ancestor request': {
108
+ 'tool name': 'listReports',
109
+ description: 'Provide basics about all available reports.',
110
+ method: 'GET',
111
+ URL: `${thisHost}/api/listReports`
112
+ }
113
+ },
114
+ 'URLs of similar requests for web users': {
115
+ 'this request': `${thisHost}/requestTestForm.html`,
116
+ 'closest ancestor request': `${thisHost}/listReports.html`
117
+ },
118
+ 'response metadata': getResponseMetadata(),
119
+ 'response content': responseContent
120
+ };
121
+ // Return it.
122
+ return body;
123
+ };
@@ -1,13 +1,13 @@
1
1
  /*
2
- routes.test.js
3
- Tests for api/routes.js, verifying the structure and completeness of the OpenAPI route metadata.
2
+ routes.test.ts
3
+ Tests for api/routes.ts, verifying the structure and completeness of the OpenAPI route metadata.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test} = require('node:test');
9
- const assert = require('node:assert/strict');
10
- const routes = require('./routes');
8
+ import {test} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {routes} from './routes.ts';
11
11
 
12
12
  // TESTS
13
13
 
@@ -1,12 +1,12 @@
1
1
  /*
2
- routes.js
3
- Route metadata for OpenAPI generation. Pairs each Kilotest API operation with its HTTP method, path template, and the request/response schemas from schemas.js. This table supplies the facts that schemas.js cannot express on its own (method, path, path-vs-body placement), mirroring the manual dispatch in index.js and the // GET/POST comments in schemas.js.
2
+ routes.ts
3
+ Route metadata for OpenAPI generation. Pairs each Kilotest API operation with its HTTP method, path template, and the request/response schemas from schemas.ts. This table supplies the facts that schemas.ts cannot express on its own (method, path, path-vs-body placement), mirroring the manual dispatch in index.ts and the // GET/POST comments in schemas.ts.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {z} = require('zod');
9
- const {
8
+ import {z} from 'zod';
9
+ import {
10
10
  listIssuesSchema,
11
11
  listViolatorsSchema,
12
12
  listDiagnosesSchema,
@@ -22,11 +22,11 @@ const {
22
22
  requestTestResponseSchema,
23
23
  requestRetestResponseSchema,
24
24
  requestFeatureResponseSchema
25
- } = require('./schemas');
25
+ } from './schemas.ts';
26
26
 
27
27
  // ROUTES
28
28
 
29
- module.exports = [
29
+ export const routes = [
30
30
  {
31
31
  operationId: 'listReports',
32
32
  method: 'get',
@@ -1,29 +1,34 @@
1
1
  /*
2
- schemas.js
3
- Zod input and response schemas shared by mcp.js (MCP tool inputSchema/outputSchema), api/routes.js, and the openapi.yaml generation script (scripts/generate-openapi.js). Descriptions and component ids (.meta({id})) in this file are the single source of truth for parameter documentation and OpenAPI component naming and reuse.
2
+ schemas.ts
3
+ Zod input and response schemas shared by mcp.ts (MCP tool inputSchema/outputSchema), api/routes.ts, and the openapi.yaml generation script (scripts/generate-openapi.ts). Descriptions and component ids (.meta({id})) in this file are the single source of truth for parameter documentation and OpenAPI component naming and reuse.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {z} = require('zod');
8
+ import {z} from 'zod';
9
+
10
+ // TYPES
11
+
12
+ // A Zod schema of any shape, as accepted by the schema-composing helpers below.
13
+ type ZodTypeAny = import('zod').ZodTypeAny;
9
14
 
10
15
  // REQUEST SCHEMAS
11
16
 
12
17
  // listIssues: GET /api/listIssues/{timeStamp}/{jobID}
13
- exports.listIssuesSchema = {
18
+ export const listIssuesSchema = {
14
19
  timeStamp: z.string().describe('Timestamp of the report in YYMMDDTHHmm format (example: 260503T0432)'),
15
20
  jobID: z.string().describe('Job identifier of the report (example: x9z)')
16
21
  };
17
22
 
18
23
  // listViolators: GET /api/listViolators/{issueID}/{timeStamp}/{jobID}
19
- exports.listViolatorsSchema = {
24
+ export const listViolatorsSchema = {
20
25
  issueID: z.string().describe('Issue identifier (example: contrastPoor)'),
21
26
  timeStamp: z.string().describe('Timestamp of the report in YYMMDDTHHmm format (example: 260503T0432)'),
22
27
  jobID: z.string().describe('Job identifier of the report (example: x9z)')
23
28
  };
24
29
 
25
30
  // listDiagnoses: GET /api/listDiagnoses/{catalogIndex}/{issueID}/{timeStamp}/{jobID}
26
- exports.listDiagnosesSchema = {
31
+ export const listDiagnosesSchema = {
27
32
  catalogIndex: z.string().describe('Identifier of the issue-exhibiting element in the catalog of elements on the page (example: 372)'),
28
33
  issueID: z.string().describe('Issue identifier (example: contrastPoor)'),
29
34
  timeStamp: z.string().describe('Timestamp of the report in YYMMDDTHHmm format (example: 260503T0432)'),
@@ -31,31 +36,31 @@ exports.listDiagnosesSchema = {
31
36
  };
32
37
 
33
38
  // getReport: GET /api/getReport/{timeStamp}/{jobID}
34
- exports.getReportSchema = {
39
+ export const getReportSchema = {
35
40
  timeStamp: z.string().describe('Timestamp of the report in YYMMDDTHHmm format (example: 260503T0432)'),
36
41
  jobID: z.string().describe('Job identifier of the report (example: x9z)')
37
42
  };
38
43
 
39
44
  // requestTest: POST /api/requestTest
40
- exports.requestTestSchema = {
45
+ export const requestTestSchema = {
41
46
  description: z.string().describe('10- to 100-character description of the page conforming to the naming convention used in the listReports output'),
42
47
  URL: z.string().describe('12- to 300-character URL of the page, including the https:// scheme and any query'),
43
48
  reason: z.string().describe('20- to 100-character reason why the page should be tested')
44
49
  };
45
50
 
46
51
  // requestRetest: POST /api/requestRetest/{timeStamp}/{jobID}
47
- exports.requestRetestSchema = {
52
+ export const requestRetestSchema = {
48
53
  timeStamp: z.string().describe('Timestamp of the latest report about the page in YYMMDDTHHmm format (example: 260503T0432)'),
49
54
  jobID: z.string().describe('Job identifier of the latest report about the page (example: x9z)'),
50
55
  reason: z.string().describe('20- to 100-character reason why the page should be retested')
51
56
  };
52
57
 
53
58
  // requestFeature: POST /api/requestFeature
54
- exports.requestFeatureSchema = {
59
+ export const requestFeatureSchema = {
55
60
  feature: z.string().describe('description of requested feature improvement or new feature')
56
61
  };
57
62
 
58
- // listReports takes no input; omitted (mcp.js already uses inputSchema: {}).
63
+ // listReports takes no input; omitted (mcp.ts already uses inputSchema: {}).
59
64
 
60
65
  // RESPONSE SCHEMAS
61
66
 
@@ -97,7 +102,7 @@ const requestReferenceSchema = z.object({
97
102
  }).meta({id: 'RequestReference'});
98
103
 
99
104
  // GET endpoints have no 'body'; POST endpoints get a request-specific body schema.
100
- const thisRequestSchema = (method, bodySchema) => z.object({
105
+ const thisRequestSchema = (method: string, bodySchema?: ZodTypeAny) => z.object({
101
106
  description: z.string(),
102
107
  method: z.literal(method),
103
108
  URL: z.string(),
@@ -105,11 +110,11 @@ const thisRequestSchema = (method, bodySchema) => z.object({
105
110
  'closest ancestor request': requestReferenceSchema.nullable()
106
111
  });
107
112
 
108
- const envelope = (
109
- method,
110
- responseContentSchema,
111
- bodySchema,
112
- /** @type {z.ZodType} */ similarWebSchema = similarWebRequestsSchema
113
+ const envelope = <T extends ZodTypeAny>(
114
+ method: string,
115
+ responseContentSchema: T,
116
+ bodySchema?: ZodTypeAny,
117
+ similarWebSchema: ZodTypeAny = similarWebRequestsSchema
113
118
  ) => z.object({
114
119
  'tool collection': toolsFactsSchema,
115
120
  'tool name': z.string(),
@@ -133,7 +138,7 @@ const responseMetadataSchema = z.object({
133
138
  const reportBasicsSchema = z.object({
134
139
  identifier: z.string().describe('timeStamp-jobID identifier of the report.'),
135
140
  'completion date and time': z.string(),
136
- 'days since the report was completed': z.number(),
141
+ 'days since the report was completed': z.number().nullable(),
137
142
  'tested web page': z.object({description: z.string(), URL: z.string()}),
138
143
  'whether a later report about the same page exists': z.boolean()
139
144
  }).meta({id: 'ReportBasics'});
@@ -161,7 +166,7 @@ const issueBasicsOrErrorSchema = z.union([
161
166
  z.object({error: z.string()})
162
167
  ]).meta({id: 'IssueBasicsOrError'});
163
168
 
164
- exports.listReportsResponseSchema = envelope('GET', z.object({
169
+ export const listReportsResponseSchema = envelope('GET', z.object({
165
170
  'basics about all available reports': z.array(reportBasicsSchema.extend({
166
171
  'how to get details about the report': z.object({method: z.literal('GET'), URL: z.string()}),
167
172
  'web users can get details about the report at': z.string()
@@ -175,7 +180,7 @@ exports.listReportsResponseSchema = envelope('GET', z.object({
175
180
  'how a web user can request that the page be tested': z.object({URL: z.string()})
176
181
  }));
177
182
 
178
- exports.listIssuesResponseSchema = envelope('GET', z.object({
183
+ export const listIssuesResponseSchema = envelope('GET', z.object({
179
184
  'basics about the report': reportBasicsOrErrorSchema,
180
185
  'details about the report': z.object({
181
186
  'job definition': z.object({
@@ -215,20 +220,20 @@ exports.listIssuesResponseSchema = envelope('GET', z.object({
215
220
  }).nullable(),
216
221
  'how a web user can get the full report in JSON': z.object({URL: z.string()}).nullable(),
217
222
  'basics about all issues reported in the report': z.array(issueBasicsSchema.extend({
218
- 'rule engines with any violations belonging to the issue': z.array(z.string()),
223
+ 'rule engines with any violations belonging to the issue': z.array(z.string().nullable()),
219
224
  'how to get details about the issue': z.object({method: z.literal('GET'), URL: z.string()}),
220
225
  'web users can get details about the issue at': z.string()
221
- }))
226
+ })).nullable()
222
227
  }));
223
228
 
224
- exports.listViolatorsResponseSchema = envelope('GET', z.object({
229
+ export const listViolatorsResponseSchema = envelope('GET', z.object({
225
230
  'basics about the report': reportBasicsOrErrorSchema,
226
231
  'basics about the issue': issueBasicsOrErrorSchema,
227
232
  'details about the issue': z.object({
228
233
  'rule engines reporting violations belonging to the issue': z.array(ruleEngineFactsSchema)
229
234
  }).nullable(),
230
- 'basics about all elements reported as exhibiting the issue': z.array(z.object({
231
- identifier: z.string(),
235
+ 'basics about all elements exhibiting the issue': z.array(z.object({
236
+ identifier: z.union([z.string(), z.number()]).describe('Catalog index of the element in the report; a string or number, copied verbatim.'),
232
237
  'tag name': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
233
238
  'inner text': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
234
239
  'count of rule engines reporting that the element exhibited the issue': z.number(),
@@ -237,7 +242,7 @@ exports.listViolatorsResponseSchema = envelope('GET', z.object({
237
242
  })).nullable()
238
243
  }));
239
244
 
240
- exports.listDiagnosesResponseSchema = envelope('GET', z.object({
245
+ export const listDiagnosesResponseSchema = envelope('GET', z.object({
241
246
  'basics about the report': reportBasicsOrErrorSchema,
242
247
  'basics about the issue': issueBasicsOrErrorSchema,
243
248
  'basics about the element': z.object({
@@ -255,41 +260,55 @@ exports.listDiagnosesResponseSchema = envelope('GET', z.object({
255
260
  'description of the violation': z.unknown().describe('Copied from the report instance (what); type not guaranteed.'),
256
261
  'severity of the violation on a 0-to-3 scale': z.unknown().describe('Copied from the report instance (ordinalSeverity); type not guaranteed.'),
257
262
  'count of violations of the rule by the element': z.number()
258
- })).or(z.object({error: z.string()}))
263
+ })).or(z.object({error: z.string()})).nullable()
259
264
  }));
260
265
 
261
- exports.getReportResponseSchema = envelope('GET', z.object({
266
+ export const getReportResponseSchema = envelope('GET', z.object({
262
267
  'size of the report in bytes': z.union([z.number(), z.string()]),
263
268
  'full report': z.unknown().describe('The full raw Testaro/Testilo report JSON, copied verbatim, or an error object if it could not be retrieved.')
264
269
  }));
265
270
 
266
- exports.requestTestResponseSchema = envelope(
271
+ export const requestTestResponseSchema = envelope(
267
272
  'POST',
268
- z.object({'details about your request': z.union([
269
- z.object({error: z.string()}),
270
- z.object({
271
- 'date and time received': z.string(),
272
- 'page to be tested': z.object({description: z.string(), URL: z.string()}),
273
- 'reason why the page should be tested': z.string()
274
- })
275
- ])}),
276
- z.object(exports.requestTestSchema)
273
+ z.object({
274
+ 'details about your request': z.union([
275
+ z.object({error: z.string()}),
276
+ z.object({
277
+ 'date and time received': z.string(),
278
+ 'page to be tested': z.object({description: z.string(), URL: z.string()}),
279
+ 'reason why the page should be tested': z.string()
280
+ })
281
+ ]),
282
+ 'disposition of your request': z.object({
283
+ 'what happens next': z.string(),
284
+ 'how you can check for completion': z.string(),
285
+ 'how a web user can check for completion': z.string()
286
+ }).nullable()
287
+ }),
288
+ z.object(requestTestSchema)
277
289
  );
278
290
 
279
- exports.requestRetestResponseSchema = envelope(
291
+ export const requestRetestResponseSchema = envelope(
280
292
  'POST',
281
- z.object({'details about your request': z.union([
282
- z.object({error: z.string()}),
283
- z.object({
284
- 'date and time received': z.string(),
285
- 'page to be retested': z.object({description: z.string(), URL: z.string()}),
286
- 'reason why the page should be retested': z.string()
287
- })
288
- ])}),
289
- z.object({reason: exports.requestRetestSchema.reason})
293
+ z.object({
294
+ 'details about your request': z.union([
295
+ z.object({error: z.string()}),
296
+ z.object({
297
+ 'date and time received': z.string(),
298
+ 'page to be retested': z.object({description: z.string(), URL: z.string()}),
299
+ 'reason why the page should be retested': z.string()
300
+ })
301
+ ]),
302
+ 'disposition of your request': z.object({
303
+ 'what happens next': z.string(),
304
+ 'how you can check for completion': z.string(),
305
+ 'how a web user can check for completion': z.string()
306
+ }).nullable()
307
+ }),
308
+ z.object({reason: requestRetestSchema.reason})
290
309
  );
291
310
 
292
- exports.requestFeatureResponseSchema = envelope(
311
+ export const requestFeatureResponseSchema = envelope(
293
312
  'POST',
294
313
  z.object({'details about your request': z.union([
295
314
  z.object({error: z.string()}),
@@ -298,6 +317,6 @@ exports.requestFeatureResponseSchema = envelope(
298
317
  disposition: z.string()
299
318
  })
300
319
  ])}),
301
- z.object(exports.requestFeatureSchema),
320
+ z.object(requestFeatureSchema),
302
321
  z.null()
303
322
  );
@@ -1,14 +1,14 @@
1
1
  /*
2
- util.test.js
3
- Tests for api/util.js.
2
+ util.test.ts
3
+ Tests for api/util.ts.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test} = require('node:test');
9
- const assert = require('node:assert/strict');
10
- const path = require('node:path');
11
- const {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts, processTestRequest} = require('./util');
8
+ import {test} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts} from './util.ts';
11
+ import {processTestRequest} from '../util.ts';
12
12
 
13
13
  // SETUP
14
14
 
@@ -64,8 +64,8 @@ test('getIssueSpec returns null for the ignorable issue ID', () => {
64
64
  });
65
65
 
66
66
  test('getReportBasics returns an error for a nonexistent report', async () => {
67
- process.env.DB_DIR = path.join(__dirname, '..', 'test', 'fixtures', 'db');
68
- const basics = await getReportBasics('999999T9999', 'xyz');
67
+ process.env.DB_DIR = (await import('../test/dbFixture.ts')).fixtureDBDir;
68
+ const basics: any = await getReportBasics('999999T9999', 'xyz');
69
69
  assert.ok(basics.error);
70
70
  if (savedDBDir !== undefined) {
71
71
  process.env.DB_DIR = savedDBDir;
@@ -75,13 +75,34 @@ test('getReportBasics returns an error for a nonexistent report', async () => {
75
75
  }
76
76
  });
77
77
 
78
- test('processTestRequest returns an error for a duplicate recommendation', async () => {
79
- process.env.DB_DIR = path.join(__dirname, '..', 'test', 'fixtures', 'db');
78
+ test('getReportBasics returns an error for a report whose extract cannot be read', async () => {
79
+ process.env.DB_DIR = (await import('../test/dbFixture.ts')).fixtureDBDir;
80
+ const fs = await import('node:fs/promises');
81
+ const path = await import('node:path');
82
+ const reportPath = path.join(process.env.DB_DIR, 'reports', '888888T8888-bad.json');
83
+ await fs.writeFile(reportPath, '{"malformed": true}\n');
84
+ try {
85
+ const basics: any = await getReportBasics('888888T8888', 'bad');
86
+ assert.ok(basics.error);
87
+ }
88
+ finally {
89
+ await fs.unlink(reportPath);
90
+ if (savedDBDir !== undefined) {
91
+ process.env.DB_DIR = savedDBDir;
92
+ }
93
+ else {
94
+ delete process.env.DB_DIR;
95
+ }
96
+ }
97
+ });
98
+
99
+ test('processTestRequest returns an error for a duplicate request', async () => {
100
+ process.env.DB_DIR = (await import('../test/dbFixture.ts')).fixtureDBDir;
80
101
  // Submit the same request twice; the second should be a duplicate.
81
- await processTestRequest('test', 'Dup Page', 'https://example.com/dup', 'A reason that is long enough.');
82
- const result = await processTestRequest('test', 'Dup Page', 'https://example.com/dup', 'A reason that is long enough.');
83
- assert.equal(result.status, 'error');
84
- assert.equal(result.message, 'Duplicate request');
102
+ const target = {description: 'Dup Page', url: 'https://example.com/dup'};
103
+ await processTestRequest('A reason that is long enough.', target);
104
+ const {result} = await processTestRequest('A reason that is long enough.', target);
105
+ assert.equal(result, 'duplicate');
85
106
  if (savedDBDir !== undefined) {
86
107
  process.env.DB_DIR = savedDBDir;
87
108
  }