upskill-event-manager/tests/e2e/mobile-accessibility.test.js
Ben 7c9ca65cf2
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
feat: add comprehensive test framework and test files
- 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>
2025-08-29 23:23:26 -03:00

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