@jrpool/kilotest 46.0.8 → 46.0.14

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 (60) hide show
  1. package/.devin/config.local.json +4 -1
  2. package/.github/workflows/{smoke-test.yml → ci.yml} +8 -2
  3. package/README.md +14 -8
  4. package/alerts.test.js +137 -0
  5. package/api/listDiagnoses.test.js +44 -0
  6. package/api/listIssues.js +1 -1
  7. package/api/listIssues.test.js +10 -0
  8. package/api/listReports.test.js +6 -2
  9. package/api/listViolators.test.js +35 -0
  10. package/api/requestTest.test.js +7 -0
  11. package/api/routes.test.js +63 -0
  12. package/api/util.js +9 -11
  13. package/api/util.test.js +68 -2
  14. package/apiIntegration.test.js +13 -3
  15. package/docs/AI-TOOLS.md +41 -158
  16. package/docs/{Smoke Test.json → CI.json} +4 -3
  17. package/index.html +3 -3
  18. package/index.js +64 -77
  19. package/index.test.js +1152 -0
  20. package/indexBalances.test.js +188 -0
  21. package/indexFallback.test.js +33 -0
  22. package/indexServe.test.js +59 -0
  23. package/indexWorkers.test.js +79 -0
  24. package/lhm.plugin.json +3218 -0
  25. package/llms-full.txt +6 -7
  26. package/llms.txt +1 -1
  27. package/mcp.js +2 -2
  28. package/mcp.test.js +281 -0
  29. package/openapi.yaml +1 -1
  30. package/package.json +10 -5
  31. package/routing.test.js +13 -3
  32. package/scripts/generate-openapi.js +1 -1
  33. package/test/fixtures/buildFixtures.js +83 -0
  34. package/test/fixtures/db/reports/260101T0008-mul.json +137 -0
  35. package/test/fixtures/db/reports/260101T0009-brd.json +147 -0
  36. package/test/setup.cjs +14 -0
  37. package/util.js +16 -6
  38. package/util.test.js +886 -3
  39. package/web/ai0BalanceForm/ai0BalanceForm.test.js +88 -0
  40. package/web/expungeReportsForm/expungeReportsForm.test.js +114 -0
  41. package/web/hideReportForm/hideReportForm.test.js +82 -0
  42. package/web/listDiagnoses/listDiagnoses.test.js +160 -0
  43. package/web/listIssues/listIssues.test.js +109 -0
  44. package/web/listReports/listReports.test.js +139 -3
  45. package/web/listRules/listRules.test.js +103 -0
  46. package/web/listTopIssues/listTopIssues.test.js +126 -0
  47. package/web/listViolators/index.js +1 -2
  48. package/web/listViolators/listViolators.test.js +256 -0
  49. package/web/pruneReportsForm/pruneReportsForm.test.js +86 -0
  50. package/web/reannotate/reannotate.test.js +144 -0
  51. package/web/reannotateForm/reannotateForm.test.js +134 -0
  52. package/web/renewWCAG/renewWCAG.test.js +124 -0
  53. package/web/requestRetest/requestRetest.test.js +119 -0
  54. package/web/requestRetestForm/requestRetestForm.test.js +62 -0
  55. package/web/requestTest/requestTest.test.js +115 -0
  56. package/web/requestTestForm/requestTestForm.test.js +40 -0
  57. package/web/rewindReportsForm/rewindReportsForm.test.js +86 -0
  58. package/web/tutorial/index.html +3 -3
  59. package/web/tutorial/tutorial.test.js +97 -0
  60. package/web/unhideReportForm/unhideReportForm.test.js +135 -0
@@ -8,6 +8,7 @@
8
8
  const {test, before, after} = require('node:test');
9
9
  const assert = require('node:assert/strict');
10
10
  const path = require('node:path');
11
+ const fs = require('node:fs/promises');
11
12
  const {parse} = require('node-html-parser');
12
13
  const {answer} = require('./index');
