""" Accessibility audit and testing features """ import json import logging from datetime import datetime from browser.driver import get_driver, cleanup_driver logger = logging.getLogger(__name__) def accessibility_audit(url: str, use_persistent: bool = False) -> str: """Perform basic accessibility audit on a page""" driver = None try: driver = get_driver(url, use_persistent) # Run accessibility checks audit_results = driver.execute_script(""" const results = { images_without_alt: [], form_inputs_without_labels: [], low_contrast_elements: [], missing_landmarks: [], heading_structure: [], interactive_elements_without_text: [], links_without_text: [], aria_issues: [] }; // Check images without alt text document.querySelectorAll('img:not([alt])').forEach(img => { results.images_without_alt.push(img.src || 'no-src'); }); // Check form inputs without labels document.querySelectorAll('input, select, textarea').forEach(input => { const id = input.id; const hasLabel = id && document.querySelector(`label[for="${id}"]`); const hasAriaLabel = input.getAttribute('aria-label'); const hasAriaLabelledBy = input.getAttribute('aria-labelledby'); if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy) { results.form_inputs_without_labels.push({ type: input.type, name: input.name, id: id || 'no-id' }); } }); // Check heading structure const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); let lastLevel = 0; headings.forEach(heading => { const level = parseInt(heading.tagName.substring(1)); results.heading_structure.push({ level: level, text: heading.textContent.trim().substring(0, 50), issue: level > lastLevel + 1 ? 'skipped_level' : null }); lastLevel = level; }); // Check for landmarks const landmarks = { header: document.querySelector('header, [role="banner"]'), nav: document.querySelector('nav, [role="navigation"]'), main: document.querySelector('main, [role="main"]'), footer: document.querySelector('footer, [role="contentinfo"]') }; for (const [role, element] of Object.entries(landmarks)) { if (!element) { results.missing_landmarks.push(role); } } // Check interactive elements without text document.querySelectorAll('button, a').forEach(elem => { const text = elem.textContent.trim(); const ariaLabel = elem.getAttribute('aria-label'); const ariaLabelledBy = elem.getAttribute('aria-labelledby'); if (!text && !ariaLabel && !ariaLabelledBy) { results.interactive_elements_without_text.push({ tag: elem.tagName, class: elem.className, href: elem.href || null }); } }); // Check links without meaningful text document.querySelectorAll('a[href]').forEach(link => { const text = link.textContent.trim().toLowerCase(); if (text === 'click here' || text === 'here' || text === 'read more') { results.links_without_text.push({ text: text, href: link.href }); } }); // Check ARIA issues document.querySelectorAll('[aria-hidden="true"]').forEach(elem => { if (elem.querySelector('a, button, input, select, textarea')) { results.aria_issues.push({ issue: 'interactive_element_hidden', element: elem.tagName + (elem.className ? '.' + elem.className : '') }); } }); return results; """) # Calculate score issues_count = sum(len(v) if isinstance(v, list) else 0 for v in audit_results.values()) score = max(0, 100 - (issues_count * 5)) # Deduct 5 points per issue result = { "url": url, "accessibility_score": score, "grade": get_grade(score), "issues": audit_results, "total_issues": issues_count, "timestamp": datetime.now().isoformat(), "recommendations": get_recommendations(audit_results) } return json.dumps(result, indent=2) except Exception as e: logger.error(f"Error in accessibility_audit: {e}") return f"Error: {e}" finally: cleanup_driver(driver, use_persistent) def get_grade(score): """Convert score to letter grade""" if score >= 90: return "A" elif score >= 80: return "B" elif score >= 70: return "C" elif score >= 60: return "D" else: return "F" def get_recommendations(audit_results): """Get recommendations based on audit results""" recommendations = [] if audit_results["images_without_alt"]: recommendations.append("Add alt text to all images for screen reader users") if audit_results["form_inputs_without_labels"]: recommendations.append("Associate all form inputs with labels using 'for' attribute or aria-label") if audit_results["missing_landmarks"]: recommendations.append("Add semantic HTML5 landmarks (header, nav, main, footer) or ARIA roles") if audit_results["heading_structure"]: has_issues = any(h["issue"] for h in audit_results["heading_structure"]) if has_issues: recommendations.append("Fix heading hierarchy - don't skip heading levels") if audit_results["interactive_elements_without_text"]: recommendations.append("Add descriptive text or aria-labels to all interactive elements") return recommendations def check_color_contrast(url: str, use_persistent: bool = False) -> str: """Check color contrast for text elements""" driver = None try: driver = get_driver(url, use_persistent) # Check color contrast contrast_results = driver.execute_script(""" function getLuminance(r, g, b) { const sRGB = [r, g, b].map(val => { val = val / 255; return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); }); return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2]; } function getContrastRatio(color1, color2) { const l1 = getLuminance(...color1); const l2 = getLuminance(...color2); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } function getRGBValues(colorStr) { const match = colorStr.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/); if (match) { return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]; } return null; } const results = []; const textElements = document.querySelectorAll('p, span, div, h1, h2, h3, h4, h5, h6, a, button'); textElements.forEach(elem => { const style = window.getComputedStyle(elem); const color = getRGBValues(style.color); const bgColor = getRGBValues(style.backgroundColor); if (color && bgColor) { const ratio = getContrastRatio(color, bgColor); const fontSize = parseFloat(style.fontSize); const fontWeight = style.fontWeight; // WCAG AA standards const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700); const requiredRatio = isLargeText ? 3 : 4.5; if (ratio < requiredRatio) { results.push({ element: elem.tagName, text: elem.textContent.trim().substring(0, 50), contrastRatio: ratio.toFixed(2), requiredRatio: requiredRatio, fontSize: fontSize, isLargeText: isLargeText }); } } }); return results; """) return json.dumps({ "low_contrast_elements": contrast_results, "total_issues": len(contrast_results) }, indent=2) except Exception as e: logger.error(f"Error in check_color_contrast: {e}") return f"Error: {e}" finally: cleanup_driver(driver, use_persistent)