UX-agent / backend /services /accessibilityService.js
AUXteam's picture
Deploying UX Analyst AI to Hugging Face (V2)
21cac8a verified
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;