Spaces:
Paused
Paused
| const puppeteer = require('puppeteer'); | |
| const { AxePuppeteer } = require('@axe-core/puppeteer'); | |
| class AccessibilityService { | |
| constructor() { | |
| this.axeConfig = { | |
| tags: ['wcag2a', 'wcag2aa', 'best-practice'] | |
| }; | |
| } | |
| async scanAccessibility(url, options = {}) { | |
| const { | |
| viewport = { width: 1920, height: 1080 }, | |
| includeImages = true, | |
| timeout = 30000 | |
| } = options; | |
| let browser = null; | |
| try { | |
| browser = await puppeteer.launch({ | |
| headless: 'new', | |
| args: [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage' | |
| ] | |
| }); | |
| const page = await browser.newPage(); | |
| await page.setViewport(viewport); | |
| // Navigate to the page | |
| await page.goto(url, { | |
| waitUntil: 'networkidle0', | |
| timeout | |
| }); | |
| // Wait a bit more for dynamic content | |
| await page.waitForTimeout(2000); | |
| // Run axe accessibility scan with better error handling | |
| let results; | |
| try { | |
| results = await new AxePuppeteer(page) | |
| .withTags(['wcag2a', 'wcag2aa', 'best-practice']) | |
| .analyze(); | |
| } catch (axeError) { | |
| console.warn('Axe-core scan failed, returning minimal results:', axeError.message); | |
| // Return minimal valid results if axe fails | |
| results = { | |
| violations: [], | |
| passes: [], | |
| incomplete: [], | |
| inapplicable: [] | |
| }; | |
| } | |
| // Process and categorize results | |
| const processedResults = this.processAxeResults(results); | |
| return { | |
| url, | |
| timestamp: new Date().toISOString(), | |
| summary: this.generateSummary(processedResults), | |
| violations: processedResults.violations, | |
| passes: processedResults.passes, | |
| incomplete: processedResults.incomplete, | |
| inapplicable: processedResults.inapplicable | |
| }; | |
| } catch (error) { | |
| console.error('Accessibility scan error:', error); | |
| throw new Error(`Failed to scan accessibility: ${error.message}`); | |
| } finally { | |
| if (browser) { | |
| await browser.close(); | |
| } | |
| } | |
| } | |
| processAxeResults(axeResults) { | |
| const processRule = (rule) => ({ | |
| id: rule.id, | |
| impact: rule.impact, | |
| tags: rule.tags, | |
| description: rule.description, | |
| help: rule.help, | |
| helpUrl: rule.helpUrl, | |
| nodes: rule.nodes.map(node => ({ | |
| html: node.html, | |
| impact: node.impact, | |
| target: node.target, | |
| failureSummary: node.failureSummary, | |
| any: node.any, | |
| all: node.all, | |
| none: node.none | |
| })) | |
| }); | |
| return { | |
| violations: axeResults.violations.map(processRule), | |
| passes: axeResults.passes.map(processRule), | |
| incomplete: axeResults.incomplete.map(processRule), | |
| inapplicable: axeResults.inapplicable.map(processRule) | |
| }; | |
| } | |
| generateSummary(results) { | |
| const violationsByImpact = { | |
| critical: 0, | |
| serious: 0, | |
| moderate: 0, | |
| minor: 0 | |
| }; | |
| results.violations.forEach(violation => { | |
| if (violation.impact && violationsByImpact.hasOwnProperty(violation.impact)) { | |
| violationsByImpact[violation.impact] += violation.nodes.length; | |
| } | |
| }); | |
| const totalViolations = Object.values(violationsByImpact).reduce((a, b) => a + b, 0); | |
| const totalPasses = results.passes.reduce((total, pass) => total + pass.nodes.length, 0); | |
| const totalIncomplete = results.incomplete.reduce((total, incomplete) => total + incomplete.nodes.length, 0); | |
| return { | |
| totalViolations, | |
| violationsByImpact, | |
| totalPasses, | |
| totalIncomplete, | |
| score: this.calculateAccessibilityScore(violationsByImpact, totalPasses), | |
| categories: this.categorizeViolations(results.violations) | |
| }; | |
| } | |
| calculateAccessibilityScore(violationsByImpact, totalPasses) { | |
| // Simple scoring algorithm - can be made more sophisticated | |
| const weights = { critical: 10, serious: 5, moderate: 2, minor: 1 }; | |
| const totalDeductions = Object.entries(violationsByImpact) | |
| .reduce((total, [impact, count]) => total + (count * weights[impact]), 0); | |
| const baseScore = 100; | |
| const passBonus = Math.min(totalPasses * 0.1, 20); // Max 20 bonus points | |
| const score = Math.max(0, baseScore - totalDeductions + passBonus); | |
| return Math.round(score); | |
| } | |
| categorizeViolations(violations) { | |
| const categories = {}; | |
| violations.forEach(violation => { | |
| violation.tags.forEach(tag => { | |
| if (!categories[tag]) { | |
| categories[tag] = []; | |
| } | |
| categories[tag].push({ | |
| id: violation.id, | |
| impact: violation.impact, | |
| nodeCount: violation.nodes.length, | |
| description: violation.description | |
| }); | |
| }); | |
| }); | |
| return categories; | |
| } | |
| async scanMultipleViewports(url, viewports = ['desktop', 'tablet', 'mobile']) { | |
| const viewportConfigs = { | |
| desktop: { width: 1920, height: 1080 }, | |
| tablet: { width: 768, height: 1024 }, | |
| mobile: { width: 375, height: 667 } | |
| }; | |
| const results = {}; | |
| const errors = {}; | |
| for (const viewportName of viewports) { | |
| try { | |
| const viewport = viewportConfigs[viewportName]; | |
| if (!viewport) { | |
| throw new Error(`Unknown viewport: ${viewportName}`); | |
| } | |
| const result = await this.scanAccessibility(url, { viewport }); | |
| results[viewportName] = result; | |
| } catch (error) { | |
| console.error(`Error scanning ${viewportName} accessibility:`, error); | |
| errors[viewportName] = error.message; | |
| } | |
| } | |
| return { | |
| results, | |
| errors: Object.keys(errors).length > 0 ? errors : null | |
| }; | |
| } | |
| generateAccessibilityReport(scanResults) { | |
| const report = { | |
| executive_summary: this.generateExecutiveSummary(scanResults), | |
| detailed_findings: this.generateDetailedFindings(scanResults), | |
| recommendations: this.generateRecommendations(scanResults), | |
| compliance_status: this.generateComplianceStatus(scanResults) | |
| }; | |
| return report; | |
| } | |
| generateExecutiveSummary(scanResults) { | |
| const summary = scanResults.summary; | |
| return { | |
| overall_score: summary.score, | |
| total_issues: summary.totalViolations, | |
| critical_issues: summary.violationsByImpact.critical, | |
| accessibility_level: this.getAccessibilityLevel(summary.score), | |
| primary_concerns: this.getPrimaryConcerns(scanResults.violations) | |
| }; | |
| } | |
| generateDetailedFindings(scanResults) { | |
| return scanResults.violations.map(violation => ({ | |
| rule: violation.id, | |
| impact: violation.impact, | |
| description: violation.description, | |
| help: violation.help, | |
| instances: violation.nodes.length, | |
| affected_elements: violation.nodes.map(node => ({ | |
| target: node.target.join(', '), | |
| html: node.html.substring(0, 200) + (node.html.length > 200 ? '...' : '') | |
| })) | |
| })); | |
| } | |
| generateRecommendations(scanResults) { | |
| const recommendations = []; | |
| // Group violations by priority | |
| const criticalViolations = scanResults.violations.filter(v => v.impact === 'critical'); | |
| const seriousViolations = scanResults.violations.filter(v => v.impact === 'serious'); | |
| if (criticalViolations.length > 0) { | |
| recommendations.push({ | |
| priority: 'High', | |
| category: 'Critical Accessibility Issues', | |
| items: criticalViolations.map(v => v.help), | |
| description: 'These issues prevent users from accessing content and must be fixed immediately.' | |
| }); | |
| } | |
| if (seriousViolations.length > 0) { | |
| recommendations.push({ | |
| priority: 'Medium', | |
| category: 'Serious Accessibility Issues', | |
| items: seriousViolations.map(v => v.help), | |
| description: 'These issues significantly impact user experience for people with disabilities.' | |
| }); | |
| } | |
| return recommendations; | |
| } | |
| generateComplianceStatus(scanResults) { | |
| const violations = scanResults.violations; | |
| const wcag2aViolations = violations.filter(v => v.tags.includes('wcag2a')); | |
| const wcag2aaViolations = violations.filter(v => v.tags.includes('wcag2aa')); | |
| return { | |
| wcag_2_1_a: wcag2aViolations.length === 0 ? 'Compliant' : 'Non-compliant', | |
| wcag_2_1_aa: wcag2aaViolations.length === 0 ? 'Compliant' : 'Non-compliant', | |
| total_violations: violations.length, | |
| compliance_score: scanResults.summary.score | |
| }; | |
| } | |
| getAccessibilityLevel(score) { | |
| if (score >= 90) return 'Excellent'; | |
| if (score >= 75) return 'Good'; | |
| if (score >= 60) return 'Fair'; | |
| if (score >= 40) return 'Poor'; | |
| return 'Critical'; | |
| } | |
| getPrimaryConcerns(violations) { | |
| return violations | |
| .filter(v => v.impact === 'critical' || v.impact === 'serious') | |
| .slice(0, 5) | |
| .map(v => v.description); | |
| } | |
| } | |
| module.exports = AccessibilityService; |