@jrpool/kilotest 60.9.1 → 62.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": {
@@ -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
  });
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}`
@@ -31,7 +31,7 @@ after(() => {
31
31
  test('listPages returns one entry per tested page, sorted by page description', async () => {
32
32
  const body = await response();
33
33
  const pagesBasics = body['response content']['basics about all tested pages'];
34
- const descriptions = pagesBasics.map(b => b['tested web page'].description);
34
+ const descriptions = pagesBasics.map(b => b.description);
35
35
  assert.deepEqual(
36
36
  descriptions,
37
37
  [
@@ -49,31 +49,32 @@ test('listPages returns one entry per tested page, sorted by page description',
49
49
  test('listPages does not include the page of the hidden report', async () => {
50
50
  const body = await response();
51
51
  const pagesBasics = body['response content']['basics about all tested pages'];
52
- const ids = pagesBasics.map(b => b['basics about the latest report'].identifier);
52
+ const ids = pagesBasics.map(b => b.identifier);
53
53
  assert.ok(!ids.includes('260101T0007-hid'), 'hidden report must not appear in listPages');
54
54
  });
55
55
 
56
- test('listPages describes a multi-report page by its latest report and its report count', async () => {
56
+ test('listPages identifies a multi-report page by its latest report', async () => {
57
57
  const body = await response();
58
58
  const pagesBasics = body['response content']['basics about all tested pages'];
59
- const mixed = pagesBasics.find(b => b['tested web page'].description === 'Mixed Outcomes')!;
60
- assert.equal(mixed['number of reports about the page'], 2);
61
- const latest = mixed['basics about the latest report'];
62
- assert.equal(latest.identifier, '260202T0000-new');
63
- assert.equal(latest['completion date and time'], '2026-02-02T00:10:00.000Z');
64
- assert.equal(typeof latest['days since the report was completed'], 'number');
65
- assert.equal(mixed['how to get details about the latest report'].method, 'GET');
66
- assert.ok(mixed['how to get details about the latest report'].URL.endsWith('/api/listIssues/260202T0000-new'));
67
- assert.ok(mixed['web users can get details about the latest report at'].endsWith('/listIssues.html/260202T0000-new'));
59
+ const mixed = pagesBasics.find(b => b.description === 'Mixed Outcomes')!;
60
+ assert.equal(mixed.identifier, '260202T0000-new');
61
+ assert.equal(mixed.URL, 'https://example.com/mixed');
68
62
  });
69
63
 
70
64
  test('listPages describes a single-report page', async () => {
71
65
  const body = await response();
72
66
  const pagesBasics = body['response content']['basics about all tested pages'];
73
- const canttell = pagesBasics.find(b => b['tested web page'].description === 'All CantTell Page')!;
74
- assert.equal(canttell['number of reports about the page'], 1);
75
- assert.equal(canttell['tested web page'].URL, 'https://example.com/canttell');
76
- assert.equal(canttell['basics about the latest report'].identifier, '260101T0001-ct');
67
+ const canttell = pagesBasics.find(b => b.description === 'All CantTell Page')!;
68
+ assert.equal(canttell.URL, 'https://example.com/canttell');
69
+ assert.equal(canttell.identifier, '260101T0001-ct');
70
+ });
71
+
72
+ test('listPages includes result-retrieval instructions with an identifier placeholder', async () => {
73
+ const body = await response();
74
+ const content = body['response content'];
75
+ assert.equal(content['how to get test results about a page'].method, 'GET');
76
+ assert.ok(content['how to get test results about a page'].URL.endsWith('/api/listIssues/{identifier}'));
77
+ assert.ok(content['web users can get test results about a page at'].endsWith('/listIssues.html/{identifier}'));
77
78
  });
78
79
 
79
80
  test('listPages includes request-test instructions', async () => {
package/api/listPages.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {z} from 'zod';
9
9
  import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
- import {getAgoDays, getReportExtracts} from '../util.ts';
10
+ import {getReportExtracts} from '../util.ts';
11
11
  import {listPagesResponseSchema} from './schemas.ts';
12
12
 
13
13
  // TYPES
@@ -20,42 +20,20 @@ type ResponseContent = z.infer<typeof listPagesResponseSchema>['response content
20
20
  // Returns the response body.
21
21
  export const response = async () => {
22
22
  const thisHost = getThisHost();
23
- // Get extracts of all available reports, sorted by page description and completion time.
24
- const reportExtracts = await getReportExtracts();
25
- // Get the number of reports about each page.
26
- const reportCounts = new Map<string, number>();
27
- reportExtracts.forEach(({description}) => {
28
- reportCounts.set(description, (reportCounts.get(description) ?? 0) + 1);
29
- });
30
- // Initialize an array of basics about the pages.
31
- const pagesBasics: ResponseContent['basics about all tested pages'] = [];
32
- // For each report:
33
- reportExtracts.forEach(extract => {
34
- const {description, id, reportTime, superseded, url} = extract;
35
- // If it is the latest report about its page:
36
- if (!superseded) {
37
- // Add basics about the page, with instructions for getting details, to the array.
38
- pagesBasics.push({
39
- 'tested web page': {
40
- description,
41
- URL: url
42
- },
43
- 'number of reports about the page': reportCounts.get(description)!,
44
- 'basics about the latest report': {
45
- identifier: id,
46
- 'completion date and time': reportTime,
47
- 'days since the report was completed': getAgoDays(new Date(reportTime))
48
- },
49
- 'how to get details about the latest report': {
50
- method: 'GET',
51
- URL: `${thisHost}/api/listIssues/${id}`
52
- },
53
- 'web users can get details about the latest report at': `${thisHost}/listIssues.html/${id}`
54
- });
55
- }
56
- });
23
+ // Get extracts of the latest reports about all tested pages, sorted by page description.
24
+ const reportExtracts = await getReportExtracts(true);
25
+ // Get the basics about each tested page: its description, URL, and the identifier of
26
+ // its latest report, which the result-retrieval instructions below use as a placeholder.
27
+ const pagesBasics: ResponseContent['basics about all tested pages'] = reportExtracts.map(
28
+ ({description, id, url}) => ({description, URL: url, identifier: id})
29
+ );
57
30
  // Create the response content.
58
31
  const responseContent: ResponseContent = {
32
+ 'how to get test results about a page': {
33
+ method: 'GET',
34
+ URL: `${thisHost}/api/listIssues/{identifier}`
35
+ },
36
+ 'web users can get test results about a page at': `${thisHost}/listIssues.html/{identifier}`,
59
37
  'basics about all tested pages': pagesBasics,
60
38
  'how to request that a page with no report be tested': {
61
39
  method: 'POST',
@@ -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
  };
@@ -0,0 +1,208 @@
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
+
46
+ after(async () => {
47
+ console.log = originalLog;
48
+ if (savedDBDir !== undefined) {
49
+ process.env.DB_DIR = savedDBDir;
50
+ }
51
+ else {
52
+ delete process.env.DB_DIR;
53
+ }
54
+ await fs.rm(testDir, {recursive: true, force: true});
55
+ });
56
+
57
+ // TESTS
58
+
59
+ test('reportDisagreement rejects a reason shorter than 20 characters', async () => {
60
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'Too short']);
61
+ const details = body['response content']['details about your request'] as any;
62
+ assert.ok(details.error.includes('reason'));
63
+ assert.ok(
64
+ !logged.some(line => line.startsWith('WARNING (Kilotest: diagnosis disagreement received)'))
65
+ );
66
+ });
67
+
68
+ test('reportDisagreement rejects a reason longer than 1000 characters', async () => {
69
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'x'.repeat(1001)]);
70
+ const details = body['response content']['details about your request'] as any;
71
+ assert.ok(details.error.includes('reason'));
72
+ });
73
+
74
+ test('reportDisagreement rejects a nonexistent report', async () => {
75
+ const body = await response(['linkNoText', '999999T9999-xyz', '0', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
76
+ const details = body['response content']['details about your request'] as any;
77
+ assert.ok(details.error.includes('report'));
78
+ });
79
+
80
+ test('reportDisagreement rejects an element not in the report', async () => {
81
+ const body = await response(['linkNoText', '260101T0000-mix', '999', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
82
+ const details = body['response content']['details about your request'] as any;
83
+ assert.ok(details.error.includes('element'));
84
+ });
85
+
86
+ test('reportDisagreement rejects a diagnosis no rule engine reported', async () => {
87
+ // The mixed report has axe and alfa diagnoses of rule r11 for element 0 and issue
88
+ // linkNoText, but no ibm diagnosis, so this engineID/ruleID pair matches nothing.
89
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'ibm', 'r11', 'This diagnosis is wrong, in my opinion']);
90
+ const details = body['response content']['details about your request'] as any;
91
+ assert.ok(details.error.includes('no such diagnosis'));
92
+ });
93
+
94
+ test('reportDisagreement rejects a rule ID reported only under a different issue', async () => {
95
+ const body = await response(['focusIndicationBad', '260101T0000-mix', '0', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
96
+ const details = body['response content']['details about your request'] as any;
97
+ assert.ok(details.error.includes('no such diagnosis'));
98
+ });
99
+
100
+ test('reportDisagreement accepts a valid disagreement and notifies the manager', async () => {
101
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
102
+ const details = body['response content']['details about your request'] as any;
103
+ assert.equal(details.error, undefined);
104
+ assert.ok(details['date and time received']);
105
+ assert.equal(details['diagnosis you disagree with']['identifier of the rule engine'], 'axe');
106
+ assert.equal(details['diagnosis you disagree with']['identifier of the violated rule'], 'r11');
107
+ assert.equal(details.disposition, 'received and logged; manager notified');
108
+ assert.ok(logged.some(line =>
109
+ line.startsWith('WARNING (Kilotest: diagnosis disagreement received)')
110
+ && line.includes('Disagreements now awaiting review: 1 of 20')
111
+ ));
112
+ });
113
+
114
+ test('reportDisagreement records a valid disagreement to the disagreements file', async () => {
115
+ await response(['linkNoText', '260101T0000-mix', '0', 'alfa', 'r11', 'The element does have an accessible name']);
116
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
117
+ assert.equal(disagreements.length, 1);
118
+ assert.equal(disagreements[0].engineID, 'alfa');
119
+ assert.equal(disagreements[0].ruleID, 'r11');
120
+ assert.equal(disagreements[0].issueID, 'linkNoText');
121
+ assert.ok(disagreements[0].timeStamp);
122
+ });
123
+
124
+ test('reportDisagreement rejects a disagreement identical to one already on file', async () => {
125
+ const args = ['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name'];
126
+ const firstBody = await response(args);
127
+ const firstDetails = firstBody['response content']['details about your request'] as any;
128
+ assert.equal(firstDetails.error, undefined);
129
+ const secondBody = await response(args);
130
+ const secondDetails = secondBody['response content']['details about your request'] as any;
131
+ assert.ok(secondDetails.error.includes('identical to one already submitted'));
132
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
133
+ // The duplicate was not recorded a second time.
134
+ assert.equal(disagreements.length, 1);
135
+ });
136
+
137
+ test('reportDisagreement accepts the same reason about a different diagnosis', async () => {
138
+ await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
139
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'alfa', 'r11', 'The element does have an accessible name']);
140
+ const details = body['response content']['details about your request'] as any;
141
+ assert.equal(details.error, undefined);
142
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
143
+ assert.equal(disagreements.length, 2);
144
+ });
145
+
146
+ test('reportDisagreement creates the disagreements file when it does not exist', async () => {
147
+ await fs.unlink(process.env.DISAGREEMENTS_PATH!);
148
+ await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
149
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
150
+ assert.equal(disagreements.length, 1);
151
+ });
152
+
153
+ test('reportDisagreement rejects a disagreement with a fallback channel when the inbox is full', async () => {
154
+ // Fill disagreements.json with 20 distinct disagreements, reaching the cap.
155
+ const filler = Array.from({length: 20}, (_, i) => ({
156
+ timeStamp: '260101T0000',
157
+ issueID: 'linkNoText',
158
+ reportID: '260101T0000-mix',
159
+ catalogIndex: '0',
160
+ engineID: 'axe',
161
+ ruleID: `r${i}`,
162
+ reason: `Filler disagreement number ${i}, long enough to pass the length check.`
163
+ }));
164
+ await fs.writeFile(process.env.DISAGREEMENTS_PATH!, JSON.stringify(filler));
165
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
166
+ const details = body['response content']['details about your request'] as any;
167
+ assert.ok(details.error.includes('too many disagreements are awaiting review right now.'));
168
+ assert.ok(details.error.includes('https://github.com/jrpool/kilotest/issues'));
169
+ assert.ok(details.error.includes('info@kilotest.com'));
170
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
171
+ // The rejected disagreement was not recorded.
172
+ assert.equal(disagreements.length, 20);
173
+ });
174
+
175
+ test('reportDisagreement never rejects for a full inbox when DISAGREEMENTS_MAX is 0 (no limit)', async () => {
176
+ process.env.DISAGREEMENTS_MAX = '0';
177
+ try {
178
+ // Fill disagreements.json past the default cap.
179
+ const filler = Array.from({length: 25}, (_, i) => ({
180
+ timeStamp: '260101T0000',
181
+ issueID: 'linkNoText',
182
+ reportID: '260101T0000-mix',
183
+ catalogIndex: '0',
184
+ engineID: 'axe',
185
+ ruleID: `r${i}`,
186
+ reason: `Filler disagreement number ${i}, long enough to pass the length check.`
187
+ }));
188
+ await fs.writeFile(process.env.DISAGREEMENTS_PATH!, JSON.stringify(filler));
189
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
190
+ const details = body['response content']['details about your request'] as any;
191
+ assert.equal(details.error, undefined);
192
+ }
193
+ finally {
194
+ delete process.env.DISAGREEMENTS_MAX;
195
+ }
196
+ });
197
+
198
+ test('reportDisagreement does not record an invalid disagreement', async () => {
199
+ await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'short']);
200
+ const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
201
+ assert.equal(disagreements.length, 0);
202
+ });
203
+
204
+ test('reportDisagreement includes tool name and metadata', async () => {
205
+ const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
206
+ assert.equal(body['tool name'], 'reportDisagreement');
207
+ assert.ok(body['response metadata'].identifier);
208
+ });
@@ -0,0 +1,208 @@
1
+ /*
2
+ reportDisagreement.ts
3
+ Processes a report that the requester disagrees with a diagnosis and returns an acknowledgement.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {z} from 'zod';
9
+ import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
+ import {sendAlert} from '../alerts.ts';
11
+ import {
12
+ checkLength,
13
+ describeMax,
14
+ getEnvMax,
15
+ getJSON,
16
+ getNowStamp,
17
+ getReport,
18
+ getTestActInstances,
19
+ isReportError
20
+ } from '../util.ts';
21
+ import {reportDisagreementResponseSchema} from './schemas.ts';
22
+ import fs from 'node:fs/promises';
23
+ import path from 'node:path';
24
+
25
+ // TYPES
26
+
27
+ // The response content defined by the response schema.
28
+ type ResponseContent = z.infer<typeof reportDisagreementResponseSchema>['response content'];
29
+
30
+ // A stored disagreement with a diagnosis.
31
+ type Disagreement = {
32
+ timeStamp: string;
33
+ issueID: string;
34
+ reportID: string;
35
+ catalogIndex: string;
36
+ engineID: string;
37
+ ruleID: string;
38
+ reason: string;
39
+ };
40
+
41
+ // FUNCTIONS (helpers)
42
+
43
+ const getDisagreementsPath = () => process.env.DISAGREEMENTS_PATH || path.join(import.meta.dirname, '../db/disagreements.json');
44
+ // Maximum number of diagnosis disagreements held at once. Once reached, a new, distinct
45
+ // disagreement is rejected rather than stored, so the disagreements file and the manager
46
+ // alert emails it generates cannot grow without bound (modeled on the feature-request
47
+ // cap in requestFeature.ts). Configurable via DISAGREEMENTS_MAX; a value of 0 means no
48
+ // limit.
49
+ const getDisagreementsMax = () => getEnvMax('DISAGREEMENTS_MAX', 20);
50
+
51
+ // FUNCTIONS
52
+
53
+ // Returns the response body.
54
+ export const response = async (args: string[]) => {
55
+ const [issueID = '', reportID = '', catalogIndex = '', engineID = '', ruleID = '', reason = ''] = args;
56
+ const thisHost = getThisHost();
57
+ const reasonCheck = checkLength(reason, 20, 1000, 'reason');
58
+ // Initialize the response content.
59
+ let responseContent: ResponseContent;
60
+ // If the reason is too short or too long:
61
+ if (reasonCheck.status === 'error') {
62
+ responseContent = {
63
+ 'details about your request': {
64
+ error: `request invalid: your ${reasonCheck.message}`
65
+ }
66
+ };
67
+ }
68
+ // Otherwise, i.e. if it is valid:
69
+ else {
70
+ // Get the report.
71
+ const report = await getReport(reportID);
72
+ // If the report was not retrieved successfully:
73
+ if (isReportError(report)) {
74
+ responseContent = {
75
+ 'details about your request': {
76
+ error: 'request invalid: the specified report does not exist or is not usable'
77
+ }
78
+ };
79
+ }
80
+ // Otherwise, if the element is not in the report's catalog:
81
+ else if (!report.catalog[catalogIndex]) {
82
+ responseContent = {
83
+ 'details about your request': {
84
+ error: 'request invalid: the specified element is not in the report'
85
+ }
86
+ };
87
+ }
88
+ // Otherwise, i.e. if the report and element exist:
89
+ else {
90
+ // Get the reported diagnoses of the element for the issue matching the engine and
91
+ // rule (a ruleID is only unique within a rule engine: axe and alfa, for example,
92
+ // both report rules numbered like r11 for the same element and issue).
93
+ const matches = getTestActInstances(report, {violationsOnly: true, issueID, catalogIndex})
94
+ .filter(({act, instance}) => act.which === engineID && instance.ruleID === ruleID);
95
+ // If no reported diagnosis matches:
96
+ if (!matches.length) {
97
+ responseContent = {
98
+ 'details about your request': {
99
+ error: 'request invalid: no such diagnosis was reported for the specified element and issue'
100
+ }
101
+ };
102
+ }
103
+ // Otherwise, i.e. if the diagnosis exists:
104
+ else {
105
+ // Get the disagreements already on file.
106
+ const disagreementsPath = getDisagreementsPath();
107
+ let disagreements: Disagreement[] = [];
108
+ try {
109
+ const existing = await fs.readFile(disagreementsPath, 'utf8');
110
+ disagreements = JSON.parse(existing);
111
+ }
112
+ catch {
113
+ // Initialize an empty disagreements array.
114
+ }
115
+ // If the disagreement is identical to one already on file:
116
+ if (disagreements.some(d =>
117
+ d.issueID === issueID
118
+ && d.reportID === reportID
119
+ && d.catalogIndex === catalogIndex
120
+ && d.engineID === engineID
121
+ && d.ruleID === ruleID
122
+ && d.reason === reason
123
+ )) {
124
+ responseContent = {
125
+ 'details about your request': {
126
+ error: 'request invalid: your disagreement is identical to one already submitted, ' +
127
+ 'but you are welcome to submit a different one'
128
+ }
129
+ };
130
+ }
131
+ // Otherwise, if the disagreements already on file have reached the cap (a cap of 0
132
+ // means no limit, so the queue is never full):
133
+ else if (getDisagreementsMax() > 0 && disagreements.length >= getDisagreementsMax()) {
134
+ responseContent = {
135
+ 'details about your request': {
136
+ error: 'request invalid: too many disagreements are awaiting review right now. ' +
137
+ 'Please try again later, or post your report at https://github.com/jrpool/kilotest/issues ' +
138
+ 'or email info@kilotest.com.'
139
+ }
140
+ };
141
+ }
142
+ // Otherwise, i.e. if it is not a duplicate and the queue is not full:
143
+ else {
144
+ // Record the disagreement alongside the existing ones.
145
+ disagreements.push({
146
+ timeStamp: getNowStamp(),
147
+ issueID,
148
+ reportID,
149
+ catalogIndex,
150
+ engineID,
151
+ ruleID,
152
+ reason
153
+ });
154
+ await fs.mkdir(path.dirname(disagreementsPath), {recursive: true});
155
+ await fs.writeFile(disagreementsPath, getJSON(disagreements));
156
+ // Notify the manager, including the resulting count, so a maintainer who has been
157
+ // away sees at a glance how urgently the disagreements need review. The ruleID is
158
+ // truncated because some rule engines (nuVal, nuVnu) use entire validator messages
159
+ // as rule identifiers, which can be arbitrarily long.
160
+ await sendAlert(
161
+ 'Kilotest: diagnosis disagreement received',
162
+ `Disputed diagnosis: ${engineID} rule ${ruleID.slice(0, 500)} classified as issue ${issueID} ` +
163
+ `on element ${catalogIndex} of report ${reportID}\nReason: ${reason}\n` +
164
+ `Disagreements now awaiting review: ${disagreements.length} of ${describeMax(getDisagreementsMax())}`
165
+ );
166
+ // Add the disposition to the response content.
167
+ responseContent = {
168
+ 'details about your request': {
169
+ 'date and time received': new Date().toISOString(),
170
+ 'diagnosis you disagree with': {
171
+ 'issue identifier': issueID,
172
+ 'identifier of the rule engine': engineID,
173
+ 'identifier of the violated rule': ruleID
174
+ },
175
+ disposition: 'received and logged; manager notified'
176
+ }
177
+ };
178
+ }
179
+ }
180
+ }
181
+ }
182
+ // Create a response body.
183
+ const body = {
184
+ 'tool collection': getToolsFacts(),
185
+ 'tool name': 'reportDisagreement',
186
+ 'this request': {
187
+ description: 'Process my report that I disagree with a diagnosis. The issueID, reportID, and catalogIndex parameters identify the issue, report, and element of the diagnosis. They were in the response to my earlier listDiagnoses request. The engineID and ruleID properties of the request body identify which diagnosis I disagree with, and the reason property explains why I disagree with it.',
188
+ method: 'POST',
189
+ URL: `${thisHost}/api/reportDisagreement/${issueID}/${reportID}/${catalogIndex}`,
190
+ body: {
191
+ engineID,
192
+ ruleID,
193
+ reason
194
+ },
195
+ 'closest ancestor request': {
196
+ 'tool name': 'listDiagnoses',
197
+ description: 'Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.',
198
+ method: 'GET',
199
+ URL: `${thisHost}/api/listDiagnoses/${catalogIndex}/${issueID}/${reportID}`
200
+ }
201
+ },
202
+ 'URLs of similar requests for web users': null,
203
+ 'response metadata': getResponseMetadata(),
204
+ 'response content': responseContent
205
+ };
206
+ // Return it.
207
+ return body;
208
+ };