@jrpool/kilotest 61.0.0 → 63.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.
@@ -41,7 +41,8 @@
41
41
  "Exec(npm update)",
42
42
  "Exec(npm ls)",
43
43
  "Exec(pkill)",
44
- "Exec(python3)"
44
+ "Exec(python3)",
45
+ "Exec(npx)"
45
46
  ]
46
47
  }
47
48
  }
@@ -3,7 +3,6 @@
3
3
  "**/*.md"
4
4
  ],
5
5
  "ignores": [
6
- "docs/IDEAS.md",
7
6
  "node_modules/**"
8
7
  ],
9
8
  "config": {
@@ -29,6 +29,7 @@ before(() => {
29
29
  });
30
30
 
31
31
  import {response} from './awaitTest.ts';
32
+ import {awaitTestResponseSchema} from './schemas.ts';
32
33
 
33
34
  after(() => {
34
35
  delete process.env.AWAIT_JOB_POLL_MS;
@@ -112,3 +113,11 @@ test('awaitTest reports both orderNewTest and orderRetest as possible ancestors'
112
113
  assert.equal(ancestor.method, 'POST');
113
114
  }
114
115
  });
116
+
117
+ test('awaitTest response conforms to its response schema', async () => {
118
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
119
+ // original catches extra keys that zod strips but that the strict JSON schema
120
+ // advertised to MCP clients (additionalProperties: false) rejects.
121
+ const body = await response(['999999T9999-xyz']);
122
+ assert.deepEqual(awaitTestResponseSchema.parse(body), body);
123
+ });
@@ -8,6 +8,7 @@
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import {response} from './getReport.ts';
11
+ import {getReportResponseSchema} from './schemas.ts';
11
12
 
12
13
  // SETUP AND TEARDOWN
13
14
 
@@ -59,3 +60,11 @@ test('getReport includes tool name and metadata', async () => {
59
60
  assert.equal(body['tool name'], 'getReport');
60
61
  assert.ok(body['response metadata'].identifier);
61
62
  });
63
+
64
+ test('getReport response conforms to its response schema', async () => {
65
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
66
+ // original catches extra keys that zod strips but that the strict JSON schema
67
+ // advertised to MCP clients (additionalProperties: false) rejects.
68
+ const body = await response(['260101T0000-mix']);
69
+ assert.deepEqual(getReportResponseSchema.parse(body), body);
70
+ });
@@ -8,6 +8,7 @@
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import {response} from './listDiagnoses.ts';
11
+ import {listDiagnosesResponseSchema} from './schemas.ts';
11
12
 
12
13
  // SETUP AND TEARDOWN
13
14
 
@@ -129,3 +130,11 @@ test('listDiagnoses returns issue error for a falsy issue ID', async () => {
129
130
  const issueBasics = body['response content']['basics about the issue'] as any;
130
131
  assert.ok(issueBasics.error);
131
132
  });
133
+
134
+ test('listDiagnoses response conforms to its response schema', async () => {
135
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
136
+ // original catches extra keys that zod strips but that the strict JSON schema
137
+ // advertised to MCP clients (additionalProperties: false) rejects.
138
+ const body = await response(['0', 'linkNoText', '260101T0000-mix']);
139
+ assert.deepEqual(listDiagnosesResponseSchema.parse(body), body);
140
+ });
@@ -94,14 +94,24 @@ export const response = async (args: string[]) => {
94
94
  const diagnoses: Diagnoses = [];
95
95
  // For each instance reporting a violation of the issue by the violator:
96
96
  getTestActInstances(report, {violationsOnly: true, issueID, catalogIndex})
97
- .forEach(({instance}) => {
97
+ .forEach(({act, instance}) => {
98
98
  const {count, ordinalSeverity, ruleID, what} = instance;
99
99
  // Get the diagnosis.
100
- const diagnosis = {
100
+ const diagnosis: Diagnoses[number] = {
101
+ 'identifier of the rule engine': act.which!,
101
102
  'identifier of the violated rule': ruleID !== what ? ruleID : null,
102
103
  'description of the violation': what,
103
104
  'severity of the violation on a 0-to-3 scale': ordinalSeverity,
104
- 'count of violations of the rule by the element': count ?? 1
105
+ 'count of violations of the rule by the element': count ?? 1,
106
+ 'how to report that you disagree with this diagnosis': {
107
+ method: 'POST',
108
+ URL: `${thisHost}/api/reportDisagreement/${issueID}/${reportID}/${catalogIndex}`,
109
+ 'request body': {
110
+ engineID: 'the identifier of the rule engine shown in this diagnosis',
111
+ ruleID: 'the identifier of the violated rule shown in this diagnosis, or, if it is null, the description of the violation',
112
+ reason: '20- to 1000-character explanation of why you disagree with this diagnosis'
113
+ }
114
+ }
105
115
  }
106
116
  diagnoses.push(diagnosis);
107
117
  });
@@ -8,6 +8,7 @@
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import {response} from './listIssues.ts';
11
+ import {listIssuesResponseSchema} from './schemas.ts';
11
12
 
12
13
  // SETUP AND TEARDOWN
13
14
 
@@ -145,3 +146,11 @@ test('listIssues gives no history for a nonexistent report', async () => {
145
146
  const body = await response(['999999T9999-xyz']);
146
147
  assert.equal(body['response content']['history of reports about the page'], null);
147
148
  });
149
+
150
+ test('listIssues response conforms to its response schema', async () => {
151
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
152
+ // original catches extra keys that zod strips but that the strict JSON schema
153
+ // advertised to MCP clients (additionalProperties: false) rejects.
154
+ const body = await response(['260101T0000-mix']);
155
+ assert.deepEqual(listIssuesResponseSchema.parse(body), body);
156
+ });
package/api/listIssues.ts CHANGED
@@ -31,6 +31,10 @@ import {listIssuesResponseSchema} from './schemas.ts';
31
31
 
32
32
  // The response content defined by the response schema.
33
33
  type ResponseContent = z.infer<typeof listIssuesResponseSchema>['response content'];
34
+ // One entry of the history of reports about the page.
35
+ type ReportHistoryEntry = NonNullable<ResponseContent['history of reports about the page']>[number];
36
+ // The basics about one issue in the report.
37
+ type IssueBasics = NonNullable<ResponseContent['basics about all issues reported in the report']>[number];
34
38
 
35
39
  // FUNCTIONS
36
40
 
@@ -67,7 +71,7 @@ export const response = async (args: string[]) => {
67
71
  const pageExtracts = await getPageReportExtracts(report.target.what);
68
72
  const lastIndex = pageExtracts.length - 1;
69
73
  // Add the history of reports about the page to the response content.
70
- responseContent['history of reports about the page'] = pageExtracts.map((extract, index) => {
74
+ responseContent['history of reports about the page'] = pageExtracts.map((extract, index): ReportHistoryEntry => {
71
75
  const extractID = extract.id;
72
76
  return {
73
77
  identifier: extractID,
@@ -77,7 +81,7 @@ export const response = async (args: string[]) => {
77
81
  'whether it is the latest report about the page': index === lastIndex,
78
82
  'whether it is the report described in this response': extractID === reportID,
79
83
  'how to get details about the report': {
80
- method: 'GET' as const,
84
+ method: 'GET',
81
85
  URL: `${thisHost}/api/listIssues/${extractID}`
82
86
  },
83
87
  'web users can get details about the report at': `${thisHost}/listIssues.html/${extractID}`
@@ -210,7 +214,7 @@ export const response = async (args: string[]) => {
210
214
  // Sort the data about issues by summary.
211
215
  const sortedIssuesData = objectSort(Object.values(issuesData), 'summary', 'alpha');
212
216
  // Get the basics about the issues.
213
- const issuesBasics = sortedIssuesData.map(issueData => {
217
+ const issuesBasics = sortedIssuesData.map((issueData): IssueBasics => {
214
218
  const {id, summary, weight, why, reporterIDs} = issueData;
215
219
  return {
216
220
  identifier: id,
@@ -220,7 +224,7 @@ export const response = async (args: string[]) => {
220
224
  'rule engines with any violations belonging to the issue': getRuleEnginesFacts(reporterIDs)
221
225
  .map(ruleEnginesFact => ruleEnginesFact.name),
222
226
  'how to get details about the issue': {
223
- method: 'GET' as const,
227
+ method: 'GET',
224
228
  URL: `${thisHost}/api/listViolators/${id}/${reportID}`
225
229
  },
226
230
  'web users can get details about the issue at': `${thisHost}/listViolators.html/${id}/${reportID}`
@@ -8,6 +8,7 @@
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import {response} from './listPages.ts';
11
+ import {listPagesResponseSchema} from './schemas.ts';
11
12
 
12
13
  // SETUP AND TEARDOWN
13
14
 
@@ -91,3 +92,11 @@ test('listPages includes tool name and metadata', async () => {
91
92
  assert.ok(body['response metadata'].identifier);
92
93
  assert.ok(body['response metadata']['date and time']);
93
94
  });
95
+
96
+ test('listPages response conforms to its response schema', async () => {
97
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
98
+ // original catches extra keys that zod strips but that the strict JSON schema
99
+ // advertised to MCP clients (additionalProperties: false) rejects.
100
+ const body = await response();
101
+ assert.deepEqual(listPagesResponseSchema.parse(body), body);
102
+ });
@@ -8,6 +8,7 @@
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import {response} from './listViolators.ts';
11
+ import {listViolatorsResponseSchema} from './schemas.ts';
11
12
 
12
13
  // SETUP AND TEARDOWN
13
14
 
@@ -105,3 +106,11 @@ test('listViolators returns an error for a falsy issue ID', async () => {
105
106
  const issueBasics = body['response content']['basics about the issue'] as any;
106
107
  assert.ok(issueBasics.error);
107
108
  });
109
+
110
+ test('listViolators response conforms to its response schema', async () => {
111
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
112
+ // original catches extra keys that zod strips but that the strict JSON schema
113
+ // advertised to MCP clients (additionalProperties: false) rejects.
114
+ const body = await response(['linkNoText', '260101T0000-mix']);
115
+ assert.deepEqual(listViolatorsResponseSchema.parse(body), body);
116
+ });
@@ -21,6 +21,8 @@ import {listViolatorsResponseSchema} from './schemas.ts';
21
21
 
22
22
  // The response content defined by the response schema.
23
23
  type ResponseContent = z.infer<typeof listViolatorsResponseSchema>['response content'];
24
+ // The basics about one element exhibiting the issue.
25
+ type ViolatorBasics = NonNullable<ResponseContent['basics about all elements exhibiting the issue']>[number];
24
26
 
25
27
  // FUNCTIONS
26
28
 
@@ -96,7 +98,7 @@ export const response = async (args: string[]) => {
96
98
  );
97
99
  // Get basics about the violators.
98
100
  const violatorsBasics = sortedViolatorData
99
- .map(violator => {
101
+ .map((violator): ViolatorBasics => {
100
102
  const {catalogIndex, reporters} = violator;
101
103
  const catalogItem = catalog[catalogIndex];
102
104
  return {
@@ -105,8 +107,8 @@ export const response = async (args: string[]) => {
105
107
  'inner text': catalogItem?.text ?? null,
106
108
  'count of rule engines reporting that the element exhibited the issue': reporters.size,
107
109
  'how to get details about the element': {
108
- URL: `${thisHost}/api/listDiagnoses/${catalogIndex}/${issueID}/${reportID}`,
109
- 'request method': 'GET' as const
110
+ method: 'GET',
111
+ URL: `${thisHost}/api/listDiagnoses/${catalogIndex}/${issueID}/${reportID}`
110
112
  },
111
113
  'web users can get details about the element at': `${thisHost}/listDiagnoses.html/${issueID}/${reportID}/${catalogIndex}`
112
114
  };
@@ -45,6 +45,7 @@ beforeEach(async () => {
45
45
  });
46
46
 
47
47
  import {response} from './orderNewTest.ts';
48
+ import {orderNewTestResponseSchema} from './schemas.ts';
48
49
 
49
50
  after(() => {
50
51
  console.log = originalLog;
@@ -210,3 +211,11 @@ test('orderNewTest response omits a web UI URL for this request but includes the
210
211
  assert.equal(similarWeb['this request'], null);
211
212
  assert.ok(similarWeb['closest ancestor request'].includes('listPages.html'));
212
213
  });
214
+
215
+ test('orderNewTest response conforms to its response schema', async () => {
216
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
217
+ // original catches extra keys that zod strips but that the strict JSON schema
218
+ // advertised to MCP clients (additionalProperties: false) rejects.
219
+ const body = await response(['Schema Conformance Ordered Page', 'https://example.com/schema-ordered', 'A reason that is long enough.']);
220
+ assert.deepEqual(orderNewTestResponseSchema.parse(body), body);
221
+ });
@@ -48,6 +48,7 @@ beforeEach(async () => {
48
48
  });
49
49
 
50
50
  import {response} from './orderRetest.ts';
51
+ import {orderRetestResponseSchema} from './schemas.ts';
51
52
 
52
53
  after(async () => {
53
54
  await fs.unlink(retestFixturePath);
@@ -177,3 +178,11 @@ test('orderRetest response omits a web UI URL for this request but includes the
177
178
  assert.equal(similarWeb['this request'], null);
178
179
  assert.ok(similarWeb['closest ancestor request'].includes('listIssues.html'));
179
180
  });
181
+
182
+ test('orderRetest response conforms to its response schema', async () => {
183
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
184
+ // original catches extra keys that zod strips but that the strict JSON schema
185
+ // advertised to MCP clients (additionalProperties: false) rejects.
186
+ const body = await response(['260101T0004-ord', 'A reason that is long enough.']);
187
+ assert.deepEqual(orderRetestResponseSchema.parse(body), body);
188
+ });
@@ -0,0 +1,217 @@
1
+ /*
2
+ reportDisagreement.test.ts
3
+ Tests for api/reportDisagreement.ts using the fixture corpus, with mocked side effects.
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
+
13
+ // Blank the alert configuration unconditionally, before ./reportDisagreement.ts (which
14
+ // calls sendAlert) is ever imported below, so this file sends no real alert emails
15
+ // even when run directly rather than via `npm test`.
16
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
17
+ process.env[key] = '';
18
+ }
19
+
20
+ // ENVIRONMENT
21
+
22
+ const testDir = path.join(import.meta.dirname, '../test/fixtures/disagreements');
23
+ process.env.DISAGREEMENTS_PATH = path.join(testDir, 'disagreements.json');
24
+
25
+ // SETUP AND TEARDOWN
26
+
27
+ const savedDBDir = process.env.DB_DIR;
28
+ let logged: any[] = [];
29
+ const originalLog = console.log;
30
+
31
+ before(async () => {
32
+ process.env.DB_DIR = (await import('../test/dbFixture.ts')).fixtureDBDir;
33
+ });
34
+
35
+ // Capture console.log so the alert sendAlert emits can be observed. The alert
36
+ // configuration is blanked above, so sendAlert logs a WARNING instead of sending.
37
+ beforeEach(async () => {
38
+ logged = [];
39
+ console.log = (...args) => logged.push(args.join(' '));
40
+ await fs.mkdir(testDir, {recursive: true});
41
+ await fs.writeFile(process.env.DISAGREEMENTS_PATH!, '[]\n');
42
+ });
43
+
44
+ import {response} from './reportDisagreement.ts';
45
+ import {reportDisagreementResponseSchema} from './schemas.ts';
46
+
47
+ after(async () => {
48
+ console.log = originalLog;
49
+ if (savedDBDir !== undefined) {
50
+ process.env.DB_DIR = savedDBDir;
51
+ }
52
+ else {
53
+ delete process.env.DB_DIR;
54
+ }
55
+ await fs.rm(testDir, {recursive: true, force: true});
56
+ });
57
+
58
+ // TESTS
59
+
60
+ test('reportDisagreement rejects a reason shorter than 20 characters', async () => {
61
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'Too short']);
62
+ const details = body['response content']['details about your request'] as any;
63
+ assert.ok(details.error.includes('reason'));
64
+ assert.ok(
65
+ !logged.some(line => line.startsWith('WARNING (Kilotest: diagnosis disagreement received)'))
66
+ );
67
+ });
68
+
69
+ test('reportDisagreement rejects a reason longer than 1000 characters', async () => {
70
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'x'.repeat(1001)]);
71
+ const details = body['response content']['details about your request'] as any;
72
+ assert.ok(details.error.includes('reason'));
73
+ });
74
+
75
+ test('reportDisagreement rejects a nonexistent report', async () => {
76
+ const body = await response(['linkNoText', '999999T9999-xyz', '0', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
77
+ const details = body['response content']['details about your request'] as any;
78
+ assert.ok(details.error.includes('report'));
79
+ });
80
+
81
+ test('reportDisagreement rejects an element not in the report', async () => {
82
+ const body = await response(['linkNoText', '260101T0000-mix', '999', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
83
+ const details = body['response content']['details about your request'] as any;
84
+ assert.ok(details.error.includes('element'));
85
+ });
86
+
87
+ test('reportDisagreement rejects a diagnosis no rule engine reported', async () => {
88
+ // The mixed report has axe and alfa diagnoses of rule r11 for element 0 and issue
89
+ // linkNoText, but no ibm diagnosis, so this engineID/ruleID pair matches nothing.
90
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'ibm', 'r11', 'This diagnosis is wrong, in my opinion']);
91
+ const details = body['response content']['details about your request'] as any;
92
+ assert.ok(details.error.includes('no such diagnosis'));
93
+ });
94
+
95
+ test('reportDisagreement rejects a rule ID reported only under a different issue', async () => {
96
+ const body = await response(['focusIndicationBad', '260101T0000-mix', '0', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
97
+ const details = body['response content']['details about your request'] as any;
98
+ assert.ok(details.error.includes('no such diagnosis'));
99
+ });
100
+
101
+ test('reportDisagreement accepts a valid disagreement and notifies the manager', async () => {
102
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
103
+ const details = body['response content']['details about your request'] as any;
104
+ assert.equal(details.error, undefined);
105
+ assert.ok(details['date and time received']);
106
+ assert.equal(details['diagnosis you disagree with']['identifier of the rule engine'], 'axe');
107
+ assert.equal(details['diagnosis you disagree with']['identifier of the violated rule'], 'r11');
108
+ assert.equal(details.disposition, 'received and logged; manager notified');
109
+ assert.ok(logged.some(line =>
110
+ line.startsWith('WARNING (Kilotest: diagnosis disagreement received)')
111
+ && line.includes('Disagreements now awaiting review: 1 of 20')
112
+ ));
113
+ });
114
+
115
+ test('reportDisagreement records a valid disagreement to the disagreements file', async () => {
116
+ await response(['linkNoText', '260101T0000-mix', '0', 'alfa', 'r11', 'The element does have an accessible name']);
117
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
118
+ assert.equal(disagreements.length, 1);
119
+ assert.equal(disagreements[0].engineID, 'alfa');
120
+ assert.equal(disagreements[0].ruleID, 'r11');
121
+ assert.equal(disagreements[0].issueID, 'linkNoText');
122
+ assert.ok(disagreements[0].timeStamp);
123
+ });
124
+
125
+ test('reportDisagreement rejects a disagreement identical to one already on file', async () => {
126
+ const args = ['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name'];
127
+ const firstBody = await response(args);
128
+ const firstDetails = firstBody['response content']['details about your request'] as any;
129
+ assert.equal(firstDetails.error, undefined);
130
+ const secondBody = await response(args);
131
+ const secondDetails = secondBody['response content']['details about your request'] as any;
132
+ assert.ok(secondDetails.error.includes('identical to one already submitted'));
133
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
134
+ // The duplicate was not recorded a second time.
135
+ assert.equal(disagreements.length, 1);
136
+ });
137
+
138
+ test('reportDisagreement accepts the same reason about a different diagnosis', async () => {
139
+ await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
140
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'alfa', 'r11', 'The element does have an accessible name']);
141
+ const details = body['response content']['details about your request'] as any;
142
+ assert.equal(details.error, undefined);
143
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
144
+ assert.equal(disagreements.length, 2);
145
+ });
146
+
147
+ test('reportDisagreement creates the disagreements file when it does not exist', async () => {
148
+ await fs.unlink(process.env.DISAGREEMENTS_PATH!);
149
+ await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
150
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
151
+ assert.equal(disagreements.length, 1);
152
+ });
153
+
154
+ test('reportDisagreement rejects a disagreement with a fallback channel when the inbox is full', async () => {
155
+ // Fill disagreements.json with 20 distinct disagreements, reaching the cap.
156
+ const filler = Array.from({length: 20}, (_, i) => ({
157
+ timeStamp: '260101T0000',
158
+ issueID: 'linkNoText',
159
+ reportID: '260101T0000-mix',
160
+ catalogIndex: '0',
161
+ engineID: 'axe',
162
+ ruleID: `r${i}`,
163
+ reason: `Filler disagreement number ${i}, long enough to pass the length check.`
164
+ }));
165
+ await fs.writeFile(process.env.DISAGREEMENTS_PATH!, JSON.stringify(filler));
166
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
167
+ const details = body['response content']['details about your request'] as any;
168
+ assert.ok(details.error.includes('too many disagreements are awaiting review right now.'));
169
+ assert.ok(details.error.includes('https://github.com/jrpool/kilotest/issues'));
170
+ assert.ok(details.error.includes('info@kilotest.com'));
171
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
172
+ // The rejected disagreement was not recorded.
173
+ assert.equal(disagreements.length, 20);
174
+ });
175
+
176
+ test('reportDisagreement never rejects for a full inbox when DISAGREEMENTS_MAX is 0 (no limit)', async () => {
177
+ process.env.DISAGREEMENTS_MAX = '0';
178
+ try {
179
+ // Fill disagreements.json past the default cap.
180
+ const filler = Array.from({length: 25}, (_, i) => ({
181
+ timeStamp: '260101T0000',
182
+ issueID: 'linkNoText',
183
+ reportID: '260101T0000-mix',
184
+ catalogIndex: '0',
185
+ engineID: 'axe',
186
+ ruleID: `r${i}`,
187
+ reason: `Filler disagreement number ${i}, long enough to pass the length check.`
188
+ }));
189
+ await fs.writeFile(process.env.DISAGREEMENTS_PATH!, JSON.stringify(filler));
190
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
191
+ const details = body['response content']['details about your request'] as any;
192
+ assert.equal(details.error, undefined);
193
+ }
194
+ finally {
195
+ delete process.env.DISAGREEMENTS_MAX;
196
+ }
197
+ });
198
+
199
+ test('reportDisagreement does not record an invalid disagreement', async () => {
200
+ await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'short']);
201
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
202
+ assert.equal(disagreements.length, 0);
203
+ });
204
+
205
+ test('reportDisagreement includes tool name and metadata', async () => {
206
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
207
+ assert.equal(body['tool name'], 'reportDisagreement');
208
+ assert.ok(body['response metadata'].identifier);
209
+ });
210
+
211
+ test('reportDisagreement response conforms to its response schema', async () => {
212
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
213
+ // original catches extra keys that zod strips but that the strict JSON schema
214
+ // advertised to MCP clients (additionalProperties: false) rejects.
215
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'A reason submitted only for schema conformance.']);
216
+ assert.deepEqual(reportDisagreementResponseSchema.parse(body), body);
217
+ });