Explorer reports addition
We have added a new Explorer feature to reports, with a timeline scrubber and easy anomaly detection.
Advanced WCAG compliance and accessibility testing using Playwright browser automation
LoadForge can record your browser, graphically build tests, scan your site with a wizard and more. Sign up now to run your first test.
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.
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!")
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!")
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
This advanced browser-based testing catches accessibility issues that HTML parsing alone cannot detect, providing comprehensive WCAG compliance validation through real user interaction simulation.