UNPKG

a11yanalyze

Version:

A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance

360 lines (312 loc) 9.41 kB
import { Page } from 'playwright'; import { RuleEngineConfig, RuleResult, WCAGRule, WCAGLevel, ViolationNode } from '../types'; import axeCore from 'axe-core'; /** * Hybrid rule engine combining axe-core with custom WCAG 2.2 rules * Provides comprehensive accessibility testing with extensible rule system */ export class RuleEngine { private config: RuleEngineConfig; private customRules: Map<string, WCAGRule> = new Map(); private disabledRules: Set<string> = new Set(); constructor(config: RuleEngineConfig) { this.config = { wcagLevel: config.wcagLevel ?? 'AA', includeAAA: config.includeAAA ?? false, includeARIA: config.includeARIA ?? true, customRules: config.customRules ?? [], disabledRules: config.disabledRules ?? [], axeCoreConfig: config.axeCoreConfig ?? { tags: ['wcag2a', 'wcag2aa'], rules: {}, }, }; this.initializeRules(); } /** * Initialize rule engine with axe-core and custom rules * @private */ private initializeRules(): void { // Setup axe-core configuration this.setupAxeCore(); // Load custom rules this.loadCustomRules(); // Apply disabled rules this.config.disabledRules.forEach(ruleId => { this.disabledRules.add(ruleId); }); } /** * Configure axe-core based on WCAG level and preferences * @private */ private setupAxeCore(): void { const tags: string[] = []; // Base WCAG 2.1/2.2 rules if (this.config.wcagLevel === 'A' || this.config.wcagLevel === 'AA' || this.config.wcagLevel === 'AAA') { tags.push('wcag2a'); } if (this.config.wcagLevel === 'AA' || this.config.wcagLevel === 'AAA') { tags.push('wcag2aa'); } if (this.config.wcagLevel === 'AAA' || this.config.includeAAA) { tags.push('wcag2aaa'); } // ARIA rules if (this.config.includeARIA) { tags.push('wcag2a', 'wcag2aa'); // ARIA is part of WCAG } // Additional rule categories tags.push('best-practice'); this.config.axeCoreConfig = { tags, rules: this.config.axeCoreConfig?.rules ?? {}, }; } /** * Load and validate custom WCAG rules * @private */ private loadCustomRules(): void { this.config.customRules.forEach(rule => { if (this.validateRule(rule)) { this.customRules.set(rule.ruleId, rule); } }); } /** * Validate custom rule configuration * @private */ private validateRule(rule: WCAGRule): boolean { if (!rule.ruleId || !rule.wcagCriterion || !rule.level) { console.warn(`Invalid rule configuration: ${rule.ruleId}`); return false; } if (!['A', 'AA', 'AAA'].includes(rule.level)) { console.warn(`Invalid WCAG level for rule ${rule.ruleId}: ${rule.level}`); return false; } return true; } /** * Execute accessibility scan on a page using hybrid rule engine * @param page - Playwright page instance * @returns Promise<RuleResult[]> */ async executeRules(page: Page): Promise<RuleResult[]> { const results: RuleResult[] = []; try { // Execute axe-core rules const axeResults = await this.executeAxeCore(page); results.push(...axeResults); // Execute custom WCAG rules const customResults = await this.executeCustomRules(page); results.push(...customResults); // Filter disabled rules return results.filter(result => !this.disabledRules.has(result.ruleId)); } catch (error) { console.error('Error executing accessibility rules:', error); throw new Error(`Rule execution failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Execute axe-core accessibility rules * @private */ private async executeAxeCore(page: Page): Promise<RuleResult[]> { try { // Inject axe-core into the page await page.addScriptTag({ content: axeCore.source, }); // Configure and run axe const config = this.config.axeCoreConfig; if (!config) { throw new Error('axeCoreConfig is not defined'); } // Ensure rules is always an object if (!config.rules || Array.isArray(config.rules)) { config.rules = {}; } const axeResults = await page.evaluate((configParam: any) => { return new Promise((resolve, reject) => { try { const win = (globalThis as any); if (typeof win.axe === 'undefined') { reject(new Error('axe-core not loaded')); return; } win.axe.configure({ tags: configParam.tags, rules: configParam.rules, }); win.axe.run((err: any, results: any) => { if (err) { reject(err); } else { resolve(results); } }); } catch (error) { reject(error); } }); }, config); return this.transformAxeResults(axeResults as any); } catch (error) { console.error('Axe-core execution failed:', error); return []; } } /** * Transform axe-core results to standard RuleResult format * @private */ private transformAxeResults(axeResults: any): RuleResult[] { const results: RuleResult[] = []; // Process violations axeResults.violations?.forEach((violation: any) => { const ruleResult: RuleResult = { ruleId: violation.id, wcagReference: this.extractWcagReference(violation.tags), level: this.determineWcagLevel(violation.tags), impact: violation.impact || 'moderate', nodes: this.transformAxeNodes(violation.nodes), description: violation.description, help: violation.help, helpUrl: violation.helpUrl, }; results.push(ruleResult); }); return results; } /** * Execute custom WCAG 2.2 rules * @private */ private async executeCustomRules(page: Page): Promise<RuleResult[]> { const results: RuleResult[] = []; for (const [ruleId, rule] of this.customRules) { if (this.disabledRules.has(ruleId)) { continue; } try { const ruleResult = await this.executeCustomRule(page, rule); if (ruleResult) { results.push(ruleResult); } } catch (error) { console.warn(`Custom rule ${ruleId} execution failed:`, error); } } return results; } /** * Execute a single custom rule * @private */ private async executeCustomRule(page: Page, rule: WCAGRule): Promise<RuleResult | null> { try { // Placeholder for custom rule execution // In a real implementation, this would evaluate the custom rule logic // For now, return null (no violations) as this is a framework setup // Future implementation would: // 1. Parse the rule.evaluate function string // 2. Execute it in the page context with proper error handling // 3. Transform results to RuleResult format return null; } catch (error) { console.warn(`Error executing custom rule ${rule.ruleId}:`, error); return null; } } /** * Extract WCAG reference from axe tags * @private */ private extractWcagReference(tags: string[]): string { const wcagTag = tags.find(tag => tag.match(/^wcag\d{3}$/)); if (wcagTag) { const numbers = wcagTag.replace('wcag', ''); return `${numbers[0]}.${numbers[1]}.${numbers[2]}`; } return ''; } /** * Determine WCAG level from tags * @private */ private determineWcagLevel(tags: string[]): WCAGLevel { if (tags.includes('wcag2aaa')) return 'AAA'; if (tags.includes('wcag2aa')) return 'AA'; if (tags.includes('wcag2a')) return 'A'; return 'AA'; // Default } /** * Transform axe node information * @private */ private transformAxeNodes(nodes: any[]): ViolationNode[] { return nodes.map(node => ({ target: node.target, html: node.html, any: node.any || [], all: node.all || [], none: node.none || [], failureSummary: node.failureSummary, })); } /** * Add a custom rule to the engine */ addCustomRule(rule: WCAGRule): void { if (this.validateRule(rule)) { this.customRules.set(rule.ruleId, rule); } } /** * Remove a custom rule from the engine */ removeCustomRule(ruleId: string): void { this.customRules.delete(ruleId); } /** * Enable a previously disabled rule */ enableRule(ruleId: string): void { this.disabledRules.delete(ruleId); } /** * Disable a rule from execution */ disableRule(ruleId: string): void { this.disabledRules.add(ruleId); } /** * Get current rule configuration */ getConfiguration(): RuleEngineConfig { return { ...this.config }; } /** * Get list of available custom rules */ getCustomRules(): WCAGRule[] { return Array.from(this.customRules.values()); } /** * Get list of disabled rules */ getDisabledRules(): string[] { return Array.from(this.disabledRules); } /** * Update engine configuration */ updateConfiguration(newConfig: Partial<RuleEngineConfig>): void { this.config = { ...this.config, ...newConfig }; this.initializeRules(); } }