Files
CompanySite/tests/accessibility.spec.ts
T
Clintchiz d402256547
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
First Init
2026-03-21 16:46:46 +05:30

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