import { test, expect } from '@playwright/test'; const pages = [ { path: '/', name: 'Home' }, { path: '/about', name: 'About' }, { path: '/services', name: 'Services' }, { path: '/portfolio', name: 'Portfolio' }, { path: '/blog', name: 'Blog' }, { path: '/contact', name: 'Contact' }, { path: '/privacy', name: 'Privacy Policy' }, { path: '/terms', name: 'Terms of Service' }, { path: '/sitemap', name: 'Sitemap' }, ]; // ============================================================ // WCAG 2.1 AA - 1.1.1 Non-text Content // ============================================================ test.describe('1.1.1 Non-text Content (Images & Icons)', () => { for (const page of pages) { test(`${page.name}: all images have alt text or role="presentation"`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const images = await browserPage.locator('img').all(); for (const img of images) { const alt = await img.getAttribute('alt'); const role = await img.getAttribute('role'); const hasAccessibleName = alt !== null || role === 'presentation'; expect(hasAccessibleName, `Image missing alt text on ${page.path}`).toBeTruthy(); } }); test(`${page.name}: SVGs are either decorative (aria-hidden) or have accessible labels`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const svgs = await browserPage.locator('svg').all(); for (const svg of svgs) { const ariaHidden = await svg.getAttribute('aria-hidden'); const ariaLabel = await svg.getAttribute('aria-label'); const ariaLabelledby = await svg.getAttribute('aria-labelledby'); const role = await svg.getAttribute('role'); const title = await svg.locator('title').count(); // SVG must be either: hidden from AT or have accessible label const isAccessible = ariaHidden === 'true' || ariaLabel !== null || ariaLabelledby !== null || (role === 'img' && title > 0); expect(isAccessible, `SVG without aria-hidden or accessible label on ${page.path}`).toBeTruthy(); } }); } }); // ============================================================ // WCAG 2.1 AA - 1.3.1 Info and Relationships (Structure) // ============================================================ test.describe('1.3.1 Info and Relationships (Document Structure)', () => { for (const page of pages) { test(`${page.name}: has exactly one h1 heading`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const h1Count = await browserPage.locator('h1').count(); expect(h1Count, `${page.path} should have exactly one h1`).toBeGreaterThanOrEqual(1); expect(h1Count, `${page.path} should not have more than one h1`).toBeLessThanOrEqual(2); }); test(`${page.name}: has semantic landmarks (header, main, footer)`, async ({ page: browserPage }) => { await browserPage.goto(page.path); await expect(browserPage.locator('header').first()).toBeVisible(); await expect(browserPage.locator('main').first()).toBeVisible(); await expect(browserPage.locator('footer').first()).toBeVisible(); }); test(`${page.name}: form inputs have associated labels`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const inputs = await browserPage.locator('input:not([type="hidden"]):not([type="submit"]):not([type="button"])').all(); for (const input of inputs) { const id = await input.getAttribute('id'); const ariaLabel = await input.getAttribute('aria-label'); const ariaLabelledby = await input.getAttribute('aria-labelledby'); let hasLabel = ariaLabel !== null || ariaLabelledby !== null; if (id && !hasLabel) { const labelCount = await browserPage.locator(`label[for="${id}"]`).count(); hasLabel = labelCount > 0; } expect(hasLabel, `Input ${id || 'unknown'} on ${page.path} missing label`).toBeTruthy(); } }); test(`${page.name}: lists use proper list elements`, async ({ page: browserPage }) => { await browserPage.goto(page.path); // Navigation should use list elements const navLists = await browserPage.locator('nav ul, nav ol').count(); // At least one nav should contain a list (header nav) if (await browserPage.locator('nav').count() > 0) { expect(navLists).toBeGreaterThanOrEqual(0); // soft check - not all navs require lists } }); } }); // ============================================================ // WCAG 2.1 AA - 1.3.3 Sensory Characteristics // ============================================================ test.describe('1.3.3 Required fields are not indicated by color alone', () => { test('Contact form: required fields have text indicator beyond asterisk color', async ({ page }) => { await page.goto('/contact'); // Check that required fields use aria-required attribute const requiredInputs = await page.locator('input[required], select[required], textarea[required]').all(); expect(requiredInputs.length).toBeGreaterThan(0); for (const input of requiredInputs) { const ariaRequired = await input.getAttribute('aria-required'); const required = await input.getAttribute('required'); expect(required !== null || ariaRequired === 'true', 'Required field must have required or aria-required attribute').toBeTruthy(); } }); }); // ============================================================ // WCAG 2.1 AA - 1.4.3 Contrast (Minimum) // ============================================================ test.describe('1.4.3 Color Contrast', () => { test('Main text has defined color styles', async ({ page }) => { await page.goto('/'); const heading = page.locator('h1').first(); if (await heading.isVisible()) { const color = await heading.evaluate((el) => window.getComputedStyle(el).color); expect(color).toBeDefined(); expect(color).not.toBe(''); } }); test('Body text has defined color styles', async ({ page }) => { await page.goto('/'); const paragraph = page.locator('p').first(); if (await paragraph.isVisible()) { const styles = await paragraph.evaluate((el) => { const style = window.getComputedStyle(el); return { color: style.color, backgroundColor: style.backgroundColor }; }); expect(styles.color).toBeDefined(); expect(styles.color).not.toBe(''); } }); test('Buttons have distinct background colors', async ({ page }) => { await page.goto('/contact'); const submitBtn = page.locator('button[type="submit"]').first(); if (await submitBtn.isVisible()) { const bgColor = await submitBtn.evaluate((el) => window.getComputedStyle(el).backgroundColor); // Should not be transparent expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); expect(bgColor).not.toBe('transparent'); } }); }); // ============================================================ // WCAG 2.1 AA - 2.1.1 Keyboard Navigation // ============================================================ test.describe('2.1.1 Keyboard Navigation', () => { test('Tab navigation reaches interactive elements', async ({ page }) => { await page.goto('/'); await page.locator('body').focus(); await page.keyboard.press('Tab'); const focusedElement = await page.evaluate(() => document.activeElement?.tagName); expect(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(focusedElement || '')).toBeTruthy(); }); test('Skip link is the first focusable element', async ({ page }) => { await page.goto('/'); await page.keyboard.press('Tab'); const focusedElement = await page.evaluate(() => { const el = document.activeElement as HTMLElement; return { tagName: el?.tagName, href: el?.getAttribute('href'), text: el?.textContent?.trim(), }; }); // Skip link should be focused first expect(focusedElement.tagName).toBe('A'); expect(focusedElement.href).toContain('#main-content'); }); test('Skip link works - activating it focuses main content', async ({ page }) => { await page.goto('/'); // Tab to skip link await page.keyboard.press('Tab'); // Activate skip link await page.keyboard.press('Enter'); // Main content should be focused const focusedId = await page.evaluate(() => document.activeElement?.id); expect(focusedId).toBe('main-content'); }); test('Mobile menu opens and closes with keyboard', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await page.goto('/'); // Find and focus the mobile menu toggle const menuToggle = page.locator('#mobile-menu-toggle'); await menuToggle.focus(); await page.keyboard.press('Enter'); // Menu should open await expect(page.locator('#mobile-menu')).toBeVisible(); // Escape should close the menu await page.keyboard.press('Escape'); // Menu should be closed (translated out of view) const menuTransform = await page.locator('#mobile-menu').evaluate((el) => window.getComputedStyle(el).transform ); // After escape, menu should have translate-x-full applied const ariaExpanded = await menuToggle.getAttribute('aria-expanded'); expect(ariaExpanded).toBe('false'); }); test('FAQ accordion is keyboard operable', async ({ page }) => { await page.goto('/'); const firstFaqButton = page.locator('.faq-question').first(); await firstFaqButton.focus(); await page.keyboard.press('Enter'); const isExpanded = await firstFaqButton.getAttribute('aria-expanded'); expect(isExpanded).toBe('true'); }); test('Portfolio modal can be closed with Escape key', async ({ page }) => { await page.goto('/portfolio'); // Open first project modal const firstProjectCard = page.locator('.project-card').first(); if (await firstProjectCard.count() > 0) { await firstProjectCard.click(); const modal = page.locator('[role="dialog"]'); await expect(modal).toBeVisible(); await page.keyboard.press('Escape'); await expect(modal).not.toBeVisible(); } }); test('All interactive elements are reachable via tab on Contact page', async ({ page }) => { await page.goto('/contact'); const interactiveElements = await page.locator('a, button, input, select, textarea').all(); const visibleInteractive = []; for (const el of interactiveElements) { if (await el.isVisible()) { const tabindex = await el.getAttribute('tabindex'); if (tabindex !== '-1') { visibleInteractive.push(el); } } } expect(visibleInteractive.length).toBeGreaterThan(0); }); }); // ============================================================ // WCAG 2.1 AA - 2.4.3 Focus Order // ============================================================ test.describe('2.4.3 Focus Order', () => { test('Focus order follows logical reading order on Home page', async ({ page }) => { await page.goto('/'); const tabOrder: string[] = []; let maxTabs = 20; // Limit to first 20 tab stops await page.keyboard.press('Tab'); // skip link while (maxTabs-- > 0) { const focused = await page.evaluate(() => { const el = document.activeElement as HTMLElement; return { tag: el?.tagName, text: el?.textContent?.trim()?.substring(0, 30), href: el?.getAttribute('href'), }; }); tabOrder.push(`${focused.tag}:${focused.text || focused.href}`); await page.keyboard.press('Tab'); } // Skip link should appear first expect(tabOrder[0]).toContain('#main-content'); }); }); // ============================================================ // WCAG 2.1 AA - 2.4.6 Headings and Labels // ============================================================ test.describe('2.4.6 Headings and Labels', () => { for (const page of pages) { test(`${page.name}: heading hierarchy is logical (no skipped levels)`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const headings = await browserPage.locator('h1, h2, h3, h4, h5, h6').all(); const levels: number[] = []; for (const heading of headings) { const tag = await heading.evaluate((el) => el.tagName); levels.push(parseInt(tag.replace('H', ''))); } // Check that heading levels don't skip (e.g., h1 -> h3 without h2) for (let i = 1; i < levels.length; i++) { const jump = levels[i] - levels[i - 1]; // Allow going down by any amount or going up, but not jumping more than 1 level expect(jump, `Heading level skips from h${levels[i-1]} to h${levels[i]} on ${page.path}`).toBeLessThanOrEqual(1); } }); test(`${page.name}: all form controls have visible labels`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const selects = await browserPage.locator('select').all(); for (const select of selects) { const id = await select.getAttribute('id'); const ariaLabel = await select.getAttribute('aria-label'); let hasLabel = ariaLabel !== null; if (id && !hasLabel) { const labelCount = await browserPage.locator(`label[for="${id}"]`).count(); hasLabel = labelCount > 0; } expect(hasLabel, `Select ${id || 'unknown'} on ${page.path} missing label`).toBeTruthy(); } }); } }); // ============================================================ // WCAG 2.1 AA - 2.4.7 Focus Visible // ============================================================ test.describe('2.4.7 Focus Visible', () => { test('Navigation links have visible focus indicators', async ({ page }) => { await page.goto('/'); const navLink = page.locator('nav a').first(); await navLink.focus(); const outlineStyle = await navLink.evaluate((el) => { const style = window.getComputedStyle(el); return { outline: style.outline, outlineWidth: style.outlineWidth, boxShadow: style.boxShadow, }; }); const hasFocusIndicator = (outlineStyle.outline !== 'none' && outlineStyle.outline !== '') || (outlineStyle.outlineWidth !== '0px' && outlineStyle.outlineWidth !== '') || (outlineStyle.boxShadow !== 'none' && outlineStyle.boxShadow !== ''); expect(hasFocusIndicator, 'Navigation link must have a visible focus indicator').toBeTruthy(); }); test('Form inputs have visible focus indicators', async ({ page }) => { await page.goto('/contact'); const input = page.locator('#name'); await input.focus(); const focusStyle = await input.evaluate((el) => { const style = window.getComputedStyle(el); return { outline: style.outline, boxShadow: style.boxShadow, borderColor: style.borderColor, }; }); const hasFocusIndicator = (focusStyle.outline !== 'none' && focusStyle.outline !== '') || (focusStyle.boxShadow !== 'none' && focusStyle.boxShadow !== ''); expect(hasFocusIndicator, 'Form input must have a visible focus indicator').toBeTruthy(); }); test('Buttons have visible focus indicators', async ({ page }) => { await page.goto('/contact'); const button = page.locator('button[type="submit"]').first(); await button.focus(); const focusStyle = await button.evaluate((el) => { const style = window.getComputedStyle(el); return { outline: style.outline, boxShadow: style.boxShadow, }; }); const hasFocusIndicator = (focusStyle.outline !== 'none' && focusStyle.outline !== '') || (focusStyle.boxShadow !== 'none' && focusStyle.boxShadow !== ''); expect(hasFocusIndicator, 'Submit button must have a visible focus indicator').toBeTruthy(); }); }); // ============================================================ // WCAG 2.1 AA - 3.3.1 Error Identification // ============================================================ test.describe('3.3.1 Error Identification', () => { test('Contact form shows error messages for invalid submissions', async ({ page }) => { await page.goto('/contact'); // Submit empty form await page.locator('button[type="submit"]').click(); // Error messages should appear const nameError = page.locator('#name-error, [data-error="name"]'); await expect(nameError).toBeVisible(); }); test('Contact form error messages are associated with inputs via aria-describedby', async ({ page }) => { await page.goto('/contact'); const nameInput = page.locator('#name'); const describedBy = await nameInput.getAttribute('aria-describedby'); expect(describedBy).toBeTruthy(); if (describedBy) { const errorEl = page.locator(`#${describedBy}`); const count = await errorEl.count(); expect(count).toBeGreaterThan(0); } }); test('Newsletter form has accessible error/success messaging', async ({ page }) => { await page.goto('/'); const newsletterMessage = page.locator('#newsletter-message'); const ariaLive = await newsletterMessage.getAttribute('aria-live'); expect(ariaLive).toBeTruthy(); }); }); // ============================================================ // WCAG 2.1 AA - 3.3.2 Labels or Instructions // ============================================================ test.describe('3.3.2 Labels or Instructions', () => { test('Contact form has required field indicators', async ({ page }) => { await page.goto('/contact'); // Name field should be marked as required const nameInput = page.locator('#name'); const required = await nameInput.getAttribute('required'); const ariaRequired = await nameInput.getAttribute('aria-required'); expect(required !== null || ariaRequired === 'true').toBeTruthy(); }); test('Newsletter email input has a label', async ({ page }) => { await page.goto('/'); const emailInput = page.locator('#newsletter-email'); const id = await emailInput.getAttribute('id'); const ariaLabel = await emailInput.getAttribute('aria-label'); let hasLabel = ariaLabel !== null; if (id && !hasLabel) { const labelCount = await page.locator(`label[for="${id}"]`).count(); hasLabel = labelCount > 0; } expect(hasLabel, 'Newsletter email input must have a label').toBeTruthy(); }); }); // ============================================================ // WCAG 2.1 AA - 4.1.2 Name, Role, Value (Interactive Components) // ============================================================ test.describe('4.1.2 Name, Role, Value', () => { for (const page of pages) { test(`${page.name}: all buttons have accessible names`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const buttons = await browserPage.locator('button').all(); for (const button of buttons) { const text = await button.textContent(); const ariaLabel = await button.getAttribute('aria-label'); const ariaLabelledby = await button.getAttribute('aria-labelledby'); const title = await button.getAttribute('title'); const hasAccessibleName = (text && text.trim().length > 0) || ariaLabel !== null || ariaLabelledby !== null || title !== null; expect(hasAccessibleName, `Button without accessible name on ${page.path}`).toBeTruthy(); } }); test(`${page.name}: all links have accessible names`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const links = await browserPage.locator('a').all(); for (const link of links) { const text = await link.textContent(); const ariaLabel = await link.getAttribute('aria-label'); const title = await link.getAttribute('title'); const hasImg = (await link.locator('img[alt]').count()) > 0; const hasSvgWithLabel = (await link.locator('svg[aria-label]').count()) > 0; // SVG links with accompanying text (the SVG is aria-hidden) are accessible const hasSvgWithText = (await link.locator('svg[aria-hidden]').count()) > 0 && text && text.trim().length > 0; const hasAccessibleName = (text && text.trim().length > 0) || ariaLabel !== null || title !== null || hasImg || hasSvgWithLabel || hasSvgWithText; expect(hasAccessibleName, `Link without accessible name on ${page.path}: ${await link.getAttribute('href')}`).toBeTruthy(); } }); } test('Mobile menu toggle has correct aria-expanded state', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await page.goto('/'); const toggle = page.locator('#mobile-menu-toggle'); // Initially closed expect(await toggle.getAttribute('aria-expanded')).toBe('false'); // Open menu await toggle.click(); expect(await toggle.getAttribute('aria-expanded')).toBe('true'); // Close menu await toggle.click(); expect(await toggle.getAttribute('aria-expanded')).toBe('false'); }); test('Portfolio filter tabs have correct aria-selected state', async ({ page }) => { await page.goto('/portfolio'); const tablist = page.locator('[role="tablist"]'); await expect(tablist).toBeVisible(); const tabs = page.locator('[role="tab"]'); const tabCount = await tabs.count(); expect(tabCount).toBeGreaterThan(0); // First tab should be selected by default const firstTab = tabs.first(); expect(await firstTab.getAttribute('aria-selected')).toBe('true'); }); test('FAQ accordion buttons have aria-expanded and aria-controls', async ({ page }) => { await page.goto('/'); const faqButtons = page.locator('.faq-question'); const count = await faqButtons.count(); expect(count).toBeGreaterThan(0); for (let i = 0; i < count; i++) { const btn = faqButtons.nth(i); const ariaExpanded = await btn.getAttribute('aria-expanded'); const ariaControls = await btn.getAttribute('aria-controls'); expect(ariaExpanded, `FAQ button ${i} missing aria-expanded`).not.toBeNull(); expect(ariaControls, `FAQ button ${i} missing aria-controls`).not.toBeNull(); // Verify the controlled element exists if (ariaControls) { const controlled = page.locator(`#${ariaControls}`); expect(await controlled.count(), `FAQ answer ${ariaControls} not found`).toBeGreaterThan(0); } } }); test('Testimonials carousel has aria-roledescription', async ({ page }) => { await page.goto('/'); const carousel = page.locator('[aria-roledescription="carousel"]'); await expect(carousel).toBeAttached(); }); test('Mobile menu dialog has correct ARIA attributes', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await page.goto('/'); const mobileMenu = page.locator('#mobile-menu'); const role = await mobileMenu.getAttribute('role'); const ariaModal = await mobileMenu.getAttribute('aria-modal'); const ariaLabel = await mobileMenu.getAttribute('aria-label'); expect(role).toBe('dialog'); expect(ariaModal).toBe('true'); expect(ariaLabel).toBeTruthy(); }); }); // ============================================================ // WCAG 2.1 AA - 4.1.3 Status Messages // ============================================================ test.describe('4.1.3 Status Messages', () => { test('Toast container has aria-live region', async ({ page }) => { await page.goto('/contact'); const toastContainer = page.locator('#toast-container'); const ariaLive = await toastContainer.getAttribute('aria-live'); expect(ariaLive).toBeTruthy(); }); test('Newsletter message has aria-live region', async ({ page }) => { await page.goto('/'); const messageEl = page.locator('#newsletter-message'); const ariaLive = await messageEl.getAttribute('aria-live'); expect(ariaLive).not.toBeNull(); }); }); // ============================================================ // Additional: Skip Navigation // ============================================================ test.describe('Skip Navigation', () => { test('Skip link is present in the DOM', async ({ page }) => { await page.goto('/'); const skipLink = page.locator('a[href="#main-content"]'); await expect(skipLink).toBeAttached(); }); test('Main content landmark has id="main-content"', async ({ page }) => { await page.goto('/'); const main = page.locator('#main-content'); await expect(main).toBeAttached(); const tag = await main.evaluate((el) => el.tagName.toLowerCase()); expect(tag).toBe('main'); }); test('Skip link becomes visible on focus', async ({ page }) => { await page.goto('/'); const skipLink = page.locator('a[href="#main-content"]'); await skipLink.focus(); // After focus, the link should be visible (not sr-only) const isVisible = await skipLink.isVisible(); expect(isVisible).toBeTruthy(); }); }); // ============================================================ // Additional: Reduced Motion // ============================================================ test.describe('Reduced Motion', () => { test('Animations respect prefers-reduced-motion media query', async ({ page }) => { // Enable prefers-reduced-motion await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/'); // Page should still load and be functional await expect(page.locator('h1').first()).toBeVisible(); await expect(page.locator('nav')).toBeVisible(); }); }); // ============================================================ // Additional: Page Language // ============================================================ test.describe('Language Attribute', () => { for (const page of pages) { test(`${page.name}: html element has lang attribute`, async ({ page: browserPage }) => { await browserPage.goto(page.path); const lang = await browserPage.locator('html').getAttribute('lang'); expect(lang).toBeTruthy(); expect(lang).toMatch(/^[a-z]{2}/); // Valid ISO language code }); } });