/** * 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' } };