13
14
 
@@ -15,8 +16,12 @@ const {answer} = require('./index');
15
16
 
16
17
  const savedDBDir = process.env.DB_DIR;
17
18
 
18
- before(() => {
19
+ before(async () => {
19
20
  process.env.DB_DIR = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
21
+ // Ensure job subdirectories exist (they are empty and not tracked by Git).
22
+ for (const sub of ['queue', 'claimed', 'failed']) {
23
+ await fs.mkdir(path.join(process.env.DB_DIR, 'jobs', sub), {recursive: true});
24
+ }
20
25
  });
21
26
 
22
27
  after(() => {
@@ -38,11 +43,11 @@ test('listReports returns an ok status with valid HTML', async () => {
38
43
  assert.equal(html.querySelector('title').text, 'Pages tested | Kilotest');
39
44
  });
40
45
 
41
- test('listReports includes the 6 non-hidden fixture reports as details elements', async () => {
46
+ test('listReports includes the 8 non-hidden fixture reports as details elements', async () => {
42
47
  const result = await answer();
43
48
  const html = parse(result.answerPage);
44
49
  const details = html.querySelectorAll('details');
45
- assert.equal(details.length, 6);
50
+ assert.equal(details.length, 8);
46
51
  });
47
52
 
48
53
  test('listReports includes the page descriptions in summary elements', async () => {
@@ -82,3 +87,134 @@ test('listReports includes a link to request testing a new page', async () => {
82
87
  const testLink = html.querySelector('a[href="requestTestForm.html"]');
83
88
  assert.ok(testLink);
84
89
  });
90
+
91
+ test('listReports shows recommendations when recs.json has entries', async () => {
92
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
93
+ const recsPath = path.join(dbDir, 'jobs', 'recs.json');
94
+ const originalRecs = await fs.readFile(recsPath, 'utf8');
95
+ try {
96
+ const testRecs = {
97
+ 'https://example.com/mixed': [
98
+ {what: 'Mixed Outcomes Page', why: 'Needs retesting for accessibility'}
99
+ ]
100
+ };
101
+ await fs.writeFile(recsPath, JSON.stringify(testRecs, null, 2));
102
+ const result = await answer();
103
+ assert.equal(result.status, 'ok');
104
+ assert.ok(result.answerPage.includes('https://example.com/mixed'));
105
+ assert.ok(result.answerPage.includes('Needs retesting for accessibility'));
106
+ }
107
+ finally {
108
+ await fs.writeFile(recsPath, originalRecs);
109
+ }
110
+ });
111
+
112
+ test('listReports shows queued and claimed jobs when they exist', async () => {
113
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
114
+ const queueDir = path.join(dbDir, 'jobs', 'queue');
115
+ const claimedDir = path.join(dbDir, 'jobs', 'claimed');
116
+ const queueFile = path.join(queueDir, 'queuedJob.json');
117
+ const claimedFile = path.join(claimedDir, 'claimedJob.json');
118
+ try {
119
+ const queuedJob = {
120
+ target: {url: 'https://example.com/queued', what: 'Queued Page'}
121
+ };
122
+ const claimedJob = {
123
+ target: {url: 'https://example.com/claimed', what: 'Claimed Page'}
124
+ };
125
+ await fs.writeFile(queueFile, JSON.stringify(queuedJob, null, 2));
126
+ await fs.writeFile(claimedFile, JSON.stringify(claimedJob, null, 2));
127
+ const result = await answer();
128
+ assert.equal(result.status, 'ok');
129
+ assert.ok(result.answerPage.includes('https://example.com/queued'));
130
+ assert.ok(result.answerPage.includes('Queued Page'));
131
+ assert.ok(result.answerPage.includes('https://example.com/claimed'));
132
+ assert.ok(result.answerPage.includes('Claimed Page'));
133
+ }
134
+ finally {
135
+ await fs.unlink(queueFile).catch(() => {});
136
+ await fs.unlink(claimedFile).catch(() => {});
137
+ }
138
+ });
139
+
140
+ test('listReports shows claimed retest status for a report with a matching claimed job', async () => {
141
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
142
+ const claimedDir = path.join(dbDir, 'jobs', 'claimed');
143
+ const claimedFile = path.join(claimedDir, 'claimedRetest.json');
144
+ try {
145
+ const claimedJob = {
146
+ target: {url: 'https://example.com/mixed', what: 'Mixed Outcomes Page'}
147
+ };
148
+ await fs.writeFile(claimedFile, JSON.stringify(claimedJob, null, 2));
149
+ const result = await answer();
150
+ assert.equal(result.status, 'ok');
151
+ assert.ok(result.answerPage.includes('Currently being retested'));
152
+ }
153
+ finally {
154
+ await fs.unlink(claimedFile).catch(() => {});
155
+ }
156
+ });
157
+
158
+ test('listReports shows queued retest status for a report with a matching queued job', async () => {
159
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
160
+ const queueDir = path.join(dbDir, 'jobs', 'queue');
161
+ const queueFile = path.join(queueDir, 'queuedRetest.json');
162
+ try {
163
+ const queuedJob = {
164
+ target: {url: 'https://example.com/mixed', what: 'Mixed Outcomes Page'}
165
+ };
166
+ await fs.writeFile(queueFile, JSON.stringify(queuedJob, null, 2));
167
+ const result = await answer();
168
+ assert.equal(result.status, 'ok');
169
+ assert.ok(result.answerPage.includes('Currently in the queue for retesting'));
170
+ }
171
+ finally {
172
+ await fs.unlink(queueFile).catch(() => {});
173
+ }
174
+ });
175
+
176
+ test('listReports returns an error when a report file is invalid', async () => {
177
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
178
+ const reportsDir = path.join(dbDir, 'reports');
179
+ const invalidReportPath = path.join(reportsDir, '260101T9999-bad.json');
180
+ try {
181
+ // Valid for getReportExtract (has target and jobData.endTime) but invalid for isValidReport (no acts or catalog).
182
+ const invalidReport = {
183
+ target: {what: 'Invalid Report', url: 'https://example.com/invalid'},
184
+ jobData: {endTime: '26-01-01T00:10'}
185
+ };
186
+ await fs.writeFile(invalidReportPath, JSON.stringify(invalidReport, null, 2));
187
+ const result = await answer();
188
+ assert.equal(result.status, 'error');
189
+ assert.ok(result.message);
190
+ }
191
+ finally {
192
+ await fs.unlink(invalidReportPath).catch(() => {});
193
+ }
194
+ });
195
+
196
+ test('listReports shows no-reports message when the database is empty', async () => {
197
+ // Create a temp DB with empty reports and jobs directories.
198
+ const os = require('node:os');
199
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'listreports-empty-'));
200
+ await fs.mkdir(path.join(tmpDir, 'reports'), {recursive: true});
201
+ await fs.mkdir(path.join(tmpDir, 'jobs', 'queue'), {recursive: true});
202
+ await fs.mkdir(path.join(tmpDir, 'jobs', 'claimed'), {recursive: true});
203
+ await fs.mkdir(path.join(tmpDir, 'jobs', 'failed'), {recursive: true});
204
+ await fs.writeFile(path.join(tmpDir, 'jobs', 'recs.json'), '{}\n');
205
+ const savedDBDir = process.env.DB_DIR;
206
+ process.env.DB_DIR = tmpDir;
207
+ try {
208
+ delete require.cache[require.resolve('./index')];
209
+ const {answer} = require('./index');
210
+ const result = await answer();
211
+ assert.equal(result.status, 'ok');
212
+ assert.ok(result.answerPage.includes('no'));
213
+ assert.ok(result.answerPage.includes(' a '));
214
+ }
215
+ finally {
216
+ process.env.DB_DIR = savedDBDir;
217
+ delete require.cache[require.resolve('./index')];
218
+ await fs.rm(tmpDir, {recursive: true}).catch(() => {});
219
+ }
220
+ });
@@ -0,0 +1,103 @@
1
+ /*
2
+ listRules.test.js
3
+ Unit tests for web/listRules/index.js.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const {parse} = require('node-html-parser');
11
+
12
+ // Monkey-patch testaro-issues to add a rule with what === ruleID,
13
+ // covering the dead branch on line 55 of listRules/index.js.
14
+ const testaroIssues = require('testaro-issues');
15
+ const originalRules = testaroIssues.rules;
16
+ const patchEngine = Object.keys(originalRules)[0];
17
+ const patchType = Object.keys(originalRules[patchEngine])[0];
18
+ const patchRuleID = '__testWhatEqualsID__';
19
+ testaroIssues.rules = {
20
+ ...originalRules,
21
+ [patchEngine]: {
22
+ ...originalRules[patchEngine],
23
+ [patchType]: {
24
+ ...originalRules[patchEngine][patchType],
25
+ [patchRuleID]: {what: patchRuleID}
26
+ }
27
+ }
28
+ };
29
+ // Add the patched rule to an existing issue's rule list.
30
+ const patchIssueID = Object.keys(testaroIssues.issueRules)[0];
31
+ const originalIssueRules = testaroIssues.issueRules;
32
+ testaroIssues.issueRules = {
33
+ ...originalIssueRules,
34
+ [patchIssueID]: {
35
+ ...originalIssueRules[patchIssueID],
36
+ [patchEngine]: {
37
+ ...originalIssueRules[patchIssueID][patchEngine],
38
+ [patchType]: [
39
+ ...originalIssueRules[patchIssueID][patchEngine][patchType],
40
+ patchRuleID
41
+ ]
42
+ }
43
+ }
44
+ };
45
+
46
+ const {answer} = require('./index');
47
+
48
+ // TESTS
49
+
50
+ test('listRules returns an error for a nonexistent issue ID', async () => {
51
+ const result = await answer('nonexistentIssue123');
52
+ assert.equal(result.status, 'error');
53
+ assert.equal(result.message, 'Issue not found');
54
+ });
55
+
56
+ test('listRules returns ok with HTML for a valid issue with both invariant and variable rules', async () => {
57
+ // pageLanguage has both invariant and variable rules in nuVal.
58
+ const result = await answer('pageLanguage');
59
+ assert.equal(result.status, 'ok');
60
+ assert.ok(result.answerPage);
61
+ const html = parse(result.answerPage);
62
+ assert.ok(html.querySelector('title'));
63
+ // Both invariant and variable rule sections should be present.
64
+ assert.ok(result.answerPage.includes('invariant rules'));
65
+ assert.ok(result.answerPage.includes('variable rules'));
66
+ });
67
+
68
+ test('listRules returns ok for an issue with only invariant rules', async () => {
69
+ // ignorable has only invariant rules in alfa.
70
+ const result = await answer('ignorable');
71
+ assert.equal(result.status, 'ok');
72
+ assert.ok(result.answerPage);
73
+ // The invariant rules heading should be present.
74
+ assert.ok(result.answerPage.includes('invariant rules'));
75
+ });
76
+
77
+ test('listRules includes the issue why, priority, and wcag in the page', async () => {
78
+ const result = await answer('pageLanguage');
79
+ assert.ok(!result.answerPage.includes('__why__'));
80
+ assert.ok(!result.answerPage.includes('__priority__'));
81
+ assert.ok(!result.answerPage.includes('__wcag__'));
82
+ });
83
+
84
+ test('listRules includes rule descriptions with htmlSafe formatting', async () => {
85
+ const result = await answer('pageLanguage');
86
+ // Rules should be listed as <li> elements with <code> tags.
87
+ assert.ok(result.answerPage.includes('<li><code>'));
88
+ });
89
+
90
+ test('listRules renders a rule without description when what equals ruleID', async () => {
91
+ // The patched issue has a rule where what === ruleID.
92
+ const result = await answer(patchIssueID);
93
+ assert.equal(result.status, 'ok');
94
+ // The rule should appear without a colon-description separator.
95
+ assert.ok(result.answerPage.includes(patchRuleID));
96
+ });
97
+
98
+ test('listRules returns ok for an issue with no rules in issueRules', async () => {
99
+ // docHeadingNotH1 exists in issueSpecs but has no entry in issueRules.
100
+ const result = await answer('docHeadingNotH1');
101
+ assert.equal(result.status, 'ok');
102
+ assert.ok(result.answerPage);
103
+ });
@@ -0,0 +1,126 @@
1
+ /*
2
+ listTopIssues.test.js
3
+ UI tests for web/listTopIssues/index.js using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test, before, after} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const fs = require('node:fs/promises');
12
+ const {parse} = require('node-html-parser');
13
+ const {answer} = require('./index');
14
+
15
+ // SETUP AND TEARDOWN
16
+
17
+ const savedDBDir = process.env.DB_DIR;
18
+
19
+ before(() => {
20
+ process.env.DB_DIR = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
21
+ });
22
+
23
+ after(() => {
24
+ if (savedDBDir !== undefined) {
25
+ process.env.DB_DIR = savedDBDir;
26
+ }
27
+ else {
28
+ delete process.env.DB_DIR;
29
+ }
30
+ });
31
+
32
+ // TESTS
33
+
34
+ test('listTopIssues returns an ok status with valid HTML', async () => {
35
+ const result = await answer();
36
+ assert.equal(result.status, 'ok');
37
+ assert.ok(result.answerPage);
38
+ const html = parse(result.answerPage);
39
+ assert.ok(html.querySelector('title'));
40
+ });
41
+
42
+ test('listTopIssues includes priority headings in the page', async () => {
43
+ const result = await answer();
44
+ assert.equal(result.status, 'ok');
45
+ assert.ok(result.answerPage.includes('priority'));
46
+ });
47
+
48
+ test('listTopIssues returns an error when a report file is invalid', async () => {
49
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
50
+ const reportsDir = path.join(dbDir, 'reports');
51
+ const invalidReportPath = path.join(reportsDir, '260101T9999-bad.json');
52
+ try {
53
+ // Valid for getReportExtract (has target and jobData.endTime) but invalid for isValidReport (no acts or catalog).
54
+ const invalidReport = {
55
+ target: {what: 'Invalid TopIssues Report', url: 'https://example.com/invalidtop'},
56
+ jobData: {endTime: '26-01-01T00:10'}
57
+ };
58
+ await fs.writeFile(invalidReportPath, JSON.stringify(invalidReport, null, 2));
59
+ const result = await answer();
60
+ assert.equal(result.status, 'error');
61
+ assert.ok(result.message);
62
+ }
63
+ finally {
64
+ await fs.unlink(invalidReportPath).catch(() => {});
65
+ }
66
+ });
67
+
68
+ test('listTopIssues handles reports with unclassified issue IDs', async () => {
69
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
70
+ const reportsDir = path.join(dbDir, 'reports');
71
+ const reportPath = path.join(reportsDir, '260101T0004-unc.json');
72
+ try {
73
+ const report = {
74
+ id: '260101T0004-unc',
75
+ what: 'Unclassified Issue Page',
76
+ target: {what: 'Unclassified Issue Page', url: 'https://example.com/unclassified'},
77
+ acts: [
78
+ {
79
+ type: 'test',
80
+ which: 'axe',
81
+ result: {
82
+ standardResult: {
83
+ instances: [
84
+ {
85
+ ruleID: 'r99',
86
+ what: 'Some unclassified issue',
87
+ outcome: 'failed',
88
+ count: 1,
89
+ catalogIndex: '0',
90
+ issueID: 'nonexistentIssue'
91
+ }
92
+ ]
93
+ }
94
+ }
95
+ }
96
+ ],
97
+ jobData: {
98
+ startTime: '26-01-01T00:00',
99
+ endTime: '26-01-01T00:10',
100
+ preventions: {},
101
+ issuelessRules: []
102
+ },
103
+ catalog: {
104
+ '0': {
105
+ tagName: 'DIV',
106
+ id: '',
107
+ startTag: '<div>',
108
+ text: '',
109
+ textLinkable: false,
110
+ boxID: '0:0:100:50',
111
+ pathID: '/html/body/div[1]'
112
+ }
113
+ },
114
+ images: {},
115
+ checkpoints: []
116
+ };
117
+ await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
118
+ const result = await answer();
119
+ // The unclassified issue is skipped, but the page should still render successfully.
120
+ assert.equal(result.status, 'ok');
121
+ assert.ok(result.answerPage);
122
+ }
123
+ finally {
124
+ await fs.unlink(reportPath).catch(() => {});
125
+ }
126
+ });
@@ -84,8 +84,7 @@ const populateQuery = async (issueID, timeStamp, jobID, query) => {
84
84
  const pathID = instance.pathID || '/html';
85
85
  const catalogIndex = instance.catalogIndex || '0';
86
86
  const tagName = catalog[catalogIndex]?.tagName
87
- ?? pathID?.split('/').pop().replace(/\[.+$/, '').toUpperCase()
88
- ?? 'HTML';
87
+ ?? pathID.split('/').pop().replace(/\[.+$/, '').toUpperCase();
89
88
  violators[catalogIndex] ??= {
90
89
  pathID: getPathID(catalog, catalogIndex, pathID),
91
90
  tagName,
@@ -8,7 +8,31 @@
8
8
  const {test, before, after} = require('node:test');
9
9
  const assert = require('node:assert/strict');
10
10
  const path = require('node:path');
11
+ const fs = require('node:fs/promises');
11
12
  const {parse} = require('node-html-parser');
13
+
14
+ // Monkey-patch util functions before requiring index, so that index.js
15
+ // destructures the patched versions.
16
+ const util = require('../../util');
17
+ const realGetReport = util.getReport;
18
+ const realGetPageDataStrings = util.getPageDataStrings;
19
+ let getReportCallCount = 0;
20
+ let failGetReportOnCall = -1;
21
+ let pageDataStringsOverride = null;
22
+ util.getReport = async (...args) => {
23
+ getReportCallCount++;
24
+ if (getReportCallCount === failGetReportOnCall) {
25
+ return {error: 'Report is invalid (test override)'};
26
+ }
27
+ return realGetReport(...args);
28
+ };
29
+ util.getPageDataStrings = async (...args) => {
30
+ if (pageDataStringsOverride !== null) {
31
+ return pageDataStringsOverride;
32
+ }
33
+ return realGetPageDataStrings(...args);
34
+ };
35
+
12
36
  const {answer} = require('./index');
13
37
 
14
38
  // SETUP AND TEARDOWN
@@ -64,3 +88,235 @@ test('listViolators returns an error status for an unknown issue', async () => {
64
88
  const result = await answer('nonexistentIssue/260101T0000/mix');
65
89
  assert.equal(result.status, 'error');
66
90
  });
91
+
92
+ test('listViolators returns an error when getReport fails after getPageDataStrings succeeds', async () => {
93
+ getReportCallCount = 0;
94
+ failGetReportOnCall = 1;
95
+ try {
96
+ const result = await answer('linkNoText/260101T0000/mix');
97
+ assert.equal(result.status, 'error');
98
+ assert.ok(result.message.includes('invalid'));
99
+ }
100
+ finally {
101
+ failGetReportOnCall = -1;
102
+ }
103
+ });
104
+
105
+ test('listViolators includes take-me-there links for text-linkable violators', async () => {
106
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
107
+ const reportsDir = path.join(dbDir, 'reports');
108
+ const reportPath = path.join(reportsDir, '260101T0003-tlk.json');
109
+ try {
110
+ const report = {
111
+ id: '260101T0003-tlk',
112
+ what: 'Text Linkable Page',
113
+ target: {what: 'Text Linkable Page', url: 'https://example.com/textlinkable'},
114
+ acts: [
115
+ {
116
+ type: 'test',
117
+ which: 'axe',
118
+ result: {
119
+ standardResult: {
120
+ instances: [
121
+ {
122
+ ruleID: 'r11',
123
+ what: 'The link does not have an accessible name',
124
+ outcome: 'failed',
125
+ count: 1,
126
+ catalogIndex: '0',
127
+ issueID: 'linkNoText'
128
+ }
129
+ ]
130
+ }
131
+ }
132
+ }
133
+ ],
134
+ jobData: {
135
+ startTime: '26-01-01T00:00',
136
+ endTime: '26-01-01T00:10',
137
+ preventions: {},
138
+ issuelessRules: []
139
+ },
140
+ catalog: {
141
+ '0': {
142
+ tagName: 'A',
143
+ id: '',
144
+ startTag: '<a>',
145
+ text: 'Click here',
146
+ textLinkable: true,
147
+ boxID: '10:20:80:30',
148
+ pathID: '/html/body/a[1]'
149
+ }
150
+ },
151
+ images: {},
152
+ checkpoints: []
153
+ };
154
+ await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
155
+ const result = await answer('linkNoText/260101T0003/tlk');
156
+ assert.equal(result.status, 'ok');
157
+ assert.ok(result.answerPage.includes('Take me there'));
158
+ assert.ok(result.answerPage.includes('Take me there</q> links will open'));
159
+ }
160
+ finally {
161
+ await fs.unlink(reportPath).catch(() => {});
162
+ }
163
+ });
164
+
165
+ test('listViolators returns an error when report facts are not obtained', async () => {
166
+ pageDataStringsOverride = {
167
+ what: 'Mixed Outcomes Page',
168
+ url: 'https://example.com/mixed',
169
+ urlLink: '<a href="https://example.com/mixed">https://example.com/mixed</a>'
170
+ };
171
+ try {
172
+ const result = await answer('linkNoText/260101T0000/mix');
173
+ assert.equal(result.status, 'error');
174
+ assert.equal(result.message, 'Report facts not obtained');
175
+ }
176
+ finally {
177
+ pageDataStringsOverride = null;
178
+ }
179
+ });
180
+
181
+ test('listViolators handles instances with missing catalogIndex and acts with no standardResult', async () => {
182
+ // Create a temporary report with:
183
+ // - An act that has no standardResult (covers ?? [] on line 77)
184
+ // - An instance with no catalogIndex (covers || '0' on line 85)
185
+ // - A catalog entry with no tagName (covers ?? pathID fallback on lines 87-88)
186
+ // - Multiple violators (covers plural count on line 101)
187
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
188
+ const reportPath = path.join(dbDir, 'reports', '260101T0004-nvi.json');
189
+ const report = {
190
+ id: '260101T0004-nvi',
191
+ target: {what: 'No Violator Info Page', url: 'https://example.com/nvi'},
192
+ acts: [
193
+ // Act with no standardResult (covers ?? [] fallback).
194
+ {type: 'test', which: 'alfa', result: {}},
195
+ // Act with instances that have missing catalogIndex.
196
+ {
197
+ type: 'test',
198
+ which: 'axe',
199
+ result: {
200
+ standardResult: {
201
+ instances: [
202
+ {
203
+ ruleID: 'r11',
204
+ what: 'The link does not have an accessible name',
205
+ outcome: 'failed',
206
+ issueID: 'linkNoText',
207
+ pathID: '/html/body/a[1]'
208
+ // No catalogIndex: defaults to '0'
209
+ },
210
+ {
211
+ ruleID: 'r12',
212
+ what: 'Another link without a name',
213
+ outcome: 'failed',
214
+ issueID: 'linkNoText',
215
+ pathID: '/html/body/div[1]/span[2]',
216
+ catalogIndex: '1'
217
+ }
218
+ ]
219
+ }
220
+ }
221
+ }
222
+ ],
223
+ jobData: {
224
+ startTime: '26-01-01T00:00',
225
+ endTime: '26-01-01T00:10',
226
+ preventions: {},
227
+ issuelessRules: []
228
+ },
229
+ catalog: {
230
+ '0': {
231
+ // No tagName: covers ?? pathID fallback.
232
+ id: '',
233
+ text: 'Click here',
234
+ pathID: '/html/body/a[1]'
235
+ },
236
+ '1': {
237
+ tagName: 'SPAN',
238
+ id: '',
239
+ text: 'More text',
240
+ pathID: '/html/body/div[1]/span[2]'
241
+ }
242
+ },
243
+ images: {},
244
+ checkpoints: []
245
+ };
246
+ await fs.writeFile(reportPath, JSON.stringify(report));
247
+ try {
248
+ const result = await answer('linkNoText/260101T0004/nvi');
249
+ assert.equal(result.status, 'ok');
250
+ // Should show plural violator count.
251
+ assert.ok(result.answerPage.includes('2 violators were'));
252
+ }
253
+ finally {
254
+ await fs.unlink(reportPath).catch(() => {});
255
+ }
256
+ });
257
+
258
+ test('listViolators uses HTML fallback when catalog has no tagName and pathID is missing', async () => {
259
+ // Create a report where an instance has no pathID and the catalog
260
+ // entry has no tagName, so the ?? 'HTML' fallback is hit.
261
+ // Also uses a catalogIndex not in the catalog to cover || {} on line 144.
262
+ const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
263
+ const reportPath = path.join(dbDir, 'reports', '260101T0005-htm.json');
264
+ const report = {
265
+ id: '260101T0005-htm',
266
+ target: {what: 'HTML Fallback Page', url: 'https://example.com/htm'},
267
+ acts: [
268
+ {
269
+ type: 'test',
270
+ which: 'axe',
271
+ result: {
272
+ standardResult: {
273
+ instances: [
274
+ {
275
+ ruleID: 'r11',
276
+ what: 'The link does not have an accessible name',
277
+ outcome: 'failed',
278
+ issueID: 'linkNoText',
279
+ catalogIndex: '0'
280
+ // No pathID: triggers ?? 'HTML' fallback
281
+ },
282
+ {
283
+ ruleID: 'r12',
284
+ what: 'Another violation',
285
+ outcome: 'failed',
286
+ issueID: 'linkNoText',
287
+ catalogIndex: '99'
288
+ // catalogIndex '99' is not in the catalog, covers || {} on line 144
289
+ }
290
+ ]
291
+ }
292
+ }
293
+ }
294
+ ],
295
+ jobData: {
296
+ startTime: '26-01-01T00:00',
297
+ endTime: '26-01-01T00:10',
298
+ preventions: {},
299
+ issuelessRules: []
300
+ },
301
+ catalog: {
302
+ '0': {
303
+ // No tagName and no pathID: both fallbacks are nullish.
304
+ id: '',
305
+ text: 'Some text'
306
+ }
307
+ // Note: no '99' entry, so catalog['99'] is undefined.
308
+ },
309
+ images: {},
310
+ checkpoints: []
311
+ };
312
+ await fs.writeFile(reportPath, JSON.stringify(report));
313
+ try {
314
+ const result = await answer('linkNoText/260101T0005/htm');
315
+ assert.equal(result.status, 'ok');
316
+ // The tagName should fall back to HTML.
317
+ assert.ok(result.answerPage.includes('HTML'));
318
+ }
319
+ finally {
320
+ await fs.unlink(reportPath).catch(() => {});
321
+ }
322
+ });