Skip to content

Commit fe6f94e

Browse files
committed
feat(plugin-axe): add source field to issues
1 parent 4ab8c73 commit fe6f94e

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
@@ -11,6 +11,15 @@ import {
1111
} from '@code-pushup/test-utils';
1212
import { executeProcess, readJsonFile } from '@code-pushup/utils';
1313

14+
function sanitizeReportPaths(report: Report): Report {
15+
const reportJson = JSON.stringify(report);
16+
const sanitized = reportJson.replace(
17+
/\/(?:[^/\s"]+\/)+index\.html/g,
18+
'/<TEST_DIR>/index.html',
19+
);
20+
return JSON.parse(sanitized);
21+
}
22+
1423
describe('PLUGIN collect report with axe-plugin NPM package', () => {
1524
const fixturesDir = path.join(
1625
'e2e',
@@ -49,6 +58,8 @@ describe('PLUGIN collect report with axe-plugin NPM package', () => {
4958
);
5059

5160
expect(() => reportSchema.parse(report)).not.toThrow();
52-
expect(omitVariableReportData(report)).toMatchSnapshot();
61+
expect(
62+
omitVariableReportData(sanitizeReportPaths(report)),
63+
).toMatchSnapshot();
5364
});
5465
});

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,6 +1,6 @@
11
import type { AxeResults, NodeResult, Result } from 'axe-core';
22
import type { AuditOutput } from '@code-pushup/models';
3-
import { createUrlSuffix, toAuditOutputs } from './transform.js';
3+
import { toAuditOutputs } from './transform.js';
44

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

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

81-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
81+
expect(toAuditOutputs(results, 'https://example.com')).toStrictEqual<
82+
AuditOutput[]
83+
>([
8284
{
8385
slug: 'image-alt',
8486
score: 0,
@@ -90,15 +92,30 @@ describe('toAuditOutputs', () => {
9092
message:
9193
'[`img`] Fix this: Element does not have an alt attribute',
9294
severity: 'error',
95+
source: {
96+
url: 'https://example.com',
97+
snippet: '<img src="logo.png">',
98+
selector: 'img',
99+
},
93100
},
94101
{
95102
message:
96103
'[`.header > img:nth-child(2)`] Fix this: Element does not have an alt attribute',
97104
severity: 'error',
105+
source: {
106+
url: 'https://example.com',
107+
snippet: '<img src="icon.svg">',
108+
selector: '.header > img:nth-child(2)',
109+
},
98110
},
99111
{
100112
message: '[`#main img`] Mock help for image-alt',
101113
severity: 'error',
114+
source: {
115+
url: 'https://example.com',
116+
snippet: '<img src="banner.jpg">',
117+
selector: '#main img',
118+
},
102119
},
103120
],
104121
},
@@ -126,7 +143,9 @@ describe('toAuditOutputs', () => {
126143
],
127144
});
128145

129-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
146+
expect(toAuditOutputs(results, 'https://example.com')).toStrictEqual<
147+
AuditOutput[]
148+
>([
130149
{
131150
slug: 'color-contrast',
132151
score: 0,
@@ -138,10 +157,20 @@ describe('toAuditOutputs', () => {
138157
message:
139158
'[`button`] Fix this: Element has insufficient color contrast',
140159
severity: 'warning',
160+
source: {
161+
url: 'https://example.com',
162+
snippet: '<button>Click me</button>',
163+
selector: 'button',
164+
},
141165
},
142166
{
143167
message: '[`a`] Review: Unable to determine contrast ratio',
144168
severity: 'warning',
169+
source: {
170+
url: 'https://example.com',
171+
snippet: '<a href="#">Link</a>',
172+
selector: 'a',
173+
},
145174
},
146175
],
147176
},
@@ -154,7 +183,7 @@ describe('toAuditOutputs', () => {
154183
inapplicable: [createMockResult('audio-caption', [])],
155184
});
156185

157-
expect(toAuditOutputs(results, '')).toEqual<AuditOutput[]>([
186+
expect(toAuditOutputs(results, '')).toStrictEqual<AuditOutput[]>([
158187
{
159188
slug: 'audio-caption',
160189
score: 1,
@@ -238,7 +267,9 @@ describe('toAuditOutputs', () => {
238267
],
239268
});
240269

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

260-
it('should fall back to html when target is missing', () => {
296+
it('should omit selector when target is missing', () => {
261297
const results = createMockAxeResults({
262298
violations: [
263299
createMockResult('aria-roles', [
@@ -272,7 +308,9 @@ describe('toAuditOutputs', () => {
272308
],
273309
});
274310

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

0 commit comments

Comments
 (0)