#!/usr/bin/env bun /** * ACCESSIBILITY TEST * ================== * Tests WCAG 2.1 AA compliance and accessibility features * - Buttons with discernible text (aria-labels) * - Form elements with labels * - CSS compatibility (backdrop-filter, user-select) * - HTTP headers (cache-control, security headers) * - Keyboard navigation * - Screen reader support */ import { chromium } from 'playwright'; const URL = "http://localhost:1999"; async function testAccessibility() { console.log('♿ ACCESSIBILITY TEST\n'); console.log('='.repeat(70)); const browser = await chromium.launch({ headless: false }); const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); const errors = []; const testResults = []; page.on('console', msg => { if (msg.type() === 'error') { errors.push(msg.text()); console.log(`❌ ERROR: ${msg.text()}`); } }); console.log("\n1️⃣ Loading page..."); const response = await page.goto(URL); await page.waitForTimeout(2000); // ======================================================================== // TEST 1: HTTP Security Headers // ======================================================================== console.log("\n2️⃣ Testing HTTP Security Headers..."); const headers = response.headers(); const securityHeaderTests = [ { name: 'x-content-type-options', expected: 'nosniff' }, { name: 'x-frame-options', expected: 'SAMEORIGIN' }, { name: 'x-xss-protection', expected: '1; mode=block' }, { name: 'referrer-policy', exists: true }, { name: 'content-security-policy', exists: true }, ]; let securityPassed = 0; for (const test of securityHeaderTests) { const value = headers[test.name]; if (test.expected) { const pass = value === test.expected; console.log(` ${test.name}: ${pass ? '✅' : '❌'} (${value || 'missing'})`); if (pass) securityPassed++; } else if (test.exists) { const pass = !!value; console.log(` ${test.name}: ${pass ? '✅' : '❌'} (${pass ? 'present' : 'missing'})`); if (pass) securityPassed++; } } const securityTestPassed = securityPassed === securityHeaderTests.length; console.log(` ${securityTestPassed ? '✅ PASS' : '❌ FAIL'} - ${securityPassed}/${securityHeaderTests.length} security headers`); testResults.push({ test: 'Security Headers', passed: securityTestPassed }); // ======================================================================== // TEST 2: Cache-Control Headers // ======================================================================== console.log("\n3️⃣ Testing Cache-Control Headers..."); const cacheControl = headers['cache-control']; const hasCacheControl = !!cacheControl; console.log(` cache-control: ${hasCacheControl ? '✅' : '❌'} (${cacheControl || 'missing'})`); console.log(` ${hasCacheControl ? '✅ PASS' : '❌ FAIL'} - Cache-Control header present`); testResults.push({ test: 'Cache-Control Header', passed: hasCacheControl }); // ======================================================================== // TEST 3: Buttons with discernible text (aria-label) // ======================================================================== console.log("\n4️⃣ Testing Buttons with Discernible Text..."); const buttonA11y = await page.evaluate(() => { const buttons = document.querySelectorAll('button'); const results = []; buttons.forEach(btn => { const hasAriaLabel = btn.hasAttribute('aria-label'); const hasAriaLabelledBy = btn.hasAttribute('aria-labelledby'); const hasTitle = btn.hasAttribute('title'); const hasTextContent = btn.textContent.trim().length > 0; const hasVisibleText = Array.from(btn.querySelectorAll('span')) .some(span => span.textContent.trim().length > 0 && window.getComputedStyle(span).display !== 'none'); const accessible = hasAriaLabel || hasAriaLabelledBy || hasTitle || hasTextContent || hasVisibleText; if (!accessible) { results.push({ id: btn.id || 'no-id', class: btn.className, accessible: false }); } }); return { total: buttons.length, inaccessible: results, passed: results.length === 0 }; }); console.log(` Total buttons: ${buttonA11y.total}`); console.log(` Inaccessible buttons: ${buttonA11y.inaccessible.length}`); if (buttonA11y.inaccessible.length > 0) { buttonA11y.inaccessible.forEach(btn => { console.log(` ❌ Button: id="${btn.id}", class="${btn.class}"`); }); } console.log(` ${buttonA11y.passed ? '✅ PASS' : '❌ FAIL'} - All buttons have discernible text`); testResults.push({ test: 'Buttons Discernible Text', passed: buttonA11y.passed }); // ======================================================================== // TEST 4: Form elements with labels // ======================================================================== console.log("\n5️⃣ Testing Form Elements with Labels..."); const formA11y = await page.evaluate(() => { const inputs = document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"])'); const results = []; inputs.forEach(input => { const hasId = input.id; const hasAriaLabel = input.hasAttribute('aria-label'); const hasAriaLabelledBy = input.hasAttribute('aria-labelledby'); const hasTitle = input.hasAttribute('title'); const hasPlaceholder = input.hasAttribute('placeholder'); // Check for associated label const hasAssociatedLabel = hasId ? document.querySelector(`label[for="${input.id}"]`) !== null : false; // Check if wrapped in a label const isWrappedInLabel = input.closest('label') !== null; const accessible = hasAriaLabel || hasAriaLabelledBy || hasTitle || hasAssociatedLabel || isWrappedInLabel || hasPlaceholder; if (!accessible) { results.push({ id: input.id || 'no-id', type: input.type, name: input.name, accessible: false }); } }); return { total: inputs.length, inaccessible: results, passed: results.length === 0 }; }); console.log(` Total form inputs: ${formA11y.total}`); console.log(` Inaccessible inputs: ${formA11y.inaccessible.length}`); if (formA11y.inaccessible.length > 0) { formA11y.inaccessible.forEach(input => { console.log(` ❌ Input: id="${input.id}", type="${input.type}", name="${input.name}"`); }); } console.log(` ${formA11y.passed ? '✅ PASS' : '❌ FAIL'} - All form elements have labels`); testResults.push({ test: 'Form Elements Labels', passed: formA11y.passed }); // ======================================================================== // TEST 5: Toggle checkboxes accessibility // ======================================================================== console.log("\n6️⃣ Testing Toggle Checkboxes Accessibility..."); const toggleA11y = await page.evaluate(() => { const toggleIds = [ 'lengthToggle', 'iconToggle', 'themeToggle', 'lengthToggleMenu', 'iconToggleMenu', 'themeToggleMenu' ]; const results = []; toggleIds.forEach(id => { const toggle = document.getElementById(id); if (!toggle) return; const hasAriaLabel = toggle.hasAttribute('aria-label'); const hasAriaLabelledBy = toggle.hasAttribute('aria-labelledby'); const labelledById = toggle.getAttribute('aria-labelledby'); const linkedLabel = labelledById ? document.getElementById(labelledById) : null; results.push({ id, hasAriaLabel, hasAriaLabelledBy, linkedLabelExists: !!linkedLabel, accessible: hasAriaLabel || (hasAriaLabelledBy && !!linkedLabel) }); }); return { results, passed: results.every(r => r.accessible) }; }); toggleA11y.results.forEach(r => { const status = r.accessible ? '✅' : '❌'; console.log(` ${status} #${r.id}: aria-labelledby=${r.hasAriaLabelledBy}, linked-label=${r.linkedLabelExists}`); }); console.log(` ${toggleA11y.passed ? '✅ PASS' : '❌ FAIL'} - All toggle checkboxes accessible`); testResults.push({ test: 'Toggle Checkboxes Accessibility', passed: toggleA11y.passed }); // ======================================================================== // TEST 6: ARIA landmarks // ======================================================================== console.log("\n7️⃣ Testing ARIA Landmarks..."); const landmarks = await page.evaluate(() => { return { navigation: document.querySelectorAll('[role="navigation"], nav').length, main: document.querySelectorAll('[role="main"], main').length, dialog: document.querySelectorAll('[role="dialog"], dialog').length, region: document.querySelectorAll('[role="region"], section[aria-label]').length }; }); console.log(` Navigation landmarks: ${landmarks.navigation}`); console.log(` Main landmarks: ${landmarks.main}`); console.log(` Dialog elements: ${landmarks.dialog}`); console.log(` Regions: ${landmarks.region}`); const hasLandmarks = landmarks.navigation > 0; console.log(` ${hasLandmarks ? '✅ PASS' : '⚠️ INFO'} - Has navigation landmarks`); testResults.push({ test: 'ARIA Landmarks', passed: hasLandmarks }); // ======================================================================== // TEST 7: Keyboard Navigation // ======================================================================== console.log("\n8️⃣ Testing Keyboard Navigation..."); // Test that interactive elements can receive focus const keyboardA11y = await page.evaluate(() => { const interactiveElements = document.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); let focusableCount = 0; interactiveElements.forEach(el => { const style = window.getComputedStyle(el); if (style.display !== 'none' && style.visibility !== 'hidden') { focusableCount++; } }); return { total: interactiveElements.length, focusable: focusableCount }; }); console.log(` Total interactive elements: ${keyboardA11y.total}`); console.log(` Focusable elements: ${keyboardA11y.focusable}`); console.log(` ${keyboardA11y.focusable > 0 ? '✅ PASS' : '❌ FAIL'} - Has keyboard-accessible elements`); testResults.push({ test: 'Keyboard Navigation', passed: keyboardA11y.focusable > 0 }); // ======================================================================== // TEST 8: Modal Accessibility (dialog elements) // ======================================================================== console.log("\n9️⃣ Testing Modal/Dialog Accessibility..."); const modalA11y = await page.evaluate(() => { const dialogs = document.querySelectorAll('dialog'); const results = []; dialogs.forEach(dialog => { const hasCloseButton = dialog.querySelector('[aria-label*="close" i], [aria-label*="cerrar" i], .info-modal-close') !== null; const hasAriaLabel = dialog.hasAttribute('aria-label') || dialog.hasAttribute('aria-labelledby'); results.push({ id: dialog.id || 'no-id', hasCloseButton, hasAriaLabel, accessible: hasCloseButton }); }); return { total: dialogs.length, results, passed: results.every(r => r.accessible) }; }); console.log(` Total dialogs: ${modalA11y.total}`); modalA11y.results.forEach(r => { console.log(` ${r.accessible ? '✅' : '❌'} #${r.id}: close-btn=${r.hasCloseButton}, aria-label=${r.hasAriaLabel}`); }); console.log(` ${modalA11y.passed ? '✅ PASS' : '❌ FAIL'} - All dialogs have close buttons`); testResults.push({ test: 'Modal Accessibility', passed: modalA11y.passed }); // ======================================================================== // TEST 9: Color Contrast (basic check via computed styles) // ======================================================================== console.log("\n🔟 Testing Color Theme Support..."); const themeSupport = await page.evaluate(() => { const html = document.documentElement; const body = document.body; // Check for theme switcher const themeSwitcher = document.getElementById('color-theme-switcher'); // Check for CSS custom properties (variables) const styles = getComputedStyle(html); const hasColorVariables = styles.getPropertyValue('--color-text') || styles.getPropertyValue('--color-background') || styles.getPropertyValue('--text-color') || styles.getPropertyValue('--bg-color'); return { hasThemeSwitcher: !!themeSwitcher, hasColorVariables: !!hasColorVariables, bodyClasses: body.className }; }); console.log(` Theme switcher present: ${themeSupport.hasThemeSwitcher ? '✅' : '❌'}`); console.log(` CSS color variables: ${themeSupport.hasColorVariables ? '✅' : '⚠️ Not detected'}`); console.log(` Body classes: ${themeSupport.bodyClasses}`); console.log(` ${themeSupport.hasThemeSwitcher ? '✅ PASS' : '⚠️ INFO'} - Theme switching available`); testResults.push({ test: 'Color Theme Support', passed: themeSupport.hasThemeSwitcher }); // ======================================================================== // TEST 10: Screen Reader Announcements // ======================================================================== console.log("\n1️⃣1️⃣ Testing Screen Reader Announcements..."); const srAnnouncements = await page.evaluate(() => { const liveRegions = document.querySelectorAll('[aria-live], [role="status"], [role="alert"]'); const srOnlyElements = document.querySelectorAll('.sr-only, .visually-hidden'); return { liveRegions: liveRegions.length, srOnlyElements: srOnlyElements.length }; }); console.log(` Live regions (aria-live): ${srAnnouncements.liveRegions}`); console.log(` Screen reader only elements: ${srAnnouncements.srOnlyElements}`); console.log(` ${srAnnouncements.liveRegions > 0 ? '✅ PASS' : '⚠️ INFO'} - Has live regions for announcements`); testResults.push({ test: 'Screen Reader Announcements', passed: srAnnouncements.liveRegions > 0 }); // ======================================================================== // FINAL SUMMARY // ======================================================================== console.log("\n" + "=".repeat(70)); console.log("📊 ACCESSIBILITY TEST SUMMARY\n"); const totalTests = testResults.length; const passedTests = testResults.filter(r => r.passed).length; const failedTests = totalTests - passedTests; testResults.forEach(result => { console.log(` ${result.passed ? '✅' : '❌'} ${result.test}`); }); console.log(`\n Total: ${passedTests}/${totalTests} tests passed`); if (errors.length === 0) { console.log("\n✅ NO CONSOLE ERRORS"); } else { console.log(`\n⚠️ ${errors.length} CONSOLE ERRORS`); } console.log("=".repeat(70) + "\n"); if (failedTests === 0) { console.log("🎉 ALL ACCESSIBILITY TESTS PASSED!"); } else { console.log("⚠️ SOME TESTS FAILED - See details above"); } console.log("\nBrowser will stay open for manual inspection."); console.log("Press Ctrl+C when done.\n"); await new Promise(() => {}); // Keep browser open } await testAccessibility();