import { chromium, Browser, BrowserContext, Page } from 'playwright'; import AxeBuilder from '@axe-core/playwright'; import type { AxeResults } from 'axe-core'; import type { AccessibilityScanOptions, ViewportSize, AuthenticationConfig } from '@wcag-ada/shared'; import { getWCAGTags, RESOURCE_BLOCK_PATTERNS } from '@wcag-ada/shared'; import { getScannerConfig, type ScannerConfig } from '@wcag-ada/config'; export interface AccessibilityBrowserOptions { headless?: boolean; scanTimeout?: number; defaultViewport?: ViewportSize; blockResources?: boolean; } export class AccessibilityBrowser { private browser?: Browser; private context?: BrowserContext; private logger: any; private config: ScannerConfig; private options: AccessibilityBrowserOptions; private initialized = false; constructor(logger?: any, options?: AccessibilityBrowserOptions) { this.logger = logger || console; this.config = getScannerConfig(); // Merge config with provided options this.options = { headless: options?.headless ?? this.config.headless, scanTimeout: options?.scanTimeout ?? this.config.timeout, defaultViewport: options?.defaultViewport ?? this.config.viewport, blockResources: options?.blockResources ?? this.config.blockResources, }; } async initialize(): Promise { if (this.initialized) return; this.browser = await chromium.launch({ headless: this.options.headless, args: this.config.browsers.chromium.args, }); this.context = await this.browser.newContext({ viewport: this.options.defaultViewport, }); // Block resources for faster scanning if enabled if (this.options.blockResources) { await this.context.route('**/*', (route) => { const url = route.request().url(); const shouldBlock = RESOURCE_BLOCK_PATTERNS.some(pattern => { if (pattern.includes('*')) { const regex = new RegExp(pattern.replace('*', '.*')); return regex.test(url); } return url.includes(pattern); }); if (shouldBlock) { route.abort(); } else { route.continue(); } }); } this.initialized = true; } async close(): Promise { if (this.context) await this.context.close(); if (this.browser) await this.browser.close(); this.initialized = false; } async scanPage(options: AccessibilityScanOptions): Promise<{ page: Page; axeResults: AxeResults; html: string; }> { if (!this.context) throw new Error('Browser not initialized'); const { url, viewport, authenticate, wcagLevel, excludeSelectors, waitForSelector } = options; const page = await this.context.newPage(); try { // Set viewport if different from default if (viewport) { await page.setViewportSize({ width: viewport.width, height: viewport.height, }); } // Handle authentication if needed if (authenticate) { await this.handleAuthentication(page, authenticate); } // Navigate to URL await page.goto(url, { waitUntil: 'networkidle', timeout: options.timeout || this.config.pageLoadTimeout, }); // Wait for specific selector if provided if (waitForSelector) { await page.waitForSelector(waitForSelector, { timeout: 10000, }); } // Additional wait for dynamic content await page.waitForTimeout(2000); // Get page HTML const html = await page.content(); // Configure axe const axeBuilder = new AxeBuilder({ page }); // Set WCAG tags if (wcagLevel) { const tags = getWCAGTags(wcagLevel); axeBuilder.withTags(tags); } // Exclude selectors if provided if (excludeSelectors && excludeSelectors.length > 0) { excludeSelectors.forEach(selector => { axeBuilder.exclude(selector); }); } // Set options axeBuilder.options({ resultTypes: this.config.axe.resultTypes, elementRef: true, runOnly: { type: 'tag', values: wcagLevel ? getWCAGTags(wcagLevel) : this.config.axe.tags, }, }); // Run accessibility scan const axeResults = await Promise.race([ axeBuilder.analyze(), new Promise((_, reject) => setTimeout(() => reject(new Error('Scan timeout')), this.options.scanTimeout!) ), ]); return { page, axeResults, html, }; } catch (error) { await page.close(); throw error; } } private async handleAuthentication(page: Page, auth: AuthenticationConfig): Promise { switch (auth.type) { case 'basic': if (auth.credentials?.username && auth.credentials?.password) { await page.context().setHTTPCredentials({ username: auth.credentials.username, password: auth.credentials.password, }); } break; case 'form': if (auth.loginUrl) { await page.goto(auth.loginUrl); if (auth.selectors?.username && auth.credentials?.username) { await page.fill(auth.selectors.username, auth.credentials.username); } if (auth.selectors?.password && auth.credentials?.password) { await page.fill(auth.selectors.password, auth.credentials.password); } if (auth.selectors?.submit) { await page.click(auth.selectors.submit); await page.waitForNavigation({ waitUntil: 'networkidle' }); } } break; case 'custom': if (auth.customScript) { await page.evaluate(auth.customScript); } break; } } async captureViolationScreenshots( page: Page, violations: AxeResults['violations'] ): Promise> { const screenshots = new Map(); for (const violation of violations) { for (let i = 0; i < violation.nodes.length; i++) { const node = violation.nodes[i]; if (node.target && node.target.length > 0) { try { const selector = node.target[0]; const element = await page.$(selector); if (element) { const screenshot = await element.screenshot({ type: 'png', }); const key = `${violation.id}_${i}`; screenshots.set(key, screenshot.toString('base64')); } } catch (error) { this.logger?.warn(`Failed to capture screenshot for ${violation.id}:`, error); } } } } return screenshots; } async evaluateCustomRules(page: Page, customRules: any[]): Promise { const results = []; for (const rule of customRules) { try { const elements = await page.$$(rule.selector); const violations = []; for (const element of elements) { const passes = await page.evaluate( (el, validatorStr) => { const validator = new Function('element', `return (${validatorStr})(element)`); return validator(el); }, element, rule.validator.toString() ); if (!passes) { const html = await element.evaluate(el => el.outerHTML); violations.push({ html, target: [rule.selector], }); } } if (violations.length > 0) { results.push({ id: rule.id, description: rule.description, help: rule.help, helpUrl: rule.helpUrl, impact: rule.severity, tags: rule.tags, nodes: violations, }); } } catch (error) { this.logger?.error(`Failed to evaluate custom rule ${rule.id}:`, error); } } return results; } }