Some checks are pending
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Waiting to run
Security Monitoring & Compliance / Secrets & Credential Scan (push) Waiting to run
Security Monitoring & Compliance / WordPress Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Static Code Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Security Compliance Validation (push) Waiting to run
Security Monitoring & Compliance / Security Summary Report (push) Blocked by required conditions
Security Monitoring & Compliance / Security Team Notification (push) Blocked by required conditions
- Add 90+ test files including E2E, unit, and integration tests - Implement Page Object Model (POM) architecture - Add Docker testing environment with comprehensive services - Include modernized test framework with error recovery - Add specialized test suites for master trainer and trainer workflows - Update .gitignore to properly track test infrastructure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
753 lines
No EOL
30 KiB
JavaScript
753 lines
No EOL
30 KiB
JavaScript
/**
|
|
* Mobile & Accessibility Test Suite
|
|
*
|
|
* Tests mobile responsiveness and accessibility compliance after CSS consolidation:
|
|
* - Mobile responsiveness with reduced CSS (250+ files → 5 bundles)
|
|
* - Touch interactions and gesture support
|
|
* - Screen reader compatibility (WCAG 2.1 AA compliance)
|
|
* - Keyboard navigation
|
|
* - Color contrast and visual accessibility
|
|
* - Progressive enhancement
|
|
* - Viewport and orientation handling
|
|
*
|
|
* @package HVAC_Community_Events
|
|
* @version 3.0.0
|
|
* @created 2025-08-20
|
|
*/
|
|
|
|
const { test, expect, authHelpers, devices } = require('../helpers/auth-fixtures');
|
|
const path = require('path');
|
|
|
|
// Test configuration
|
|
const BASE_URL = process.env.UPSKILL_STAGING_URL || 'https://upskill-staging.measurequick.com';
|
|
const TEST_TIMEOUT = 90000;
|
|
|
|
// Mobile device configurations
|
|
const MOBILE_DEVICES = [
|
|
{ name: 'iPhone 12', ...devices['iPhone 12'] },
|
|
{ name: 'Pixel 5', ...devices['Pixel 5'] },
|
|
{ name: 'iPad Pro', ...devices['iPad Pro'] },
|
|
{ name: 'Galaxy S21', viewport: { width: 360, height: 800 } }
|
|
];
|
|
|
|
// Accessibility test criteria (WCAG 2.1 AA)
|
|
const ACCESSIBILITY_CRITERIA = {
|
|
colorContrast: {
|
|
normal: 4.5, // AA standard for normal text
|
|
large: 3.0 // AA standard for large text (18pt+ or 14pt+ bold)
|
|
},
|
|
timing: {
|
|
maxTimeout: 20000, // 20 seconds max for any timeout
|
|
minResponseTime: 100 // Minimum response time for interactions
|
|
},
|
|
navigation: {
|
|
maxTabStops: 50, // Reasonable number of tab stops per page
|
|
minTouchTarget: 44 // Minimum touch target size (44x44px)
|
|
}
|
|
};
|
|
|
|
// Test pages for mobile/accessibility testing
|
|
const TEST_PAGES = [
|
|
'/trainer/dashboard/',
|
|
'/trainer/profile/',
|
|
'/trainer/events/',
|
|
'/trainer/events/create/',
|
|
'/trainer/certificate-reports/',
|
|
'/find-trainer/'
|
|
];
|
|
|
|
// Helper functions
|
|
async function loginAsTrainer(page) {
|
|
await authHelpers.loginAs(page, 'trainer');
|
|
}
|
|
|
|
async function testColorContrast(page) {
|
|
return await page.evaluate((criteria) => {
|
|
// Function to calculate relative luminance
|
|
function getLuminance(r, g, b) {
|
|
const [rs, gs, bs] = [r, g, b].map(c => {
|
|
c = c / 255;
|
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
});
|
|
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
}
|
|
|
|
// Function to calculate contrast ratio
|
|
function getContrastRatio(color1, color2) {
|
|
const lum1 = getLuminance(...color1);
|
|
const lum2 = getLuminance(...color2);
|
|
const brightest = Math.max(lum1, lum2);
|
|
const darkest = Math.min(lum1, lum2);
|
|
return (brightest + 0.05) / (darkest + 0.05);
|
|
}
|
|
|
|
// Function to parse RGB color
|
|
function parseColor(colorStr) {
|
|
const rgb = colorStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
return rgb ? [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])] : [0, 0, 0];
|
|
}
|
|
|
|
const textElements = document.querySelectorAll('p, span, a, button, label, h1, h2, h3, h4, h5, h6');
|
|
const contrastIssues = [];
|
|
|
|
textElements.forEach((element, index) => {
|
|
if (index > 50) return; // Limit testing to first 50 elements
|
|
|
|
const styles = window.getComputedStyle(element);
|
|
const textColor = parseColor(styles.color);
|
|
const backgroundColor = parseColor(styles.backgroundColor);
|
|
|
|
// If background is transparent, try to find parent background
|
|
let parentBg = backgroundColor;
|
|
if (backgroundColor.every(c => c === 0)) {
|
|
let parent = element.parentElement;
|
|
while (parent && parentBg.every(c => c === 0)) {
|
|
const parentStyles = window.getComputedStyle(parent);
|
|
parentBg = parseColor(parentStyles.backgroundColor);
|
|
parent = parent.parentElement;
|
|
}
|
|
// Default to white if no background found
|
|
if (parentBg.every(c => c === 0)) {
|
|
parentBg = [255, 255, 255];
|
|
}
|
|
}
|
|
|
|
const contrast = getContrastRatio(textColor, parentBg);
|
|
const fontSize = parseFloat(styles.fontSize);
|
|
const fontWeight = styles.fontWeight;
|
|
|
|
const isLargeText = fontSize >= 18 || (fontSize >= 14 && (fontWeight === 'bold' || parseInt(fontWeight) >= 700));
|
|
const requiredContrast = isLargeText ? criteria.colorContrast.large : criteria.colorContrast.normal;
|
|
|
|
if (contrast < requiredContrast) {
|
|
contrastIssues.push({
|
|
element: element.tagName + (element.className ? '.' + element.className.split(' ')[0] : ''),
|
|
contrast: contrast.toFixed(2),
|
|
required: requiredContrast,
|
|
fontSize: fontSize,
|
|
isLargeText
|
|
});
|
|
}
|
|
});
|
|
|
|
return contrastIssues;
|
|
}, ACCESSIBILITY_CRITERIA);
|
|
}
|
|
|
|
async function testKeyboardNavigation(page) {
|
|
const navigationResults = {
|
|
tabStops: 0,
|
|
focusableElements: [],
|
|
skipLinks: false,
|
|
focusTraps: false
|
|
};
|
|
|
|
// Test tab navigation
|
|
let currentElement = null;
|
|
let tabCount = 0;
|
|
const maxTabs = ACCESSIBILITY_CRITERIA.navigation.maxTabStops;
|
|
|
|
while (tabCount < maxTabs) {
|
|
await page.keyboard.press('Tab');
|
|
tabCount++;
|
|
|
|
const focused = await page.evaluate(() => {
|
|
const active = document.activeElement;
|
|
return active ? {
|
|
tagName: active.tagName,
|
|
type: active.type || null,
|
|
text: active.textContent?.trim().substring(0, 50) || '',
|
|
className: active.className || '',
|
|
id: active.id || '',
|
|
role: active.getAttribute('role') || null
|
|
} : null;
|
|
});
|
|
|
|
if (focused) {
|
|
navigationResults.focusableElements.push(focused);
|
|
}
|
|
|
|
// Break if we've cycled back to the beginning
|
|
if (focused && JSON.stringify(focused) === JSON.stringify(currentElement)) {
|
|
break;
|
|
}
|
|
|
|
if (tabCount === 1) {
|
|
currentElement = focused;
|
|
}
|
|
}
|
|
|
|
navigationResults.tabStops = tabCount;
|
|
|
|
// Check for skip links
|
|
await page.keyboard.press('Tab');
|
|
const skipLink = await page.evaluate(() => {
|
|
const active = document.activeElement;
|
|
return active && (
|
|
active.textContent?.toLowerCase().includes('skip') ||
|
|
active.getAttribute('href') === '#main' ||
|
|
active.className.includes('skip')
|
|
);
|
|
});
|
|
|
|
navigationResults.skipLinks = !!skipLink;
|
|
|
|
return navigationResults;
|
|
}
|
|
|
|
async function testTouchTargets(page) {
|
|
return await page.evaluate((minSize) => {
|
|
const interactive = document.querySelectorAll('button, a, input, select, textarea, [onclick], [role="button"]');
|
|
const smallTargets = [];
|
|
|
|
interactive.forEach(element => {
|
|
const rect = element.getBoundingClientRect();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
if (rect.width < minSize || rect.height < minSize) {
|
|
smallTargets.push({
|
|
element: element.tagName + (element.className ? '.' + element.className.split(' ')[0] : ''),
|
|
width: Math.round(rect.width),
|
|
height: Math.round(rect.height),
|
|
text: element.textContent?.trim().substring(0, 30) || ''
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return smallTargets;
|
|
}, ACCESSIBILITY_CRITERIA.navigation.minTouchTarget);
|
|
}
|
|
|
|
async function testAriaLabels(page) {
|
|
return await page.evaluate(() => {
|
|
const results = {
|
|
missingLabels: [],
|
|
goodLabels: 0,
|
|
landmarks: []
|
|
};
|
|
|
|
// Check interactive elements for proper labeling
|
|
const interactive = document.querySelectorAll('button, a, input, select, textarea');
|
|
interactive.forEach(element => {
|
|
const hasLabel = !!(
|
|
element.getAttribute('aria-label') ||
|
|
element.getAttribute('aria-labelledby') ||
|
|
element.textContent?.trim() ||
|
|
(element.tagName === 'INPUT' && element.type === 'submit' && element.value) ||
|
|
(element.tagName === 'INPUT' && document.querySelector(`label[for="${element.id}"]`))
|
|
);
|
|
|
|
if (hasLabel) {
|
|
results.goodLabels++;
|
|
} else {
|
|
results.missingLabels.push({
|
|
element: element.tagName,
|
|
type: element.type || null,
|
|
className: element.className || '',
|
|
id: element.id || ''
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for landmark roles
|
|
const landmarks = document.querySelectorAll('[role="main"], [role="navigation"], [role="banner"], [role="contentinfo"], main, nav, header, footer');
|
|
results.landmarks = Array.from(landmarks).map(landmark => ({
|
|
tagName: landmark.tagName,
|
|
role: landmark.getAttribute('role') || landmark.tagName.toLowerCase()
|
|
}));
|
|
|
|
return results;
|
|
});
|
|
}
|
|
|
|
async function testScreenReaderContent(page) {
|
|
return await page.evaluate(() => {
|
|
const results = {
|
|
headingStructure: [],
|
|
altTexts: { missing: 0, present: 0 },
|
|
hiddenContent: []
|
|
};
|
|
|
|
// Check heading structure
|
|
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
headings.forEach(heading => {
|
|
results.headingStructure.push({
|
|
level: parseInt(heading.tagName.charAt(1)),
|
|
text: heading.textContent?.trim().substring(0, 50) || '',
|
|
id: heading.id || ''
|
|
});
|
|
});
|
|
|
|
// Check image alt texts
|
|
const images = document.querySelectorAll('img');
|
|
images.forEach(img => {
|
|
if (img.getAttribute('alt') !== null) {
|
|
results.altTexts.present++;
|
|
} else {
|
|
results.altTexts.missing++;
|
|
}
|
|
});
|
|
|
|
// Check for screen reader only content
|
|
const srOnly = document.querySelectorAll('.sr-only, .screen-reader-text, [aria-hidden="true"]');
|
|
results.hiddenContent = Array.from(srOnly).map(element => ({
|
|
tagName: element.tagName,
|
|
className: element.className || '',
|
|
text: element.textContent?.trim().substring(0, 30) || ''
|
|
}));
|
|
|
|
return results;
|
|
});
|
|
}
|
|
|
|
async function takeMobileScreenshot(page, name, device) {
|
|
const screenshotDir = path.join(__dirname, '../../screenshots/mobile-accessibility');
|
|
await require('fs').promises.mkdir(screenshotDir, { recursive: true });
|
|
|
|
await page.screenshot({
|
|
path: path.join(screenshotDir, `${name}-${device}-${Date.now()}.png`),
|
|
fullPage: true
|
|
});
|
|
}
|
|
|
|
test.describe('Mobile & Accessibility Tests', () => {
|
|
test.setTimeout(TEST_TIMEOUT);
|
|
|
|
test.describe('Mobile Responsiveness Tests', () => {
|
|
MOBILE_DEVICES.forEach(device => {
|
|
test(`should display correctly on ${device.name}`, async ({ browser }) => {
|
|
const context = await browser.newContext({
|
|
...device,
|
|
// Override locale and timezone if needed
|
|
locale: 'en-US',
|
|
timezoneId: 'America/New_York'
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
|
|
try {
|
|
await loginAsTrainer(page);
|
|
|
|
// Test key pages on this device
|
|
for (const pagePath of TEST_PAGES.slice(0, 4)) { // Test subset for speed
|
|
await page.goto(`${BASE_URL}${pagePath}`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
// Check viewport utilization
|
|
const layoutInfo = await page.evaluate(() => {
|
|
const viewport = {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight
|
|
};
|
|
|
|
const content = document.querySelector('.hvac-page-wrapper, .hvac-content, main');
|
|
const contentRect = content ? content.getBoundingClientRect() : null;
|
|
|
|
return {
|
|
viewport,
|
|
contentWidth: contentRect ? contentRect.width : 0,
|
|
hasHorizontalScroll: document.documentElement.scrollWidth > viewport.width,
|
|
hasOverflow: contentRect ? contentRect.width > viewport.width : false
|
|
};
|
|
});
|
|
|
|
// Should not have horizontal scroll
|
|
expect(layoutInfo.hasHorizontalScroll).toBeFalsy();
|
|
|
|
// Content should fit within viewport
|
|
expect(layoutInfo.hasOverflow).toBeFalsy();
|
|
|
|
// Content should use reasonable amount of viewport width
|
|
const widthUtilization = layoutInfo.contentWidth / layoutInfo.viewport.width;
|
|
expect(widthUtilization).toBeGreaterThan(0.8); // At least 80% width usage
|
|
expect(widthUtilization).toBeLessThanOrEqual(1.0); // Not exceeding viewport
|
|
|
|
console.log(`${device.name} - ${pagePath}: width utilization ${(widthUtilization * 100).toFixed(1)}%`);
|
|
}
|
|
|
|
await takeMobileScreenshot(page, 'responsive-test', device.name.toLowerCase().replace(/\s+/g, '-'));
|
|
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test('should handle orientation changes gracefully', async ({ browser }) => {
|
|
const device = devices['iPad Pro'];
|
|
|
|
// Test portrait
|
|
const portraitContext = await browser.newContext({
|
|
...device,
|
|
viewport: { width: device.viewport.height, height: device.viewport.width } // Swap dimensions
|
|
});
|
|
const portraitPage = await portraitContext.newPage();
|
|
|
|
await loginAsTrainer(portraitPage);
|
|
await portraitPage.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
|
|
const portraitLayout = await portraitPage.evaluate(() => {
|
|
const nav = document.querySelector('.hvac-nav-menu, .hvac-trainer-nav');
|
|
const content = document.querySelector('.hvac-content, main');
|
|
|
|
return {
|
|
navVisible: nav ? window.getComputedStyle(nav).display !== 'none' : false,
|
|
contentWidth: content ? content.offsetWidth : 0,
|
|
viewportWidth: window.innerWidth
|
|
};
|
|
});
|
|
|
|
// Test landscape
|
|
const landscapeContext = await browser.newContext(device);
|
|
const landscapePage = await landscapeContext.newPage();
|
|
|
|
await loginAsTrainer(landscapePage);
|
|
await landscapePage.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
|
|
const landscapeLayout = await landscapePage.evaluate(() => {
|
|
const nav = document.querySelector('.hvac-nav-menu, .hvac-trainer-nav');
|
|
const content = document.querySelector('.hvac-content, main');
|
|
|
|
return {
|
|
navVisible: nav ? window.getComputedStyle(nav).display !== 'none' : false,
|
|
contentWidth: content ? content.offsetWidth : 0,
|
|
viewportWidth: window.innerWidth
|
|
};
|
|
});
|
|
|
|
// Both orientations should work
|
|
expect(portraitLayout.contentWidth).toBeGreaterThan(0);
|
|
expect(landscapeLayout.contentWidth).toBeGreaterThan(0);
|
|
|
|
console.log('Orientation testing:', {
|
|
portrait: portraitLayout,
|
|
landscape: landscapeLayout
|
|
});
|
|
|
|
await portraitContext.close();
|
|
await landscapeContext.close();
|
|
});
|
|
|
|
test('should handle touch interactions properly', async ({ browser }) => {
|
|
const context = await browser.newContext(devices['iPhone 12']);
|
|
const page = await context.newPage();
|
|
|
|
await loginAsTrainer(page);
|
|
await page.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
|
|
// Test touch targets
|
|
const touchTargets = await testTouchTargets(page);
|
|
|
|
console.log(`Small touch targets found: ${touchTargets.length}`);
|
|
if (touchTargets.length > 0) {
|
|
console.log('Small targets sample:', touchTargets.slice(0, 3));
|
|
}
|
|
|
|
// Should have minimal small touch targets
|
|
expect(touchTargets.length).toBeLessThan(5);
|
|
|
|
// Test mobile navigation
|
|
const hamburger = await page.$('.hvac-menu-toggle, .menu-toggle, .hamburger');
|
|
if (hamburger) {
|
|
// Test touch interaction
|
|
await hamburger.tap();
|
|
await page.waitForTimeout(500);
|
|
|
|
const menuOpened = await page.evaluate(() => {
|
|
const menu = document.querySelector('.hvac-nav-menu');
|
|
return menu ? window.getComputedStyle(menu).display !== 'none' : false;
|
|
});
|
|
|
|
expect(menuOpened).toBeTruthy();
|
|
|
|
// Test closing
|
|
await hamburger.tap();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
await context.close();
|
|
});
|
|
});
|
|
|
|
test.describe('Accessibility Compliance Tests', () => {
|
|
test('should meet WCAG 2.1 AA color contrast requirements', async ({ page }) => {
|
|
await loginAsTrainer(page);
|
|
|
|
const pagesToTest = TEST_PAGES.slice(0, 4); // Test subset
|
|
const allContrastIssues = [];
|
|
|
|
for (const pagePath of pagesToTest) {
|
|
await page.goto(`${BASE_URL}${pagePath}`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
const contrastIssues = await testColorContrast(page);
|
|
allContrastIssues.push(...contrastIssues);
|
|
|
|
console.log(`Contrast issues on ${pagePath}: ${contrastIssues.length}`);
|
|
}
|
|
|
|
// Should have minimal contrast issues
|
|
expect(allContrastIssues.length).toBeLessThan(10);
|
|
|
|
if (allContrastIssues.length > 0) {
|
|
console.log('Contrast issues sample:', allContrastIssues.slice(0, 3));
|
|
}
|
|
});
|
|
|
|
test('should support keyboard navigation', async ({ page }) => {
|
|
await loginAsTrainer(page);
|
|
await page.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
|
|
const navigationResults = await testKeyboardNavigation(page);
|
|
|
|
console.log('Keyboard navigation results:', {
|
|
tabStops: navigationResults.tabStops,
|
|
focusableElements: navigationResults.focusableElements.length,
|
|
hasSkipLinks: navigationResults.skipLinks
|
|
});
|
|
|
|
// Should have reasonable number of tab stops
|
|
expect(navigationResults.tabStops).toBeGreaterThan(3);
|
|
expect(navigationResults.tabStops).toBeLessThan(ACCESSIBILITY_CRITERIA.navigation.maxTabStops);
|
|
|
|
// Should have focusable elements
|
|
expect(navigationResults.focusableElements.length).toBeGreaterThan(3);
|
|
|
|
// Test specific keyboard interactions
|
|
await page.keyboard.press('Space'); // Should activate focused element
|
|
await page.keyboard.press('Enter'); // Should activate focused element
|
|
await page.keyboard.press('Escape'); // Should close modals/menus
|
|
|
|
// Page should remain functional after keyboard interaction
|
|
const stillFunctional = await page.evaluate(() => {
|
|
return document.body.style.display !== 'none';
|
|
});
|
|
expect(stillFunctional).toBeTruthy();
|
|
});
|
|
|
|
test('should have proper ARIA labels and landmarks', async ({ page }) => {
|
|
await loginAsTrainer(page);
|
|
|
|
const pagesToTest = TEST_PAGES.slice(0, 3);
|
|
const allAriaResults = [];
|
|
|
|
for (const pagePath of pagesToTest) {
|
|
await page.goto(`${BASE_URL}${pagePath}`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
const ariaResults = await testAriaLabels(page);
|
|
allAriaResults.push({ page: pagePath, ...ariaResults });
|
|
|
|
// Should have good labeling ratio
|
|
const totalInteractive = ariaResults.goodLabels + ariaResults.missingLabels.length;
|
|
const labelingRatio = totalInteractive > 0 ? ariaResults.goodLabels / totalInteractive : 1;
|
|
|
|
expect(labelingRatio).toBeGreaterThan(0.8); // 80% properly labeled
|
|
|
|
// Should have basic landmarks
|
|
expect(ariaResults.landmarks.length).toBeGreaterThan(0);
|
|
|
|
console.log(`ARIA compliance on ${pagePath}:`, {
|
|
labelingRatio: (labelingRatio * 100).toFixed(1) + '%',
|
|
landmarks: ariaResults.landmarks.length,
|
|
missingLabels: ariaResults.missingLabels.length
|
|
});
|
|
}
|
|
});
|
|
|
|
test('should support screen readers', async ({ page }) => {
|
|
await loginAsTrainer(page);
|
|
await page.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
|
|
const screenReaderResults = await testScreenReaderContent(page);
|
|
|
|
console.log('Screen reader compliance:', screenReaderResults);
|
|
|
|
// Should have proper heading structure
|
|
expect(screenReaderResults.headingStructure.length).toBeGreaterThan(0);
|
|
|
|
// Should have more images with alt text than without
|
|
const totalImages = screenReaderResults.altTexts.present + screenReaderResults.altTexts.missing;
|
|
if (totalImages > 0) {
|
|
const altTextRatio = screenReaderResults.altTexts.present / totalImages;
|
|
expect(altTextRatio).toBeGreaterThan(0.7); // 70% of images should have alt text
|
|
}
|
|
|
|
// Check heading hierarchy
|
|
const headingLevels = screenReaderResults.headingStructure.map(h => h.level);
|
|
if (headingLevels.length > 1) {
|
|
// Should start with h1 or h2
|
|
expect(headingLevels[0]).toBeLessThanOrEqual(2);
|
|
|
|
// Should not skip levels dramatically
|
|
const maxJump = Math.max(...headingLevels.slice(1).map((level, i) =>
|
|
level - headingLevels[i]
|
|
));
|
|
expect(maxJump).toBeLessThanOrEqual(2); // No skipping more than 1 level
|
|
}
|
|
});
|
|
|
|
test('should handle focus management properly', async ({ page }) => {
|
|
await loginAsTrainer(page);
|
|
await page.goto(`${BASE_URL}/trainer/events/create/`);
|
|
|
|
// Test focus on form elements
|
|
const formFocus = await page.evaluate(() => {
|
|
const inputs = Array.from(document.querySelectorAll('input, select, textarea, button'));
|
|
const focusableInputs = inputs.filter(input => {
|
|
const style = window.getComputedStyle(input);
|
|
return style.display !== 'none' &&
|
|
style.visibility !== 'hidden' &&
|
|
!input.disabled &&
|
|
input.tabIndex !== -1;
|
|
});
|
|
|
|
return {
|
|
totalInputs: inputs.length,
|
|
focusableInputs: focusableInputs.length,
|
|
firstFocusable: focusableInputs[0] ? {
|
|
tagName: focusableInputs[0].tagName,
|
|
type: focusableInputs[0].type || null,
|
|
id: focusableInputs[0].id || ''
|
|
} : null
|
|
};
|
|
});
|
|
|
|
expect(focusableInputs.length).toBeGreaterThan(0);
|
|
|
|
// Test focus trapping in modals (if any)
|
|
const modals = await page.$$('.modal, .overlay, [role="dialog"]');
|
|
if (modals.length > 0) {
|
|
// Test focus trap
|
|
for (let i = 0; i < 20; i++) {
|
|
await page.keyboard.press('Tab');
|
|
}
|
|
|
|
// Focus should still be within modal
|
|
const focusInModal = await page.evaluate(() => {
|
|
const active = document.activeElement;
|
|
const modal = document.querySelector('.modal, .overlay, [role="dialog"]');
|
|
return modal ? modal.contains(active) : true;
|
|
});
|
|
|
|
expect(focusInModal).toBeTruthy();
|
|
}
|
|
|
|
console.log('Focus management:', focusableInputs);
|
|
});
|
|
});
|
|
|
|
test.describe('Progressive Enhancement Tests', () => {
|
|
test('should work without JavaScript', async ({ browser }) => {
|
|
const context = await browser.newContext({
|
|
javaScriptEnabled: false
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
// Test basic functionality without JS
|
|
await page.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
|
|
const basicFunctionality = await page.evaluate(() => {
|
|
return {
|
|
hasContent: document.body.textContent.length > 100,
|
|
hasNavigation: !!document.querySelector('nav, .navigation, .hvac-nav'),
|
|
hasLinks: document.querySelectorAll('a').length > 0,
|
|
hasForms: document.querySelectorAll('form').length > 0
|
|
};
|
|
});
|
|
|
|
// Should have basic content and navigation
|
|
expect(basicFunctionality.hasContent).toBeTruthy();
|
|
expect(basicFunctionality.hasNavigation).toBeTruthy();
|
|
|
|
console.log('No-JS functionality:', basicFunctionality);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
test('should degrade gracefully with slow connections', async ({ page }) => {
|
|
// Simulate slow 3G connection
|
|
await page.route('**/*', route => {
|
|
setTimeout(() => route.continue(), 1000); // 1 second delay
|
|
});
|
|
|
|
await loginAsTrainer(page);
|
|
|
|
const startTime = Date.now();
|
|
await page.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
const loadTime = Date.now() - startTime;
|
|
|
|
// Should still load within reasonable time
|
|
expect(loadTime).toBeLessThan(30000); // 30 seconds max
|
|
|
|
// Should show content even if some resources fail
|
|
const hasContent = await page.evaluate(() => {
|
|
return document.body.textContent.length > 100;
|
|
});
|
|
|
|
expect(hasContent).toBeTruthy();
|
|
|
|
console.log(`Slow connection load time: ${loadTime}ms`);
|
|
});
|
|
|
|
test('should handle reduced motion preferences', async ({ browser }) => {
|
|
const context = await browser.newContext({
|
|
reducedMotion: 'reduce'
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
await loginAsTrainer(page);
|
|
await page.goto(`${BASE_URL}/trainer/dashboard/`);
|
|
|
|
// Check for animations that respect reduced motion
|
|
const animationInfo = await page.evaluate(() => {
|
|
const animatedElements = document.querySelectorAll('*');
|
|
let animationsFound = 0;
|
|
let respectsReducedMotion = 0;
|
|
|
|
Array.from(animatedElements).forEach(element => {
|
|
const styles = window.getComputedStyle(element);
|
|
if (styles.animationName !== 'none' || styles.transitionProperty !== 'none') {
|
|
animationsFound++;
|
|
|
|
// Check if animation is disabled with prefers-reduced-motion
|
|
if (styles.animationPlayState === 'paused' ||
|
|
styles.animationDuration === '0s' ||
|
|
styles.transitionDuration === '0s') {
|
|
respectsReducedMotion++;
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
animationsFound,
|
|
respectsReducedMotion,
|
|
compliance: animationsFound > 0 ? respectsReducedMotion / animationsFound : 1
|
|
};
|
|
});
|
|
|
|
console.log('Reduced motion compliance:', animationInfo);
|
|
|
|
// Should respect reduced motion preferences
|
|
if (animationInfo.animationsFound > 0) {
|
|
expect(animationInfo.compliance).toBeGreaterThan(0.7); // 70% compliance
|
|
}
|
|
|
|
await context.close();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Export mobile accessibility test configuration
|
|
module.exports = {
|
|
testDir: __dirname,
|
|
timeout: TEST_TIMEOUT,
|
|
retries: 1,
|
|
workers: 1, // Sequential for consistent mobile testing
|
|
use: {
|
|
baseURL: BASE_URL,
|
|
screenshot: 'only-on-failure',
|
|
video: 'retain-on-failure',
|
|
trace: 'on-first-retry'
|
|
}
|
|
}; |