Skip to content

Commit bbd3b10

Browse files
refactor: Axe - highlight violation in screencaps, hover state tests, multiviewports
1 parent d55fe50 commit bbd3b10

File tree

3 files changed

+176
-34
lines changed

3 files changed

+176
-34
lines changed

src/css/base/structure.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ body {
2626
svg:has(symbol) {
2727
@apply absolute h-0 w-0;
2828
}
29+
30+
/* ADA testing */
31+
.ada-violation {
32+
@apply outline-3 outline-dashed outline-red-500;
33+
}

tests/axe-test.spec.ts

Lines changed: 161 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,30 @@ import path from 'path';
55
import fs from 'fs';
66
import { processSitemap } from './utils/process-sitemap';
77

8+
const viewports = [
9+
{ name: 'mobile', width: 375, height: 667 },
10+
{ name: 'tablet', width: 768, height: 1024 },
11+
{ name: 'desktop', width: 1280, height: 720 },
12+
];
13+
814
test.describe('Accessibility tests', () => {
915
let urls: string[] = [];
10-
let totalPages = 0;
16+
let sitemapCounts: Record<string, number> = {};
1117
const combinedResults: Array<{
1218
url: string;
1319
violations: any[];
1420
screenshot?: string;
21+
viewport: string;
22+
state?: string;
1523
}> = [];
1624

1725
test.beforeAll(async ({ baseURL }) => {
18-
const sitemapUrl = `${baseURL}/sitemap_index.xml`;
19-
// const sitemapUrl = `${baseURL}/page-sitemap.xml`;
20-
// const sitemapUrl = `${baseURL}/post-sitemap.xml`;
21-
urls = await processSitemap(sitemapUrl);
22-
totalPages = urls.length;
26+
// Get sitemap index
27+
const { urls: allUrls, counts } = await processSitemap(
28+
`${baseURL}/sitemap_index.xml`
29+
);
30+
urls = allUrls;
31+
sitemapCounts = counts;
2332

2433
const screenshotsDir = path.resolve('./test-results/screenshots');
2534
if (!fs.existsSync(screenshotsDir)) {
@@ -28,12 +37,6 @@ test.describe('Accessibility tests', () => {
2837
});
2938

3039
test('Should not have accessibility issues', async ({ page }) => {
31-
const viewports = [
32-
{ name: 'mobile', width: 375, height: 667 },
33-
{ name: 'tablet', width: 768, height: 1024 },
34-
{ name: 'desktop', width: 1280, height: 720 },
35-
];
36-
3740
for (const viewport of viewports) {
3841
await test.step(`Viewport: ${viewport.name}`, async () => {
3942
await page.setViewportSize({ width: viewport.width, height: viewport.height });
@@ -53,22 +56,122 @@ test.describe('Accessibility tests', () => {
5356
console.log('URL is: ' + url);
5457

5558
if (results.violations.length > 0) {
59+
// Add ada-violation class to highlight each offending node
60+
for (const violation of results.violations) {
61+
for (const node of violation.nodes) {
62+
const selector = node.target.join(' ');
63+
await page.$eval(selector, el => el.classList.add('ada-violation')).catch(() => { });
64+
}
65+
}
66+
5667
// Capture screenshot
5768
const screenshotPath = path.resolve(
5869
'./test-results/screenshots',
5970
`axe-violation-screenshot-${Date.now()}.png`
6071
);
61-
6272
await page.screenshot({ path: screenshotPath, fullPage: true });
6373

74+
// Remove ada-violation highlight
75+
for (const violation of results.violations) {
76+
for (const node of violation.nodes) {
77+
const selector = node.target.join(' ');
78+
await page.$eval(selector, el => el.classList.remove('ada-violation')).catch(() => { });
79+
}
80+
}
81+
6482
combinedResults.push({
6583
url,
6684
violations: results.violations,
6785
screenshot: screenshotPath,
86+
viewport: viewport.name,
87+
state: 'initial',
6888
});
6989
} else {
7090
console.log('Nothing found.');
7191
}
92+
93+
// Hover tests, desktop only
94+
if (viewport.name === 'desktop') {
95+
// Find hoverable elements
96+
const hoverSelectors = await page.$$eval(
97+
'button, a, [role="button"], [role="link"], [tabindex]',
98+
elements =>
99+
elements
100+
.filter(el => el.offsetParent !== null && !el.disabled)
101+
.map(el => {
102+
if (el.id) return `#${CSS.escape(el.id)}`;
103+
if (el.className) {
104+
return (
105+
'.' +
106+
el.className
107+
.trim()
108+
.split(/\s+/)
109+
.map(cls => CSS.escape(cls))
110+
.join('.')
111+
);
112+
}
113+
return el.tagName.toLowerCase();
114+
})
115+
);
116+
117+
console.log(`Found ${hoverSelectors.length} hoverable elements.`);
118+
let nextId = combinedResults.length + 1;
119+
120+
// Hover each and test
121+
for (const selector of hoverSelectors) {
122+
await test.step(`Hover: ${selector}`, async () => {
123+
console.log(`Hovering: ${selector}`);
124+
125+
const el = await page.$(selector);
126+
if (!el) {
127+
console.log(`Element ${selector} not found — skipping.`);
128+
return;
129+
}
130+
131+
await el.hover();
132+
await page.waitForTimeout(300);
133+
134+
const hoverResults = await new AxeBuilder({ page })
135+
.include(selector)
136+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
137+
.analyze();
138+
139+
console.log(
140+
`Violations for hover ${selector}: ${hoverResults.violations.length}`
141+
);
142+
143+
if (hoverResults.violations.length > 0) {
144+
// Add ada-violation highlight class to element
145+
await el.evaluate(node => node.classList.add('ada-violation'));
146+
147+
const screenshotPath = path.resolve(
148+
'./test-results/screenshots',
149+
`axe-hover-${selector.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.png`
150+
);
151+
await page.screenshot({ path: screenshotPath, fullPage: true });
152+
153+
// Remove ada-violation highlight class
154+
await el.evaluate(node => node.classList.remove('ada-violation'));
155+
156+
hoverResults.violations.forEach(v => {
157+
v.reportId = nextId++;
158+
v.state = 'hover';
159+
v.viewport = viewport.name;
160+
});
161+
162+
combinedResults.push({
163+
url,
164+
violations: hoverResults.violations,
165+
screenshot: screenshotPath,
166+
viewport: viewport.name,
167+
state: 'hover',
168+
});
169+
170+
console.log(`Added hover result for ${selector}`);
171+
}
172+
});
173+
}
174+
}
72175
});
73176
}
74177
});
@@ -94,26 +197,13 @@ test.describe('Accessibility tests', () => {
94197

95198
let screenshotSummary = '';
96199

97-
// Add total pages scanned
98-
screenshotSummary += `<p>Scanned <strong>${totalPages}</strong> URLs</p>\n`;
99-
100-
// Open <ul>
101-
screenshotSummary += '<ul>\n';
102-
103-
for (let i = 0; i < combinedResults.length; i++) {
104-
const result = combinedResults[i];
105-
if (result.screenshot) {
106-
const relPath = path.relative(reportDirPath, result.screenshot);
107-
const name = path.basename(result.screenshot);
108-
109-
// Add li
110-
screenshotSummary += `<li><a href="${relPath}">${i + 1}. ${name}</a></li>\n`;
200+
// Get sitemap + viewport info
201+
for (const viewport of viewports) {
202+
for (const sitemapUrl of Object.keys(sitemapCounts)) {
203+
screenshotSummary += `<p>${sitemapCounts[sitemapUrl]} items scanned from ${sitemapUrl} at ${viewport.name}</p>\n`;
111204
}
112205
}
113206

114-
// Close </ul>
115-
screenshotSummary += '</ul>\n';
116-
117207
// Generate the combined HTML report
118208
createHtmlReport({
119209
results: allResults,
@@ -127,6 +217,47 @@ test.describe('Accessibility tests', () => {
127217
console.log(
128218
`Accessibility report saved at ${path.join(reportDir, 'combined-accessibility-report.html')}`
129219
);
220+
221+
// Inline injection of screenshots, viewport and url
222+
const reportPath = path.join(reportDirPath, 'combined-accessibility-report.html');
223+
let reportHtml = fs.readFileSync(reportPath, 'utf8');
224+
225+
combinedResults.forEach((result, resultIndex) => {
226+
if (!result.screenshot) return;
227+
228+
result.violations.forEach((violation) => {
229+
// Try to find the matching violation by Axe rule ID
230+
const ruleId = violation.id;
231+
const idNumber = resultIndex + 1;
232+
233+
console.log(`Injecting screenshot for violation ${idNumber} (${ruleId})`);
234+
235+
// Regex to match the <a id="X"> anchor and its violation card
236+
const regex = new RegExp(`(<a id=\"${idNumber}\">[\\s\\S]*?<\\/div>)`, 'g');
237+
238+
const imgTag = `
239+
<p>
240+
<strong>Breakpoint:</strong> ${result.viewport}<br>
241+
<strong>URL:</strong> <a href="${result.url}" target="_blank" rel="noopener noreferrer">${result.url}</a><br>
242+
<strong>State:</strong> ${result.state}
243+
</p>
244+
245+
<img src=\"${path.relative(reportDirPath, result.screenshot)}\"
246+
alt=\"Screenshot for violation ${idNumber}\"
247+
style=\"max-width: 100%; margin:1rem 0;\"
248+
/>`;
249+
250+
// Replace and inject
251+
reportHtml = reportHtml.replace(regex, (match) => {
252+
console.log(`Found match for ID ${idNumber}, inserting details.`);
253+
return match + imgTag;
254+
});
255+
});
256+
});
257+
258+
// Save the updated HTML
259+
fs.writeFileSync(reportPath, reportHtml, 'utf8');
260+
console.log(`Final report at: ${reportPath}`);
130261
} else {
131262
console.log('No accessibility violations found across all tested URLs.');
132263
}

tests/utils/process-sitemap.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { parseStringPromise } from 'xml2js';
2-
export const processSitemap = async (sitemapUrl: string): Promise<string[]> => {
2+
3+
export const processSitemap = async (
4+
sitemapUrl: string,
5+
counts: Record<string, number> = {}
6+
): Promise<{ urls: string[]; counts: Record<string, number> }> => {
7+
38
// Get the sitemap content
49
const response = await fetch(sitemapUrl);
510

@@ -19,15 +24,16 @@ export const processSitemap = async (sitemapUrl: string): Promise<string[]> => {
1924
if (parsedXml.sitemapindex && parsedXml.sitemapindex.sitemap) {
2025
const subSitemaps = parsedXml.sitemapindex.sitemap.map((entry: any) => entry.loc[0]);
2126
for (const subSitemap of subSitemaps) {
22-
const subUrls = await processSitemap(subSitemap);
23-
allUrls.push(...subUrls);
27+
const { urls, counts: _ } = await processSitemap(subSitemap, counts);
28+
allUrls.push(...urls);
2429
}
2530
}
2631

2732
// If URL set
2833
else if (parsedXml.urlset && parsedXml.urlset.url) {
2934
allUrls = parsedXml.urlset.url.map((entry: any) => entry.loc[0]);
35+
counts[sitemapUrl] = allUrls.length;
3036
}
3137

32-
return allUrls;
38+
return { urls: allUrls, counts };
3339
};

0 commit comments

Comments
 (0)