/** * Accessibility auditor — lightweight a11y checks using Playwright's aria snapshot * and manual DOM queries. No axe-core dependency needed. * * Checks: * 1. Images without alt text * 2. Form inputs without associated labels * 3. Buttons/links with no accessible name * 4. Missing lang attribute on html element * 5. Missing page title * 6. Empty headings * 7. Color contrast (limited — detects text with inline style color only) * 8. Keyboard-unfocusable interactive elements (tabIndex=-1 on buttons) */ import type { Page } from 'playwright'; export interface A11yViolation { rule: string; severity: 'critical' | 'serious' | 'moderate' | 'minor'; count: number; examples: string[]; } export interface A11yAuditResult { url: string; violations: A11yViolation[]; passCount: number; score: number; // 0-100 auditedAt: string; } async function queryAll(page: Page, selector: string): Promise { try { return await page.$$eval(selector, els => els.map((el: any) => ({ outerHTML: el.outerHTML?.slice(0, 200) ?? '', textContent: (el.textContent ?? '').trim().slice(0, 100), tabIndex: el.tabIndex, getAttribute: (a: string) => el.getAttribute(a), }))); } catch { return []; } } export async function auditAccessibility(page: Page, url: string): Promise { const violations: A11yViolation[] = []; let passCount = 0; const auditedAt = new Date().toISOString(); // 1. Images without alt const imgs = await page.$$eval('img:not([alt])', els => els.map((e: any) => e.outerHTML?.slice(0, 120) ?? '')); if (imgs.length > 0) violations.push({ rule: 'image-alt', severity: 'critical', count: imgs.length, examples: imgs.slice(0, 3) }); else passCount++; // 2. Inputs without labels const unlabeledInputs = await page.$$eval( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([aria-label]):not([aria-labelledby]):not([id])', els => els.map((e: any) => e.outerHTML?.slice(0, 120) ?? ''), ); if (unlabeledInputs.length > 0) violations.push({ rule: 'label', severity: 'serious', count: unlabeledInputs.length, examples: unlabeledInputs.slice(0, 3) }); else passCount++; // 3. Buttons with no text const emptyButtons = await page.$$eval( 'button:not([aria-label]):not([aria-labelledby])', els => els.filter((e: any) => !(e.textContent ?? '').trim()).map((e: any) => e.outerHTML?.slice(0, 120) ?? ''), ); if (emptyButtons.length > 0) violations.push({ rule: 'button-name', severity: 'critical', count: emptyButtons.length, examples: emptyButtons.slice(0, 3) }); else passCount++; // 4. Missing html lang const htmlLang = await page.$eval('html', (el: any) => el.getAttribute('lang') ?? '').catch(() => ''); if (!htmlLang) violations.push({ rule: 'html-has-lang', severity: 'serious', count: 1, examples: [' missing lang attribute'] }); else passCount++; // 5. Missing page title const title = await page.title().catch(() => ''); if (!title) violations.push({ rule: 'document-title', severity: 'serious', count: 1, examples: ['Page has no '] }); else passCount++; // 6. Empty headings const emptyHeadings = await page.$$eval( 'h1,h2,h3,h4,h5,h6', els => els.filter((e: any) => !(e.textContent ?? '').trim()).map((e: any) => e.tagName.toLowerCase()), ); if (emptyHeadings.length > 0) violations.push({ rule: 'empty-heading', severity: 'moderate', count: emptyHeadings.length, examples: emptyHeadings.slice(0, 3) }); else passCount++; // 7. Links with no accessible name const emptyLinks = await page.$$eval( 'a[href]:not([aria-label]):not([aria-labelledby])', els => els.filter((e: any) => !(e.textContent ?? '').trim() && !e.querySelector('img[alt]')).map((e: any) => e.outerHTML?.slice(0, 120) ?? ''), ); if (emptyLinks.length > 0) violations.push({ rule: 'link-name', severity: 'serious', count: emptyLinks.length, examples: emptyLinks.slice(0, 3) }); else passCount++; const totalChecks = passCount + violations.length; const score = totalChecks > 0 ? Math.round((passCount / totalChecks) * 100) : 100; return { url, violations, passCount, score, auditedAt }; }