Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped
713 lines
26 KiB
TypeScript
713 lines
26 KiB
TypeScript
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
|
|
});
|
|
}
|
|
});
|