Skip to content

Commit 2b58226

Browse files
committed
feat(plugin-axe): add source field to issues
1 parent 5173f00 commit 2b58226

File tree

5 files changed

+115
-47
lines changed

5 files changed

+115
-47
lines changed

e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,11 @@ exports[`PLUGIN collect report with axe-plugin NPM package > should run plugin o
499499
{
500500
"message": "[\`body > button\`] Fix any of the following: Element does not have inner text that is visible to screen readers aria-label attribute does not exist or is empty aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty Element has no title attribute Element does not have an implicit (wrapped) <label> Element does not have an explicit <label> Element's default semantics were not overridden with role="none" or role="presentation"",
501501
"severity": "error",
502+
"source": {
503+
"selector": "body > button",
504+
"snippet": "<button></button>",
505+
"url": "file:///<TEST_DIR>/index.html",
506+
},
502507
},
503508
],
504509
},
@@ -525,6 +530,13 @@ exports[`PLUGIN collect report with axe-plugin NPM package > should run plugin o
525530
{
526531
"message": "[\`.low-contrast\`] Fix any of the following: Element has insufficient color contrast of 1.57 (foreground color: #777777, background color: #999999, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1",
527532
"severity": "error",
533+
"source": {
534+
"selector": ".low-contrast",
535+
"snippet": "<div class=\"low-contrast\">
536+
This text has poor color contrast and may be hard to read.
537+
</div>",
538+
"url": "file:///<TEST_DIR>/index.html",
539+
},
528540
},
529541
],
530542
},
@@ -614,6 +626,13 @@ exports[`PLUGIN collect report with axe-plugin NPM package > should run plugin o
614626
{
615627
"message": "[\`div[role="button"]\`] Fix any of the following: Invalid ARIA attribute name: aria-invalid-attribute",
616628
"severity": "error",
629+
"source": {
630+
"selector": "div[role=\"button\"]",
631+
"snippet": "<div role=\"button\" aria-invalid-attribute=\"true\">
632+
Button with invalid ARIA attribute
633+
</div>",
634+
"url": "file:///<TEST_DIR>/index.html",
635+
},
617636
},
618637
],
619638
},
@@ -631,6 +650,11 @@ exports[`PLUGIN collect report with axe-plugin NPM package > should run plugin o
631650
{
632651
"message": "[\`img\`] Fix any of the following: Element does not have an alt attribute aria-label attribute does not exist or is empty aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty Element has no title attribute Element's default semantics were not overridden with role="none" or role="presentation"",
633652
"severity": "error",
653+
"source": {
654+
"selector": "img",
655+
"snippet": "<img src=\"test-image.jpg\" width=\"200\" height=\"150\">",
656+
"url": "file:///<TEST_DIR>/index.html",
657+
},
634658
},
635659
],
636660
},
@@ -648,6 +672,11 @@ exports[`PLUGIN collect report with axe-plugin NPM package > should run plugin o
648672
{
649673
"message": "[\`a\`] Fix all of the following: Element is in tab order and does not have accessible text Fix any of the following: Element does not have text that is visible to screen readers aria-label attribute does not exist or is empty aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty Element has no title attribute",
650674
"severity": "error",
675+
"source": {
676+
"selector": "a",
677+
"snippet": "<a href=\"#\"></a>",
678+
"url": "file:///<TEST_DIR>/index.html",
679+
},
651680
},
652681
],
653682
},

e2e/plugin-axe-e2e/tests/collect.e2e.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import {
1212
} from '@code-pushup/test-utils';
1313
import { executeProcess, readJsonFile } from '@code-pushup/utils';
1414

15+
function sanitizeReportPaths(report: Report): Report {
16+
const reportJson = JSON.stringify(report);
17+
const sanitized = reportJson.replace(
18+
/\/(?:[^/\s"]+\/)+index\.html/g,
19+
'/<TEST_DIR>/index.html',
20+
);
21+
return JSON.parse(sanitized);
22+
}
23+
1524
describe('PLUGIN collect report with axe-plugin NPM package', () => {
1625
const fixturesDir = path.join('e2e', nxTargetProject(), 'mocks', 'fixtures');
1726
const testFileDir = path.join(
@@ -44,6 +53,8 @@ describe('PLUGIN collect report with axe-plugin NPM package', () => {
4453
);
4554

4655
expect(() => reportSchema.parse(report)).not.toThrow();
47-
expect(omitVariableReportData(report)).toMatchSnapshot();
56+
expect(
57+
omitVariableReportData(sanitizeReportPaths(report)),
58+
).toMatchSnapshot();
4859
});
4960
});

packages/plugin-axe/src/lib/runner/run-axe.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
pluralizeToken,
1919
} from '@code-pushup/utils';
2020
import { type SetupFunction, runSetup } from './setup.js';
21-
import { createUrlSuffix, toAuditOutputs } from './transform.js';
21+
import { toAuditOutputs } from './transform.js';
2222

2323
export type AxeUrlArgs = {
2424
url: string;
@@ -58,10 +58,7 @@ export class AxeRunner {
5858
const page = await context.newPage();
5959
try {
6060
const axeResults = await analyzePage(page, args);
61-
const auditOutputs = toAuditOutputs(
62-
axeResults,
63-
createUrlSuffix(url, urlsCount),
64-
);
61+
const auditOutputs = toAuditOutputs(axeResults, url);
6562
return {
6663
message: `${prefix} Analyzed URL ${url}`,
6764
result: { url, axeResults, auditOutputs },

packages/plugin-axe/src/lib/runner/transform.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import type {
44
AuditOutputs,
55
Issue,
66
IssueSeverity,
7+
SourceUrlLocation,
78
} from '@code-pushup/models';
89
import {
910
formatIssueSeverities,
10-
getUrlIdentifier,
1111
pluralizeToken,
1212
truncateIssueMessage,
1313
} from '@code-pushup/utils';
@@ -18,10 +18,10 @@ import {
1818
*/
1919
export function toAuditOutputs(
2020
{ passes, violations, incomplete, inapplicable }: axe.AxeResults,
21-
urlSuffix: string,
21+
url: string,
2222
): AuditOutputs {
2323
const toEntries = (results: axe.Result[], score: number) =>
24-
results.map(res => [res.id, toAuditOutput(res, urlSuffix, score)] as const);
24+
results.map(res => [res.id, toAuditOutput(res, url, score)] as const);
2525

2626
return [
2727
...new Map<string, AuditOutput>([
@@ -33,18 +33,13 @@ export function toAuditOutputs(
3333
];
3434
}
3535

36-
/** Creates a URL suffix for issue messages, only included when analyzing multiple URLs. */
37-
export function createUrlSuffix(url: string, urlsCount: number): string {
38-
return urlsCount > 1 ? ` ([${getUrlIdentifier(url)}](${url}))` : '';
39-
}
40-
4136
/**
4237
* For failing audits (score 0), includes detailed issues with locations and severities.
4338
* For passing audits (score 1), only includes element count.
4439
*/
4540
function toAuditOutput(
4641
result: axe.Result,
47-
urlSuffix: string,
42+
url: string,
4843
score: number,
4944
): AuditOutput {
5045
const base = {
@@ -54,7 +49,7 @@ function toAuditOutput(
5449
};
5550

5651
if (score === 0 && result.nodes.length > 0) {
57-
const issues = result.nodes.map(node => toIssue(node, result, urlSuffix));
52+
const issues = result.nodes.map(node => toIssue(node, result, url));
5853

5954
return {
6055
...base,
@@ -76,20 +71,26 @@ function formatSelector(selector: axe.CrossTreeSelector): string {
7671
return selector.join(' >> ');
7772
}
7873

79-
function toIssue(
80-
node: axe.NodeResult,
81-
result: axe.Result,
82-
urlSuffix: string,
83-
): Issue {
84-
const selector = formatSelector(node.target?.[0] || node.html);
74+
function toIssue(node: axe.NodeResult, result: axe.Result, url: string): Issue {
75+
const selector = node.target?.[0]
76+
? formatSelector(node.target[0])
77+
: undefined;
8578
const rawMessage = node.failureSummary || result.help;
86-
const cleanedMessage = rawMessage.replace(/\s+/g, ' ').trim();
79+
const cleanMessage = rawMessage.replace(/\s+/g, ' ').trim();
80+
81+
// TODO: Remove selector prefix from message once Portal supports URL sources
82+
const message = selector ? `[\`${selector}\`] ${cleanMessage}` : cleanMessage;
83+
84+
const source: SourceUrlLocation = {
85+
url,
86+
...(node.html && { snippet: node.html }),
87+
...(selector && { selector }),
88+
};
8789

8890
return {
89-
message: truncateIssueMessage(
90-
`[\`${selector}\`] ${cleanedMessage}${urlSuffix}`,
91-
),
91+
message: truncateIssueMessage(message),
9292
severity: impactToSeverity(node.impact),
93+
source,
9394
};
9495
}
9596

packages/plugin-axe/src/lib/runner/transform.unit.test.ts

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { AxeResults, NodeResult, Result } from 'axe-core';
22
import { describe, expect, it } from 'vitest';
33
import type { AuditOutput } from '@code-pushup/models';
4-
import { createUrlSuffix, toAuditOutputs } from './transform.js';
4+
import { toAuditOutputs } from './transform.js';
55

66
function createMockNode(overrides: Partial<NodeResult> = {}): NodeResult {
77
return {
@@ -44,7 +44,7 @@ describe('toAuditOutputs', () => {
4444
],
4545
});
4646

47-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
47+
expect(toAuditOutputs(results, '')).toStrictEqual<AuditOutput[]>([
4848
{
4949
slug: 'color-contrast',
5050
score: 1,
@@ -79,7 +79,9 @@ describe('toAuditOutputs', () => {
7979
],
8080
});
8181

82-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
82+
expect(toAuditOutputs(results, 'https://example.com')).toStrictEqual<
83+
AuditOutput[]
84+
>([
8385
{
8486
slug: 'image-alt',
8587
score: 0,
@@ -91,15 +93,30 @@ describe('toAuditOutputs', () => {
9193
message:
9294
'[`img`] Fix this: Element does not have an alt attribute',
9395
severity: 'error',
96+
source: {
97+
url: 'https://example.com',
98+
snippet: '<img src="logo.png">',
99+
selector: 'img',
100+
},
94101
},
95102
{
96103
message:
97104
'[`.header > img:nth-child(2)`] Fix this: Element does not have an alt attribute',
98105
severity: 'error',
106+
source: {
107+
url: 'https://example.com',
108+
snippet: '<img src="icon.svg">',
109+
selector: '.header > img:nth-child(2)',
110+
},
99111
},
100112
{
101113
message: '[`#main img`] Mock help for image-alt',
102114
severity: 'error',
115+
source: {
116+
url: 'https://example.com',
117+
snippet: '<img src="banner.jpg">',
118+
selector: '#main img',
119+
},
103120
},
104121
],
105122
},
@@ -127,7 +144,9 @@ describe('toAuditOutputs', () => {
127144
],
128145
});
129146

130-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
147+
expect(toAuditOutputs(results, 'https://example.com')).toStrictEqual<
148+
AuditOutput[]
149+
>([
131150
{
132151
slug: 'color-contrast',
133152
score: 0,
@@ -139,10 +158,20 @@ describe('toAuditOutputs', () => {
139158
message:
140159
'[`button`] Fix this: Element has insufficient color contrast',
141160
severity: 'warning',
161+
source: {
162+
url: 'https://example.com',
163+
snippet: '<button>Click me</button>',
164+
selector: 'button',
165+
},
142166
},
143167
{
144168
message: '[`a`] Review: Unable to determine contrast ratio',
145169
severity: 'warning',
170+
source: {
171+
url: 'https://example.com',
172+
snippet: '<a href="#">Link</a>',
173+
selector: 'a',
174+
},
146175
},
147176
],
148177
},
@@ -155,7 +184,7 @@ describe('toAuditOutputs', () => {
155184
inapplicable: [createMockResult('audio-caption', [])],
156185
});
157186

158-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
187+
expect(toAuditOutputs(results, '')).toStrictEqual<AuditOutput[]>([
159188
{
160189
slug: 'audio-caption',
161190
score: 1,
@@ -239,7 +268,9 @@ describe('toAuditOutputs', () => {
239268
],
240269
});
241270

242-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
271+
expect(toAuditOutputs(results, 'https://example.com')).toStrictEqual<
272+
AuditOutput[]
273+
>([
243274
{
244275
slug: 'color-contrast',
245276
score: 0,
@@ -251,14 +282,19 @@ describe('toAuditOutputs', () => {
251282
message:
252283
'[`#app >> my-component >> button`] Fix this: Element has insufficient color contrast',
253284
severity: 'error',
285+
source: {
286+
url: 'https://example.com',
287+
snippet: '<button></button>',
288+
selector: '#app >> my-component >> button',
289+
},
254290
},
255291
],
256292
},
257293
},
258294
]);
259295
});
260296

261-
it('should fall back to html when target is missing', () => {
297+
it('should omit selector when target is missing', () => {
262298
const results = createMockAxeResults({
263299
violations: [
264300
createMockResult('aria-roles', [
@@ -273,7 +309,9 @@ describe('toAuditOutputs', () => {
273309
],
274310
});
275311

276-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
312+
expect(toAuditOutputs(results, 'https://example.com')).toStrictEqual<
313+
AuditOutput[]
314+
>([
277315
{
278316
slug: 'aria-roles',
279317
score: 0,
@@ -283,24 +321,16 @@ describe('toAuditOutputs', () => {
283321
issues: [
284322
{
285323
message:
286-
'[`<div role="invalid-role">Content</div>`] Fix this: Ensure all values assigned to role="" correspond to valid ARIA roles',
324+
'Fix this: Ensure all values assigned to role="" correspond to valid ARIA roles',
287325
severity: 'error',
326+
source: {
327+
url: 'https://example.com',
328+
snippet: '<div role="invalid-role">Content</div>',
329+
},
288330
},
289331
],
290332
},
291333
},
292334
]);
293335
});
294336
});
295-
296-
describe('createUrlSuffix', () => {
297-
it('should return empty string for single URL', () => {
298-
expect(createUrlSuffix('https://example.com', 1)).toBe('');
299-
});
300-
301-
it('should return formatted suffix for multiple URLs', () => {
302-
expect(createUrlSuffix('https://example.com', 2)).toBe(
303-
' ([example.com](https://example.com))',
304-
);
305-
});
306-
});

0 commit comments

Comments
 (0)