/** * 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 ` Accessibility Report - WCAG 2.1 AAA Compliance

Accessibility Report - WCAG 2.1 AAA Compliance

Generated on: ${new Date(report.timestamp).toLocaleString()}

Summary

${report.summary.totalIssues} issues found across ${report.summary.pagesWithIssues} pages (${report.summary.totalPages} pages tested)

Issues by impact:

Color Contrast Issues

${colorPairs.length === 0 ? '

No color contrast issues found.

' : ''} ${colorPairs.map(pair => `

Color Combination

Foreground: ${pair.foreground}

Background: ${pair.background}

Current contrast ratio: ${pair.currentRatio} (Required: ${pair.requiredRatio})

Found on ${pair.elementCount} element${pair.elementCount !== 1 ? 's' : ''} across ${pair.pagesAffected.length} page${pair.pagesAffected.length !== 1 ? 's' : ''}

Pages affected:

Suggested Fixes

${pair.suggestedFixes.map(fix => `
${fix.type}

${fix.description}

`).join('')}
`).join('')}

Issues by Rule

${Object.values(report.issuesByRule).map(rule => ` `).join('')}
Rule Impact Description Occurrences WCAG Criteria
${rule.id} ${rule.impact} ${rule.description} ${rule.occurrences.length} ${rule.wcagTags.join(', ')}
`; } runTests();