const { test, expect } = require('@playwright/test'); const { AxeBuilder } = require('@axe-core/playwright'); const PRODUCTION_URL = 'https://colinknapp.com'; const LOCAL_URL = 'http://localhost:8080'; async function getPageUrl(page) { try { // Try production first await page.goto(PRODUCTION_URL, { timeout: 60000 }); return PRODUCTION_URL; } catch (error) { console.log('Production site not available, falling back to local'); await page.goto(LOCAL_URL, { timeout: 60000 }); return LOCAL_URL; } } test.describe('Accessibility Tests', () => { test('should pass WCAG 2.1 Level AAA standards', async ({ page }) => { const url = await getPageUrl(page); console.log(`Running accessibility tests against ${url}`); // Run axe accessibility tests const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag2aaa']) .analyze(); // Check for any violations expect(results.violations).toHaveLength(0); }); test('should have proper ARIA attributes for theme toggle', async ({ page }) => { const url = await getPageUrl(page); console.log(`Running ARIA tests against ${url}`); // Check theme toggle button const themeToggle = await page.locator('#themeToggle'); expect(await themeToggle.getAttribute('aria-label')).toBe('Theme mode: Auto'); expect(await themeToggle.getAttribute('role')).toBe('switch'); expect(await themeToggle.getAttribute('aria-checked')).toBe('false'); expect(await themeToggle.getAttribute('title')).toBe('Toggle between light, dark, and auto theme modes'); expect(await themeToggle.getAttribute('tabindex')).toBe('0'); }); test('should have proper heading structure', async ({ page }) => { const url = await getPageUrl(page); console.log(`Running heading structure tests against ${url}`); // Check main content area const mainContent = await page.locator('.container-fluid'); expect(await mainContent.getAttribute('role')).toBe('main'); // Check heading hierarchy const h1 = await page.locator('h1'); expect(await h1.count()).toBe(1); const h2s = await page.locator('h2'); expect(await h2s.count()).toBeGreaterThan(0); }); test('should have working external links', async ({ page, request }) => { const url = await getPageUrl(page); console.log(`Running link validation tests against ${url}`); await page.goto(url, { timeout: 60000 }); await page.waitForLoadState('networkidle'); // Get all external links const externalLinks = await page.$$('a[href^="http"]:not([href^="http://localhost"])'); // Skip test if no external links found if (externalLinks.length === 0) { console.log('No external links found, skipping test'); return; } const brokenLinks = []; for (const link of externalLinks) { const href = await link.getAttribute('href'); if (!href) continue; try { const response = await request.head(href); if (response.status() >= 400) { brokenLinks.push({ href, status: response.status() }); } } catch (error) { brokenLinks.push({ href, error: error.message }); } } if (brokenLinks.length > 0) { console.log('\nBroken or inaccessible links:'); brokenLinks.forEach(link => { if (link.error) { console.log(`- ${link.href}: ${link.error}`); } else { console.log(`- ${link.href}: HTTP ${link.status}`); } }); throw new Error('Some external links are broken or inaccessible'); } }); test('should have proper color contrast', async ({ page }) => { const url = await getPageUrl(page); console.log(`Running color contrast tests against ${url}`); await page.goto(url, { timeout: 60000 }); await page.waitForLoadState('networkidle'); // Check text color contrast in both light and dark modes const contrastInfo = await page.evaluate(() => { const getContrastRatio = (color1, color2) => { const 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; }; const parseColor = (color) => { const rgb = color.match(/\d+/g).map(Number); return rgb.length === 3 ? rgb : [0, 0, 0]; }; const l1 = getLuminance(...parseColor(color1)); const l2 = getLuminance(...parseColor(color2)); const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); return ratio.toFixed(2); }; const style = getComputedStyle(document.body); const textColor = style.color; const backgroundColor = style.backgroundColor; const contrastRatio = getContrastRatio(textColor, backgroundColor); return { textColor, backgroundColor, contrastRatio: parseFloat(contrastRatio) }; }); console.log('Color contrast information:', contrastInfo); // WCAG 2.1 Level AAA requires a contrast ratio of at least 7:1 for normal text expect(contrastInfo.contrastRatio).toBeGreaterThanOrEqual(7); }); test('should have alt text for all images', async ({ page }) => { const url = await getPageUrl(page); console.log(`Running image alt text tests against ${url}`); await page.goto(url, { timeout: 60000 }); await page.waitForLoadState('networkidle'); // Get all images const images = await page.$$('img'); // Skip test if no images found if (images.length === 0) { console.log('No images found, skipping test'); return; } const missingAlt = []; for (const img of images) { const alt = await img.getAttribute('alt'); const src = await img.getAttribute('src'); // Skip decorative images (empty alt is fine) const role = await img.getAttribute('role'); if (role === 'presentation' || role === 'none') { continue; } if (!alt) { missingAlt.push(src); } } if (missingAlt.length > 0) { console.log('\nImages missing alt text:'); missingAlt.forEach(src => console.log(`- ${src}`)); throw new Error('Some images are missing alt text'); } }); });