resume/tests/accessibility/generate-detailed-report.js

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();