/** * Accessibility tree crawler — extract landmark, heading, and ARIA metadata from crawled pages. * * Captures: landmarks with interactive counts, heading hierarchy with violation detection, * interactive elements missing accessible names, live regions, and skip-link presence. * * This data feeds ZeTa's accessibility test generator to surface WCAG 2.1 Level A/AA * failures (missing names, heading skips, absent skip links, unlabeled live regions). */ import type { Page } from 'playwright'; export interface LandmarkInfo { role: string; label: string; elementId: string; interactiveCount: number; } export interface HeadingInfo { level: number; text: string; elementId: string; } export interface MissingNameEl { tagName: string; role: string; elementId: string; htmlSnippet: string; } export interface LiveRegionInfo { role: string; ariaLive: string; ariaAtomic: string; currentText: string; } export interface AccessibilityTreeResult { landmarkCount: number; landmarks: LandmarkInfo[]; headings: HeadingInfo[]; headingHierarchyViolations: string[]; missingNameElements: MissingNameEl[]; missingNameCount: number; liveRegions: LiveRegionInfo[]; totalInteractiveCount: number; hasSkipLink: boolean; } async function extractLandmarks(page: any): Promise { try { const raw = await page.evaluate(() => { const LANDMARK_SELECTORS = [ { role: 'banner', selector: 'header,[role="banner"]' }, { role: 'navigation', selector: 'nav,[role="navigation"]' }, { role: 'main', selector: 'main,[role="main"]' }, { role: 'complementary', selector: 'aside,[role="complementary"]' }, { role: 'contentinfo', selector: 'footer,[role="contentinfo"]' }, { role: 'search', selector: '[role="search"]' }, { role: 'form', selector: 'form[aria-label],[role="form"]' }, { role: 'region', selector: '[role="region"][aria-label]' }, ]; return LANDMARK_SELECTORS.flatMap(({ role, selector }) => [...document.querySelectorAll(selector)].slice(0, 10).map(el => ({ role, label: el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || '', elementId: el.id || '', interactiveCount: el.querySelectorAll( 'button,a[href],input,select,textarea,[role="button"],[tabindex="0"]' ).length, })) ); }); return raw.slice(0, 50); } catch { return []; } } async function extractHeadings(page: any): Promise { try { const raw = await page.evaluate(() => { const headingEls = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]')]; return headingEls .map(el => ({ level: parseInt(el.tagName.slice(1)) || parseInt(el.getAttribute('aria-level') || '0') || 0, text: el.textContent?.trim().slice(0, 100) || '', elementId: el.id || '', })) .filter(h => h.level >= 1 && h.level <= 6); }); return raw.slice(0, 100); } catch { return []; } } function detectHeadingViolations(headings: HeadingInfo[]): string[] { const violations: string[] = []; if (headings.length === 0) return violations; const h1s = headings.filter(h => h.level === 1); if (h1s.length === 0) { violations.push('Missing h1 landmark'); } else if (h1s.length > 1) { violations.push('Multiple h1 elements found'); } let prevLevel = 0; for (const heading of headings) { if (prevLevel > 0 && heading.level > prevLevel + 1) { violations.push( `Heading level skip: h${prevLevel} to h${heading.level}` ); } prevLevel = heading.level; } return violations; } async function findMissingNames(page: any): Promise { try { return await page.evaluate(() => { const INTERACTIVE_SELECTOR = 'button,a[href],input:not([type="hidden"]),select,textarea,' + '[role="button"],[role="link"],[role="checkbox"],[role="radio"],' + '[role="switch"],[role="menuitem"],[role="tab"]'; return [...document.querySelectorAll(INTERACTIVE_SELECTOR)] .filter(el => { const labelledById = el.getAttribute('aria-labelledby') || ''; const name = (el.getAttribute('aria-label') || '').trim() || (labelledById ? document.getElementById(labelledById)?.textContent?.trim() ?? '' : '') || (el.textContent || '').trim() || ((el as HTMLInputElement).placeholder || '').trim() || ((el as HTMLElement).title || '').trim() || ((el as HTMLImageElement).alt || '').trim(); return !name || name.length === 0; }) .slice(0, 30) .map(el => ({ tagName: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', elementId: el.id || '', htmlSnippet: el.outerHTML.slice(0, 120), })); }); } catch { return []; } } async function extractLiveRegions(page: any): Promise { try { return await page.evaluate(() => { const els = [ ...document.querySelectorAll( '[aria-live],[role="alert"],[role="status"],[role="log"]' ), ]; return els.slice(0, 20).map(el => ({ role: el.getAttribute('role') || '', ariaLive: el.getAttribute('aria-live') || '', ariaAtomic: el.getAttribute('aria-atomic') || '', currentText: (el.textContent || '').trim().slice(0, 200), })); }); } catch { return []; } } async function checkSkipLink(page: any): Promise { try { return await page.evaluate(() => { const firstFocusable = document.querySelector('a[href^="#"],a:first-of-type'); return !!( firstFocusable && /skip/i.test( (firstFocusable.textContent || '') + (firstFocusable.getAttribute('href') || '') ) ); }); } catch { return false; } } export async function crawlAccessibilityTree(page: any): Promise { const [ landmarksResult, headingsResult, missingNamesResult, liveRegionsResult, skipLinkResult, interactiveCountResult, ] = await Promise.allSettled([ extractLandmarks(page), extractHeadings(page), findMissingNames(page), extractLiveRegions(page), checkSkipLink(page), page.evaluate(() => { const INTERACTIVE_SELECTOR = 'button,a[href],input:not([type="hidden"]),select,textarea,' + '[role="button"],[role="link"],[role="checkbox"],[role="radio"],' + '[role="switch"],[role="menuitem"],[role="tab"]'; return document.querySelectorAll(INTERACTIVE_SELECTOR).length; }).catch(() => 0), ]); const landmarks = landmarksResult.status === 'fulfilled' ? landmarksResult.value : []; const headings = headingsResult.status === 'fulfilled' ? headingsResult.value : []; const missingNameElements = missingNamesResult.status === 'fulfilled' ? missingNamesResult.value : []; const liveRegions = liveRegionsResult.status === 'fulfilled' ? liveRegionsResult.value : []; const hasSkipLink = skipLinkResult.status === 'fulfilled' ? skipLinkResult.value : false; const totalInteractiveCount = interactiveCountResult.status === 'fulfilled' ? interactiveCountResult.value : 0; const headingHierarchyViolations = detectHeadingViolations(headings); return { landmarkCount: landmarks.length, landmarks, headings, headingHierarchyViolations, missingNameElements, missingNameCount: missingNameElements.length, liveRegions, totalInteractiveCount, hasSkipLink, }; }