This guide demonstrates advanced accessibility testing using Playwright browser automation in LoadForge. Test real user interactions, dynamic content, and complex accessibility scenarios that HTML parsing cannot detect.
Use Cases
- Testing keyboard navigation and focus management
- Validating ARIA live regions and dynamic content
- Real color contrast analysis and visual accessibility
- Screen reader compatibility testing
- Form accessibility with dynamic validation
- Complex user journey accessibility flows
Simple Browser Accessibility Testing
from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event
import time
class SimpleAccessibilityTester(PlaywrightUser):
def on_start(self):
"""Initialize accessibility testing"""
self.accessibility_issues = []
self.pages_tested = 0
@task(5)
@pw
async def test_keyboard_navigation(self, page: PageWithRetry):
"""Test keyboard navigation and focus management"""
if self.pages_tested == 0:
# Start with homepage and discover pages
await self._test_page_keyboard_accessibility(page, '/')
await self._discover_pages(page)
self.pages_tested += 1
elif hasattr(self, 'discovered_pages') and self.discovered_pages:
# Test discovered pages
import random
page_url = random.choice(self.discovered_pages)
await self._test_page_keyboard_accessibility(page, page_url)
async def _discover_pages(self, page: PageWithRetry):
"""Discover internal pages to test"""
if hasattr(self, 'discovered_pages'):
return
self.discovered_pages = []
# Find internal links on homepage
links = await page.locator('a[href^="/"], a[href^="./"]').all()
for link in links[:10]: # Limit to 10 pages for simple testing
try:
href = await link.get_attribute('href')
if href and self._is_valid_page_link(href):
self.discovered_pages.append(href)
except:
pass
print(f"Discovered {len(self.discovered_pages)} pages for accessibility testing")
def _is_valid_page_link(self, href):
"""Check if href is a valid page link to test"""
if not href or href in self.discovered_pages:
return False
# Skip anchors, resources, external protocols
skip_patterns = ['#', 'mailto:', 'tel:', 'javascript:', '.pdf', '.jpg', '.png', '.css', '.js']
return not any(pattern in href.lower() for pattern in skip_patterns)
async def _test_page_keyboard_accessibility(self, page: PageWithRetry, page_url: str):
"""Test keyboard accessibility on a single page"""
current_issues = []
async with event(self, f"ACCESSIBILITY: {page_url}"):
await page.goto(page_url)
# Test 1: Check for skip links
skip_links = await page.locator('a[href^="#"]:has-text("skip")').count()
if skip_links == 0:
current_issues.append("Missing skip navigation links")
# Test 2: Test tab navigation through interactive elements
interactive_elements = await page.locator('a, button, input, select, textarea').count()
if interactive_elements > 0:
# Focus first interactive element
await page.keyboard.press('Tab')
# Test if focus is visible
focused_element = await page.locator(':focus').count()
if focused_element == 0:
current_issues.append("No visible focus indicators")
# Test tab navigation through first 5 elements
for i in range(min(4, interactive_elements - 1)):
await page.keyboard.press('Tab')
# Check if focus is trapped properly
focused = await page.locator(':focus').count()
if focused == 0:
current_issues.append("Focus lost during tab navigation")
break
# Test 3: Test heading structure
h1_count = await page.locator('h1').count()
if h1_count == 0:
current_issues.append("Page missing H1 heading")
elif h1_count > 1:
current_issues.append("Multiple H1 headings found")
# Test 4: Check for images without alt text
images_without_alt = await page.locator('img:not([alt])').count()
if images_without_alt > 0:
current_issues.append(f"{images_without_alt} images missing alt text")
# Test 5: Check for empty links
empty_links = await page.locator('a:empty').count()
if empty_links > 0:
current_issues.append(f"{empty_links} empty links found")
# Test 6: Test form labels
inputs_without_labels = await page.locator('input:not([type="hidden"]):not([aria-label]):not([aria-labelledby])').count()
labels_for_inputs = await page.locator('label[for]').count()
if inputs_without_labels > labels_for_inputs:
current_issues.append("Form inputs missing proper labels")
# Record results
self.accessibility_issues.extend(current_issues)
# Report to LoadForge
if current_issues:
# This will show as failed in LoadForge
raise Exception(f"❌ {len(current_issues)} accessibility issues: {'; '.join(current_issues[:2])}")
print(f"✅ {page_url} - No accessibility issues found")
@task(2)
@pw
async def test_color_contrast(self, page: PageWithRetry):
"""Test color contrast by analyzing page elements"""
async with event(self, "Color Contrast Analysis"):
await page.goto('/')
# Get computed styles for text elements
text_elements = await page.locator('p, h1, h2, h3, h4, h5, h6, a, span, div').all()
contrast_issues = 0
for element in text_elements[:10]: # Test first 10 elements
try:
# Get computed styles
color = await element.evaluate('el => getComputedStyle(el).color')
bg_color = await element.evaluate('el => getComputedStyle(el).backgroundColor')
# Basic contrast check (simplified)
if 'rgba(0, 0, 0, 0)' in bg_color and 'rgb(255, 255, 255)' in color:
contrast_issues += 1
except:
pass
if contrast_issues > 3:
raise Exception(f"❌ Potential color contrast issues detected ({contrast_issues} elements)")
@task(1)
@pw
async def test_dynamic_content_accessibility(self, page: PageWithRetry):
"""Test accessibility of dynamic content and ARIA live regions"""
async with event(self, "Dynamic Content Accessibility"):
await page.goto('/')
# Look for interactive elements that might show dynamic content
buttons = await page.locator('button, [role="button"]').all()
for button in buttons[:3]: # Test first 3 buttons
try:
# Click button to trigger dynamic content
await button.click()
# Wait for potential dynamic content
await page.wait_for_timeout(1000)
# Check for ARIA live regions
live_regions = await page.locator('[aria-live]').count()
if live_regions == 0:
# Check if content changed without live region
modals = await page.locator('[role="dialog"], .modal').count()
if modals > 0:
# Modal should have proper focus management
focused_in_modal = await page.locator('.modal :focus, [role="dialog"] :focus').count()
if focused_in_modal == 0:
raise Exception("❌ Modal opened without proper focus management")
# Close any modals
close_buttons = await page.locator('[aria-label*="close"], .close, [data-dismiss]').all()
for close_btn in close_buttons:
try:
await close_btn.click()
break
except:
pass
except Exception as e:
if "accessibility" in str(e).lower():
raise e
pass
def on_stop(self):
"""Final accessibility report"""
print("\n" + "="*50)
print("BROWSER ACCESSIBILITY TEST COMPLETE")
print("="*50)
print(f"Pages tested: {self.pages_tested}")
print(f"Total accessibility issues: {len(self.accessibility_issues)}")
if self.accessibility_issues:
print("\nACCESSIBILITY ISSUES FOUND:")
for issue in self.accessibility_issues[:10]:
print(f"❌ {issue}")
else:
print("✅ No accessibility issues detected!")
Comprehensive WCAG Compliance Testing
from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event
import json
class WCAGComplianceTester(PlaywrightUser):
def on_start(self):
"""Initialize WCAG compliance testing"""
self.wcag_violations = []
self.pages_crawled = []
self.focus_issues = []
@task(3)
@pw
async def comprehensive_wcag_test(self, page: PageWithRetry):
"""Comprehensive WCAG 2.1 AA compliance testing"""
# Discover pages to test
if not self.pages_crawled:
await self._discover_pages(page)
# Test random page from discovered pages
if self.pages_crawled:
import random
page_url = random.choice(self.pages_crawled)
await self._test_wcag_compliance(page, page_url)
async def _discover_pages(self, page: PageWithRetry):
"""Discover internal pages to test"""
async with event(self, "Page Discovery"):
await page.goto('/')
# Find internal links
links = await page.locator('a[href^="/"], a[href^="./"]').all()
for link in links[:20]: # Limit to 20 pages
try:
href = await link.get_attribute('href')
if href and href not in self.pages_crawled:
self.pages_crawled.append(href)
except:
pass
async def _test_wcag_compliance(self, page: PageWithRetry, page_url: str):
"""Test WCAG compliance on a single page"""
violations = []
async with event(self, f"WCAG Test: {page_url}"):
await page.goto(page_url)
# WCAG 1.1.1 - Non-text Content
decorative_images = await page.locator('img[alt=""]').count()
images_without_alt = await page.locator('img:not([alt])').count()
if images_without_alt > 0:
violations.append(f"WCAG 1.1.1: {images_without_alt} images missing alt text")
# WCAG 1.3.1 - Info and Relationships
tables_without_headers = await page.locator('table:not(:has(th))').count()
if tables_without_headers > 0:
violations.append(f"WCAG 1.3.1: {tables_without_headers} tables missing headers")
# WCAG 1.4.3 - Contrast (Minimum)
await self._test_contrast_compliance(page, violations)
# WCAG 2.1.1 - Keyboard Navigation
await self._test_keyboard_compliance(page, violations)
# WCAG 2.4.1 - Bypass Blocks
skip_links = await page.locator('a[href^="#"]:has-text("skip")').count()
if skip_links == 0:
violations.append("WCAG 2.4.1: Missing skip navigation links")
# WCAG 2.4.6 - Headings and Labels
empty_headings = await page.locator('h1:empty, h2:empty, h3:empty, h4:empty, h5:empty, h6:empty').count()
if empty_headings > 0:
violations.append(f"WCAG 2.4.6: {empty_headings} empty headings")
# WCAG 3.3.2 - Labels or Instructions
unlabeled_inputs = await page.locator('input:not([type="hidden"]):not([aria-label]):not([aria-labelledby])').count()
labels = await page.locator('label[for]').count()
if unlabeled_inputs > labels:
violations.append("WCAG 3.3.2: Form inputs missing proper labels")
# WCAG 4.1.2 - Name, Role, Value
await self._test_aria_compliance(page, violations)
# Record violations
self.wcag_violations.extend(violations)
# Report results
if violations:
violation_summary = f"❌ {len(violations)} WCAG violations"
raise Exception(violation_summary)
print(f"✅ WCAG Compliant: {page_url}")
async def _test_contrast_compliance(self, page: PageWithRetry, violations):
"""Test color contrast compliance"""
# Test common elements for contrast
elements_to_test = ['a', 'button', 'p', 'h1', 'h2', 'h3']
for selector in elements_to_test:
elements = await page.locator(selector).all()
for element in elements[:3]: # Test first 3 of each type
try:
# Get computed styles
color = await element.evaluate('el => getComputedStyle(el).color')
bg_color = await element.evaluate('el => getComputedStyle(el).backgroundColor')
# Simplified contrast check
if 'rgba(0, 0, 0, 0)' in bg_color and 'rgb(255, 255, 255)' in color:
violations.append(f"WCAG 1.4.3: Potential low contrast on {selector}")
break
except:
pass
async def _test_keyboard_compliance(self, page: PageWithRetry, violations):
"""Test keyboard navigation compliance"""
# Test tab navigation
interactive_elements = await page.locator('a, button, input, select, textarea').count()
if interactive_elements > 0:
# Start tab navigation
await page.keyboard.press('Tab')
# Test focus visibility
focused_element = await page.locator(':focus').count()
if focused_element == 0:
violations.append("WCAG 2.1.1: No visible focus indicators")
# Test tab order
for i in range(min(5, interactive_elements - 1)):
await page.keyboard.press('Tab')
# Check if focus is still visible
focused = await page.locator(':focus').count()
if focused == 0:
violations.append("WCAG 2.1.1: Focus lost during keyboard navigation")
break
async def _test_aria_compliance(self, page: PageWithRetry, violations):
"""Test ARIA implementation compliance"""
# Test for invalid ARIA attributes
elements_with_aria = await page.locator('[aria-label], [aria-labelledby], [aria-describedby]').all()
for element in elements_with_aria[:5]: # Test first 5
try:
# Check if aria-labelledby references exist
labelledby = await element.get_attribute('aria-labelledby')
if labelledby:
referenced_element = await page.locator(f'#{labelledby}').count()
if referenced_element == 0:
violations.append(f"WCAG 4.1.2: aria-labelledby references non-existent element")
# Check if aria-describedby references exist
describedby = await element.get_attribute('aria-describedby')
if describedby:
referenced_element = await page.locator(f'#{describedby}').count()
if referenced_element == 0:
violations.append(f"WCAG 4.1.2: aria-describedby references non-existent element")
except:
pass
@task(1)
@pw
async def test_form_accessibility_flow(self, page: PageWithRetry):
"""Test complete form accessibility flow"""
async with event(self, "Form Accessibility Flow"):
await page.goto('/')
# Find forms on the page
forms = await page.locator('form').all()
for form in forms[:2]: # Test first 2 forms
try:
# Test form field accessibility
inputs = await form.locator('input:not([type="hidden"]), textarea, select').all()
for input_element in inputs:
# Test if input has proper labeling
aria_label = await input_element.get_attribute('aria-label')
aria_labelledby = await input_element.get_attribute('aria-labelledby')
input_id = await input_element.get_attribute('id')
has_label = False
if aria_label or aria_labelledby:
has_label = True
elif input_id:
label_for_input = await page.locator(f'label[for="{input_id}"]').count()
if label_for_input > 0:
has_label = True
if not has_label:
raise Exception("❌ Form input missing accessible label")
# Test focus management
await input_element.click()
focused = await page.locator(':focus').count()
if focused == 0:
raise Exception("❌ Form input not focusable")
except Exception as e:
if "accessibility" in str(e).lower():
raise e
pass
def on_stop(self):
"""Final WCAG compliance report"""
print("\n" + "="*50)
print("WCAG COMPLIANCE TEST COMPLETE")
print("="*50)
print(f"Pages tested: {len(self.pages_crawled)}")
print(f"WCAG violations found: {len(self.wcag_violations)}")
if self.wcag_violations:
print("\nWCAG VIOLATIONS:")
for violation in self.wcag_violations[:10]:
print(f"❌ {violation}")
else:
print("✅ WCAG 2.1 AA Compliant!")
Advanced Screen Reader Simulation
from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event
class ScreenReaderSimulation(PlaywrightUser):
@task(2)
@pw
async def simulate_screen_reader_navigation(self, page: PageWithRetry):
"""Simulate screen reader navigation through page content"""
async with event(self, "Screen Reader Simulation"):
await page.goto('/')
# Test heading navigation (screen readers jump between headings)
headings = await page.locator('h1, h2, h3, h4, h5, h6').all()
if not headings:
raise Exception("❌ No headings found for screen reader navigation")
# Simulate screen reader jumping through headings
for heading in headings:
try:
# Focus on heading
await heading.focus()
# Get heading text
heading_text = await heading.text_content()
# Check if heading is meaningful
if not heading_text or heading_text.strip() == "":
raise Exception("❌ Empty heading found - poor screen reader experience")
# Check heading level
tag_name = await heading.evaluate('el => el.tagName.toLowerCase()')
except Exception as e:
if "screen reader" in str(e).lower():
raise e
pass
# Test landmark navigation
landmarks = await page.locator('[role="main"], [role="navigation"], [role="banner"], [role="contentinfo"], main, nav, header, footer').count()
if landmarks < 2:
raise Exception("❌ Insufficient landmarks for screen reader navigation")
@task(1)
@pw
async def test_aria_live_regions(self, page: PageWithRetry):
"""Test ARIA live regions for dynamic content announcements"""
async with event(self, "ARIA Live Regions Test"):
await page.goto('/')
# Look for buttons that might trigger dynamic content
trigger_buttons = await page.locator('button, [role="button"]').all()
for button in trigger_buttons[:3]:
try:
# Click button
await button.click()
# Wait for dynamic content
await page.wait_for_timeout(1000)
# Check for ARIA live regions
live_regions = await page.locator('[aria-live="polite"], [aria-live="assertive"]').count()
# Check for dynamic content without live regions
dynamic_content = await page.locator('.alert, .notification, .toast, .message').count()
if dynamic_content > 0 and live_regions == 0:
raise Exception("❌ Dynamic content without ARIA live regions")
# Test that live region content is meaningful
if live_regions > 0:
live_region_text = await page.locator('[aria-live]').first.text_content()
if not live_region_text or live_region_text.strip() == "":
raise Exception("❌ Empty ARIA live region")
except Exception as e:
if "ARIA" in str(e) or "live region" in str(e).lower():
raise e
pass
Key Advanced Features
- Real Browser Interaction: Tests actual focus management and keyboard navigation
- Dynamic Content Testing: Validates ARIA live regions and modal accessibility
- Visual Analysis: Real color contrast and visibility testing
- WCAG 2.1 Compliance: Comprehensive testing against specific WCAG criteria
- Screen Reader Simulation: Tests heading and landmark navigation
- Form Flow Testing: Complete accessible form interaction testing
- LoadForge Integration: Failed accessibility tests show as red in reports
This advanced browser-based testing catches accessibility issues that HTML parsing alone cannot detect, providing comprehensive WCAG compliance validation through real user interaction simulation.