538 lines
16 KiB
JavaScript
538 lines
16 KiB
JavaScript
/**
|
|
* Generate a detailed accessibility report for WCAG 2.1 AAA compliance
|
|
*
|
|
* This script analyzes all pages and generates a comprehensive report
|
|
* of accessibility issues that need to be fixed for AAA compliance
|
|
*/
|
|
|
|
const { chromium } = require('playwright');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const axeCore = require('axe-core');
|
|
|
|
// URLs to test - expand this list to cover all pages
|
|
const BASE_URL = process.argv[2] || 'http://localhost:8080'\;
|
|
const PAGES = [
|
|
'/',
|
|
'/stories/',
|
|
'/stories/open-source-success.html',
|
|
'/stories/viperwire.html',
|
|
'/stories/airport-dns.html',
|
|
'/stories/wordpress-security.html',
|
|
'/stories/nitric-leadership.html',
|
|
'/stories/healthcare-platform.html',
|
|
'/stories/web-design-java.html',
|
|
'/stories/motherboard-repair.html',
|
|
'/stories/fawe-plotsquared.html',
|
|
'/stories/showerloop.html',
|
|
'/stories/athion-turnaround.html',
|
|
'/stories/youtube-game-dev.html',
|
|
'/stories/app-development.html',
|
|
'/one-pager-tools/csv-tool.html'
|
|
];
|
|
|
|
// Create reports directory if it doesn't exist
|
|
const reportsDir = path.join(__dirname, '../reports');
|
|
if (!fs.existsSync(reportsDir)) {
|
|
fs.mkdirSync(reportsDir, { recursive: true });
|
|
}
|
|
|
|
// Issues storage
|
|
const allIssues = {
|
|
byPage: {},
|
|
byRule: {},
|
|
summary: {
|
|
totalPages: 0,
|
|
pagesWithIssues: 0,
|
|
totalIssues: 0,
|
|
issuesByImpact: {
|
|
critical: 0,
|
|
serious: 0,
|
|
moderate: 0,
|
|
minor: 0
|
|
}
|
|
}
|
|
};
|
|
|
|
async function runAxe(page, pageUrl) {
|
|
try {
|
|
// Use a file URL to load axe-core from a local file to avoid CSP issues
|
|
const axeScriptPath = path.join(__dirname, 'axe-core.js');
|
|
fs.writeFileSync(axeScriptPath, axeCore.source);
|
|
|
|
// Add the script as a file
|
|
await page.addScriptTag({ path: axeScriptPath });
|
|
|
|
// Run axe with WCAG 2.1 AAA rules
|
|
const results = await page.evaluate(() => {
|
|
return new Promise(resolve => {
|
|
axe.run(document, {
|
|
runOnly: {
|
|
type: 'tag',
|
|
values: ['wcag2aaa']
|
|
},
|
|
resultTypes: ['violations', 'incomplete'],
|
|
rules: {
|
|
'color-contrast': { enabled: true, options: { noScroll: true } }
|
|
}
|
|
})
|
|
.then(results => resolve(results))
|
|
.catch(err => resolve({ error: err.toString() }));
|
|
});
|
|
});
|
|
|
|
// Clean up temporary file
|
|
fs.unlinkSync(axeScriptPath);
|
|
|
|
return results;
|
|
} catch (error) {
|
|
console.error('Error running axe:', error);
|
|
return { error: error.toString() };
|
|
}
|
|
}
|
|
|
|
async function testPage(browser, pageUrl) {
|
|
const context = await browser.newContext({
|
|
bypassCSP: true
|
|
});
|
|
const page = await context.newPage();
|
|
console.log(`Testing ${pageUrl}...`);
|
|
|
|
try {
|
|
// Navigate to the page
|
|
await page.goto(pageUrl, { waitUntil: 'networkidle' });
|
|
|
|
// Run axe-core tests
|
|
const results = await runAxe(page, pageUrl);
|
|
|
|
// Save results to file
|
|
const fileName = pageUrl === BASE_URL ? 'index' : pageUrl.replace(BASE_URL, '').replace(/\//g, '-').replace(/^-/, '');
|
|
const reportPath = path.join(reportsDir, `axe-${fileName || 'index'}.json`);
|
|
fs.writeFileSync(reportPath, JSON.stringify(results, null, 2));
|
|
|
|
// Log results summary
|
|
if (results.error) {
|
|
console.error(`Error running axe-core on ${pageUrl}:`, results.error);
|
|
return { url: pageUrl, success: false, error: results.error };
|
|
}
|
|
|
|
const { violations, incomplete } = results;
|
|
console.log(`Results for ${pageUrl}:`);
|
|
console.log(`- Violations: ${violations.length}`);
|
|
console.log(`- Incomplete: ${incomplete.length}`);
|
|
|
|
// Track issues for detailed report
|
|
const pageIssues = [];
|
|
|
|
// Process violations
|
|
if (violations.length > 0) {
|
|
console.log('\nViolations:');
|
|
violations.forEach((violation, i) => {
|
|
console.log(`${i + 1}. ${violation.id} - ${violation.help} (Impact: ${violation.impact})`);
|
|
console.log(` ${violation.description}`);
|
|
console.log(` WCAG: ${violation.tags.filter(t => t.startsWith('wcag')).join(', ')}`);
|
|
console.log(` Elements: ${violation.nodes.length}`);
|
|
|
|
// Add to issues tracking
|
|
violation.nodes.forEach(node => {
|
|
const issue = {
|
|
rule: violation.id,
|
|
impact: violation.impact,
|
|
description: violation.description,
|
|
help: violation.help,
|
|
helpUrl: violation.helpUrl,
|
|
wcagTags: violation.tags.filter(t => t.startsWith('wcag')),
|
|
element: node.html,
|
|
target: node.target,
|
|
failureSummary: node.failureSummary,
|
|
elementData: node.any.map(d => d.data || {})
|
|
};
|
|
pageIssues.push(issue);
|
|
|
|
// Update rule-based tracking
|
|
if (!allIssues.byRule[violation.id]) {
|
|
allIssues.byRule[violation.id] = {
|
|
id: violation.id,
|
|
description: violation.description,
|
|
help: violation.help,
|
|
helpUrl: violation.helpUrl,
|
|
impact: violation.impact,
|
|
wcagTags: violation.tags.filter(t => t.startsWith('wcag')),
|
|
occurrences: []
|
|
};
|
|
}
|
|
|
|
allIssues.byRule[violation.id].occurrences.push({
|
|
page: pageUrl,
|
|
element: node.html,
|
|
target: node.target,
|
|
failureSummary: node.failureSummary,
|
|
elementData: node.any.map(d => d.data || {})
|
|
});
|
|
|
|
// Update summary stats
|
|
allIssues.summary.totalIssues++;
|
|
if (violation.impact) {
|
|
allIssues.summary.issuesByImpact[violation.impact]++;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Store page issues
|
|
allIssues.byPage[pageUrl] = {
|
|
url: pageUrl,
|
|
issueCount: pageIssues.length,
|
|
issues: pageIssues
|
|
};
|
|
|
|
// Update summary
|
|
allIssues.summary.totalPages++;
|
|
if (pageIssues.length > 0) {
|
|
allIssues.summary.pagesWithIssues++;
|
|
}
|
|
|
|
return {
|
|
url: pageUrl,
|
|
success: violations.length === 0,
|
|
violations: violations.length,
|
|
incomplete: incomplete.length
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error testing ${pageUrl}:`, error);
|
|
return { url: pageUrl, success: false, error: error.toString() };
|
|
} finally {
|
|
await page.close();
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
function generateFixRecommendations() {
|
|
const recommendations = {};
|
|
|
|
// Process color contrast issues
|
|
if (allIssues.byRule['color-contrast-enhanced']) {
|
|
const colorIssues = allIssues.byRule['color-contrast-enhanced'];
|
|
recommendations.colorContrast = {
|
|
title: 'Color Contrast Issues',
|
|
description: 'The following color combinations need to be updated to meet WCAG 2.1 AAA requirements (7:1 contrast ratio):',
|
|
colorPairs: []
|
|
};
|
|
|
|
// Group by color pairs
|
|
const colorPairMap = {};
|
|
colorIssues.occurrences.forEach(occurrence => {
|
|
occurrence.elementData.forEach(data => {
|
|
if (data.fgColor && data.bgColor) {
|
|
const key = `${data.fgColor}/${data.bgColor}`;
|
|
if (!colorPairMap[key]) {
|
|
colorPairMap[key] = {
|
|
fgColor: data.fgColor,
|
|
bgColor: data.bgColor,
|
|
contrastRatio: data.contrastRatio,
|
|
requiredRatio: data.expectedContrastRatio,
|
|
pages: new Set(),
|
|
elements: []
|
|
};
|
|
}
|
|
colorPairMap[key].pages.add(occurrence.page);
|
|
colorPairMap[key].elements.push({
|
|
html: occurrence.element,
|
|
page: occurrence.page
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// Convert to array and add suggestions
|
|
Object.values(colorPairMap).forEach(pair => {
|
|
const suggestion = {
|
|
foreground: pair.fgColor,
|
|
background: pair.bgColor,
|
|
currentRatio: pair.contrastRatio,
|
|
requiredRatio: pair.requiredRatio,
|
|
pagesAffected: Array.from(pair.pages),
|
|
elementCount: pair.elements.length,
|
|
suggestedFixes: []
|
|
};
|
|
|
|
// Generate suggested fixes
|
|
// Make foreground darker
|
|
const darkerFg = darkenColor(pair.fgColor, 0.2);
|
|
suggestion.suggestedFixes.push({
|
|
type: 'Darken foreground',
|
|
color: darkerFg,
|
|
description: `Change foreground color from ${pair.fgColor} to ${darkerFg}`
|
|
});
|
|
|
|
// Make background lighter
|
|
const lighterBg = lightenColor(pair.bgColor, 0.2);
|
|
suggestion.suggestedFixes.push({
|
|
type: 'Lighten background',
|
|
color: lighterBg,
|
|
description: `Change background color from ${pair.bgColor} to ${lighterBg}`
|
|
});
|
|
|
|
recommendations.colorContrast.colorPairs.push(suggestion);
|
|
});
|
|
}
|
|
|
|
return recommendations;
|
|
}
|
|
|
|
// Helper function to darken a hex color
|
|
function darkenColor(hex, amount) {
|
|
return adjustColor(hex, -amount);
|
|
}
|
|
|
|
// Helper function to lighten a hex color
|
|
function lightenColor(hex, amount) {
|
|
return adjustColor(hex, amount);
|
|
}
|
|
|
|
// Helper function to adjust a hex color
|
|
function adjustColor(hex, amount) {
|
|
let r = parseInt(hex.substring(1, 3), 16);
|
|
let g = parseInt(hex.substring(3, 5), 16);
|
|
let b = parseInt(hex.substring(5, 7), 16);
|
|
|
|
if (amount > 0) {
|
|
// Lighten
|
|
r = Math.min(255, Math.round(r + (255 - r) * amount));
|
|
g = Math.min(255, Math.round(g + (255 - g) * amount));
|
|
b = Math.min(255, Math.round(b + (255 - b) * amount));
|
|
} else {
|
|
// Darken
|
|
amount = -amount;
|
|
r = Math.max(0, Math.round(r * (1 - amount)));
|
|
g = Math.max(0, Math.round(g * (1 - amount)));
|
|
b = Math.max(0, Math.round(b * (1 - amount)));
|
|
}
|
|
|
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
}
|
|
|
|
async function runTests() {
|
|
const browser = await chromium.launch();
|
|
const results = [];
|
|
|
|
try {
|
|
// Test each page
|
|
for (const pagePath of PAGES) {
|
|
const pageUrl = `${BASE_URL}${pagePath}`;
|
|
const result = await testPage(browser, pageUrl);
|
|
results.push(result);
|
|
}
|
|
|
|
// Generate detailed report
|
|
const recommendations = generateFixRecommendations();
|
|
|
|
const detailedReport = {
|
|
timestamp: new Date().toISOString(),
|
|
baseUrl: BASE_URL,
|
|
summary: allIssues.summary,
|
|
recommendations: recommendations,
|
|
issuesByRule: allIssues.byRule
|
|
};
|
|
|
|
// Save detailed report
|
|
fs.writeFileSync(
|
|
path.join(reportsDir, 'accessibility-detailed-report.json'),
|
|
JSON.stringify(detailedReport, null, 2)
|
|
);
|
|
|
|
// Generate HTML report
|
|
const htmlReport = generateHtmlReport(detailedReport);
|
|
fs.writeFileSync(
|
|
path.join(reportsDir, 'accessibility-report.html'),
|
|
htmlReport
|
|
);
|
|
|
|
console.log('\n=== Detailed Report Generated ===');
|
|
console.log(`Total pages tested: ${detailedReport.summary.totalPages}`);
|
|
console.log(`Pages with issues: ${detailedReport.summary.pagesWithIssues}`);
|
|
console.log(`Total issues found: ${detailedReport.summary.totalIssues}`);
|
|
console.log(`Issues by impact:`);
|
|
Object.entries(detailedReport.summary.issuesByImpact).forEach(([impact, count]) => {
|
|
if (count > 0) {
|
|
console.log(` - ${impact}: ${count}`);
|
|
}
|
|
});
|
|
console.log(`\nDetailed report saved to: ${path.join(reportsDir, 'accessibility-detailed-report.json')}`);
|
|
console.log(`HTML report saved to: ${path.join(reportsDir, 'accessibility-report.html')}`);
|
|
|
|
// Exit with appropriate code
|
|
process.exit(detailedReport.summary.totalIssues > 0 ? 1 : 0);
|
|
} catch (error) {
|
|
console.error('Error running tests:', error);
|
|
process.exit(1);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
function generateHtmlReport(report) {
|
|
const colorPairs = report.recommendations.colorContrast ? report.recommendations.colorContrast.colorPairs : [];
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Accessibility Report - WCAG 2.1 AAA Compliance</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
h1, h2, h3, h4 {
|
|
color: #2c3e50;
|
|
}
|
|
.summary {
|
|
background-color: #f8f9fa;
|
|
border-radius: 5px;
|
|
padding: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
.issue-count {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
}
|
|
.color-pair {
|
|
margin-bottom: 30px;
|
|
border: 1px solid #ddd;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
}
|
|
.color-sample {
|
|
display: inline-block;
|
|
width: 100px;
|
|
height: 50px;
|
|
margin-right: 10px;
|
|
border: 1px solid #ddd;
|
|
}
|
|
.color-info {
|
|
display: inline-block;
|
|
vertical-align: top;
|
|
}
|
|
.suggestion {
|
|
margin-top: 10px;
|
|
padding: 10px;
|
|
background-color: #f0f7ff;
|
|
border-radius: 5px;
|
|
}
|
|
.pages-list {
|
|
margin-top: 10px;
|
|
font-size: 14px;
|
|
}
|
|
.impact-critical { color: #d9534f; }
|
|
.impact-serious { color: #f0ad4e; }
|
|
.impact-moderate { color: #5bc0de; }
|
|
.impact-minor { color: #5cb85c; }
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-bottom: 20px;
|
|
}
|
|
th, td {
|
|
border: 1px solid #ddd;
|
|
padding: 8px;
|
|
text-align: left;
|
|
}
|
|
th {
|
|
background-color: #f2f2f2;
|
|
}
|
|
tr:nth-child(even) {
|
|
background-color: #f9f9f9;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Accessibility Report - WCAG 2.1 AAA Compliance</h1>
|
|
<p>Generated on: ${new Date(report.timestamp).toLocaleString()}</p>
|
|
|
|
<div class="summary">
|
|
<h2>Summary</h2>
|
|
<p><span class="issue-count">${report.summary.totalIssues}</span> issues found across ${report.summary.pagesWithIssues} pages (${report.summary.totalPages} pages tested)</p>
|
|
<p>Issues by impact:</p>
|
|
<ul>
|
|
${report.summary.issuesByImpact.critical > 0 ? `<li><span class="impact-critical">Critical: ${report.summary.issuesByImpact.critical}</span></li>` : ''}
|
|
${report.summary.issuesByImpact.serious > 0 ? `<li><span class="impact-serious">Serious: ${report.summary.issuesByImpact.serious}</span></li>` : ''}
|
|
${report.summary.issuesByImpact.moderate > 0 ? `<li><span class="impact-moderate">Moderate: ${report.summary.issuesByImpact.moderate}</span></li>` : ''}
|
|
${report.summary.issuesByImpact.minor > 0 ? `<li><span class="impact-minor">Minor: ${report.summary.issuesByImpact.minor}</span></li>` : ''}
|
|
</ul>
|
|
</div>
|
|
|
|
<h2>Color Contrast Issues</h2>
|
|
${colorPairs.length === 0 ? '<p>No color contrast issues found.</p>' : ''}
|
|
${colorPairs.map(pair => `
|
|
<div class="color-pair">
|
|
<h3>Color Combination</h3>
|
|
<div>
|
|
<div class="color-sample" style="background-color: ${pair.foreground}"></div>
|
|
<div class="color-info">
|
|
<p>Foreground: ${pair.foreground}</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="color-sample" style="background-color: ${pair.background}"></div>
|
|
<div class="color-info">
|
|
<p>Background: ${pair.background}</p>
|
|
</div>
|
|
</div>
|
|
<p>Current contrast ratio: ${pair.currentRatio} (Required: ${pair.requiredRatio})</p>
|
|
<p>Found on ${pair.elementCount} element${pair.elementCount !== 1 ? 's' : ''} across ${pair.pagesAffected.length} page${pair.pagesAffected.length !== 1 ? 's' : ''}</p>
|
|
|
|
<div class="pages-list">
|
|
<p>Pages affected:</p>
|
|
<ul>
|
|
${pair.pagesAffected.map(page => `<li>${page}</li>`).join('')}
|
|
</ul>
|
|
</div>
|
|
|
|
<h4>Suggested Fixes</h4>
|
|
${pair.suggestedFixes.map(fix => `
|
|
<div class="suggestion">
|
|
<h5>${fix.type}</h5>
|
|
<div class="color-sample" style="background-color: ${fix.color}"></div>
|
|
<div class="color-info">
|
|
<p>${fix.description}</p>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`).join('')}
|
|
|
|
<h2>Issues by Rule</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Rule</th>
|
|
<th>Impact</th>
|
|
<th>Description</th>
|
|
<th>Occurrences</th>
|
|
<th>WCAG Criteria</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${Object.values(report.issuesByRule).map(rule => `
|
|
<tr>
|
|
<td><a href="${rule.helpUrl}" target="_blank">${rule.id}</a></td>
|
|
<td class="impact-${rule.impact}">${rule.impact}</td>
|
|
<td>${rule.description}</td>
|
|
<td>${rule.occurrences.length}</td>
|
|
<td>${rule.wcagTags.join(', ')}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
runTests();
|