40733034ca
- Add aria-labels to menu action buttons (PDF, Print, Contact) - Add aria-labelledby to toggle checkboxes (desktop + mobile) - Add -webkit-user-select prefix for Safari compatibility - Add DynamicCacheControl middleware for HTML pages - Add accessibility test suite (60-accessibility.test.mjs) - Add comprehensive accessibility documentation (21-ACCESSIBILITY.md) - Update Modern Web Techniques doc to mark audit complete
398 lines
15 KiB
JavaScript
398 lines
15 KiB
JavaScript
#!/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();
|