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

484 lines 19.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AccessibilityScorer = void 0; /** * Comprehensive accessibility scoring algorithm */ class AccessibilityScorer { constructor(config = {}) { this.config = this.mergeWithProfile(config); } /** * Calculate accessibility score for a single page */ calculatePageScore(scanResult) { const startTime = Date.now(); // Start with perfect score let baseScore = this.config.maxScore; // Calculate coverage assessment const coverage = this.assessCoverage(scanResult); // Calculate issue deductions const issueDeductions = this.calculateIssueDeductions(scanResult.issues); const totalIssueDeduction = issueDeductions.reduce((sum, d) => sum + d.pointsDeducted, 0); // Apply issue deductions baseScore -= totalIssueDeduction; // Calculate bonuses const bonuses = this.config.enableBonuses ? this.calculateBonuses(scanResult, coverage) : []; const totalBonuses = bonuses.reduce((sum, b) => sum + b.points, 0); // Calculate penalties const penalties = this.config.enablePenalties ? this.calculatePenalties(scanResult, coverage) : []; const totalPenalties = penalties.reduce((sum, p) => sum + p.points, 0); // Apply bonuses and penalties const adjustedScore = baseScore + totalBonuses - totalPenalties; // Ensure score is within bounds const finalScore = Math.max(this.config.minScore, Math.min(this.config.maxScore, adjustedScore)); return { finalScore: Math.round(finalScore * 100) / 100, baseScore: this.config.maxScore, issueDeductions, bonuses, penalties, coverage, metadata: { algorithmVersion: '1.0.0', profile: this.config.profile, calculatedAt: new Date().toISOString(), calculationTime: Date.now() - startTime, configChecksum: this.generateConfigChecksum(), }, }; } /** * Calculate site-wide accessibility score */ calculateSiteScore(crawlSession, scanResults, aggregationMethod = 'weighted') { // Calculate individual page scores const pageScores = scanResults.map(result => { const breakdown = this.calculatePageScore(result); const urlEntry = crawlSession.urls.get(result.url); return { url: result.url, score: breakdown.finalScore, weight: this.calculatePageWeight(result, urlEntry?.depth || 0), issueCount: result.issues.length, importance: this.determinePageImportance(result.url, urlEntry?.depth || 0), }; }); // Calculate overall score based on aggregation method const overallScore = this.aggregateScores(pageScores, aggregationMethod); // Calculate site-wide bonuses const siteWideBonuses = this.calculateSiteWideBonuses(crawlSession, scanResults); // Calculate distribution and consistency metrics const distribution = this.calculateScoreDistribution(pageScores); const consistency = this.calculateConsistencyMetrics(pageScores, scanResults); return { overallScore, pageScores, aggregationMethod, siteWideBonuses, distribution, consistency, }; } /** * Get scoring configuration */ getConfig() { return { ...this.config }; } /** * Update scoring configuration */ updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; } /** * Calculate issue deductions * @private */ calculateIssueDeductions(issues) { return issues.map(issue => { const severityPenalty = this.config.severityPenalties[issue.severity] || 0; const levelWeight = this.config.wcagLevelWeights[issue.level]; const weight = levelWeight * this.getIssueTypeMultiplier(issue); const pointsDeducted = Math.min(severityPenalty * weight, this.config.maxPenaltyPerIssue); return { issueId: issue.id, wcagReference: issue.wcagReference, severity: issue.severity, level: issue.level, pointsDeducted, weight, reason: `${issue.severity} ${issue.level} issue: ${issue.wcagReference}`, }; }); } /** * Calculate performance and quality bonuses * @private */ calculateBonuses(scanResult, coverage) { const bonuses = []; // Performance bonus if (scanResult.metadata?.pageLoadTime && scanResult.metadata.pageLoadTime < 2000) { bonuses.push({ type: 'performance', points: 2, description: 'Fast page load time', criteria: `Load time: ${scanResult.metadata.pageLoadTime}ms < 2000ms`, }); } // Coverage bonus if (coverage.testCoverage > 95) { bonuses.push({ type: 'coverage', points: 3, description: 'Excellent test coverage', criteria: `Coverage: ${coverage.testCoverage}% > 95%`, }); } else if (coverage.testCoverage > 90) { bonuses.push({ type: 'coverage', points: 1, description: 'Good test coverage', criteria: `Coverage: ${coverage.testCoverage}% > 90%`, }); } // Zero critical issues bonus const criticalIssues = scanResult.issues.filter(i => i.severity === 'critical').length; if (criticalIssues === 0 && scanResult.issues.length > 0) { bonuses.push({ type: 'accessibility', points: 5, description: 'No critical accessibility issues', criteria: 'Zero critical issues detected', }); } // Perfect accessibility bonus if (scanResult.issues.length === 0) { bonuses.push({ type: 'accessibility', points: 10, description: 'Perfect accessibility score', criteria: 'No accessibility issues detected', }); } return bonuses; } /** * Calculate system penalties * @private */ calculatePenalties(scanResult, coverage) { const penalties = []; // Error penalties if (scanResult.errors && scanResult.errors.length > 0) { scanResult.errors.forEach(error => { penalties.push({ type: error.type === 'timeout' ? 'timeout' : 'error', points: error.type === 'timeout' ? 10 : 5, description: `Scan error: ${error.message}`, affectedArea: 'page scanning', }); }); } // Coverage penalty if (coverage.testCoverage < 80) { penalties.push({ type: 'coverage', points: Math.round((80 - coverage.testCoverage) * 0.5), description: 'Low test coverage', affectedArea: `${coverage.testCoverage}% coverage`, }); } // Critical issue penalty const criticalIssues = scanResult.issues.filter(i => i.severity === 'critical').length; if (criticalIssues > 3) { penalties.push({ type: 'critical', points: (criticalIssues - 3) * 5, description: 'Multiple critical accessibility issues', affectedArea: `${criticalIssues} critical issues`, }); } return penalties; } /** * Assess test coverage quality * @private */ assessCoverage(scanResult) { const metadata = scanResult.metadata; const totalElements = metadata?.totalElements || 0; const testedElements = metadata?.testedElements || 0; const failedElements = Math.max(0, totalElements - testedElements); const testCoverage = totalElements > 0 ? (testedElements / totalElements) * 100 : 100; // Quality score based on coverage and error rate const errorRate = failedElements / Math.max(totalElements, 1); const qualityScore = Math.max(0, testCoverage - (errorRate * 50)); return { testCoverage: Math.round(testCoverage * 100) / 100, totalElements, testedElements, failedElements, qualityScore: Math.round(qualityScore * 100) / 100, uncoveredAreas: this.identifyUncoveredAreas(scanResult), }; } /** * Helper methods for scoring calculations * @private */ getIssueTypeMultiplier(issue) { // Increase penalty for certain high-impact issue types if (issue.wcagReference.startsWith('1.1.1')) return 1.2; // Alt text if (issue.wcagReference.startsWith('2.1.1')) return 1.3; // Keyboard if (issue.wcagReference.startsWith('1.4.3')) return 1.1; // Contrast if (issue.wcagReference.startsWith('4.1.2')) return 1.2; // Name, Role, Value return 1.0; } calculatePageWeight(scanResult, depth) { // Higher weight for pages closer to root const depthWeight = Math.max(0.3, 1 - (depth * 0.1)); // Bonus weight for important pages if (this.isImportantPage(scanResult.url)) { return depthWeight * 1.5; } return depthWeight; } determinePageImportance(url, depth) { if (depth === 0) return 'critical'; // Home page if (this.isImportantPage(url)) return 'high'; if (depth <= 2) return 'medium'; return 'low'; } isImportantPage(url) { const importantPaths = [ '/', '/home', '/index', '/contact', '/about', '/login', '/register', '/signup', '/checkout', '/cart', '/payment', '/search', '/help', '/support' ]; return importantPaths.some(path => url.endsWith(path) || url.includes(path + '/')); } aggregateScores(pageScores, method) { if (pageScores.length === 0) return 0; const scores = pageScores.map(p => p.score); switch (method) { case 'weighted': const totalWeight = pageScores.reduce((sum, p) => sum + p.weight, 0); return pageScores.reduce((sum, p) => sum + (p.score * p.weight), 0) / totalWeight; case 'average': return scores.reduce((sum, score) => sum + score, 0) / scores.length; case 'median': const sorted = [...scores].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? ((sorted[mid - 1] || 0) + (sorted[mid] || 0)) / 2 : (sorted[mid] || 0); case 'worst': return Math.min(...scores); default: return this.aggregateScores(pageScores, 'weighted'); } } calculateSiteWideBonuses(crawlSession, scanResults) { const bonuses = []; // Comprehensive scanning bonus const totalPages = crawlSession.urls.size; const scannedPages = scanResults.length; const scanRate = scannedPages / totalPages; if (scanRate > 0.95) { bonuses.push({ type: 'coverage', points: 5, description: 'Comprehensive site scanning', criteria: `${scannedPages}/${totalPages} pages scanned (${Math.round(scanRate * 100)}%)`, }); } // Consistency bonus const scores = scanResults.map(r => this.calculatePageScore(r).finalScore); const variance = this.calculateVariance(scores); if (variance < 100) { // Low variance indicates consistent quality bonuses.push({ type: 'accessibility', points: 3, description: 'Consistent accessibility across site', criteria: `Score variance: ${Math.round(variance)}`, }); } return bonuses; } calculateScoreDistribution(pageScores) { const scores = pageScores.map(p => p.score); const average = scores.reduce((sum, score) => sum + score, 0) / scores.length; const sorted = [...scores].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); const median = sorted.length % 2 === 0 ? ((sorted[mid - 1] || 0) + (sorted[mid] || 0)) / 2 : (sorted[mid] || 0); const variance = this.calculateVariance(scores); const standardDeviation = Math.sqrt(variance); return { average: Math.round(average * 100) / 100, median: Math.round(median * 100) / 100, minimum: Math.min(...scores), maximum: Math.max(...scores), standardDeviation: Math.round(standardDeviation * 100) / 100, ranges: { excellent: scores.filter(s => s >= 90).length, good: scores.filter(s => s >= 80 && s < 90).length, fair: scores.filter(s => s >= 70 && s < 80).length, poor: scores.filter(s => s >= 60 && s < 70).length, critical: scores.filter(s => s < 60).length, }, }; } calculateConsistencyMetrics(pageScores, scanResults) { const scores = pageScores.map(p => p.score); const average = scores.reduce((sum, score) => sum + score, 0) / scores.length; const variance = this.calculateVariance(scores); // Consistency score: higher variance = lower consistency const consistencyScore = Math.max(0, 100 - (variance / 10)); // Find outlier pages (scores significantly below average) const threshold = average - (Math.sqrt(variance) * 1.5); const outlierPages = pageScores .filter(p => p.score < threshold) .map(p => p.url); // Find most common issues across site const allIssues = scanResults.flatMap(r => r.issues); const issueFrequency = new Map(); allIssues.forEach(issue => { const key = issue.wcagReference; issueFrequency.set(key, (issueFrequency.get(key) || 0) + 1); }); const commonIssues = Array.from(issueFrequency.entries()) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([issue]) => issue); return { consistencyScore: Math.round(consistencyScore * 100) / 100, outlierPages, commonIssues, scoreVariance: Math.round(variance * 100) / 100, }; } calculateVariance(numbers) { if (numbers.length === 0) return 0; const mean = numbers.reduce((sum, num) => sum + num, 0) / numbers.length; const squaredDifferences = numbers.map(num => Math.pow(num - mean, 2)); return squaredDifferences.reduce((sum, sqDiff) => sum + sqDiff, 0) / numbers.length; } identifyUncoveredAreas(scanResult) { const uncovered = []; // This would be enhanced with actual coverage analysis if (scanResult.errors?.some(e => e.type === 'timeout')) { uncovered.push('Dynamic content (timeout during loading)'); } if (scanResult.errors?.some(e => e.type === 'parsing')) { uncovered.push('Malformed HTML content'); } return uncovered; } mergeWithProfile(config) { const profile = config.profile || 'balanced'; const profileConfig = AccessibilityScorer.SCORING_PROFILES[profile]; const defaultConfig = { profile, wcagLevelWeights: { A: 2.5, AA: 2.0, AAA: 1.0, ARIA: 1.5 }, severityPenalties: { critical: 12, serious: 8, moderate: 5, minor: 2, warning: 0.5 }, maxPenaltyPerIssue: 15, minScore: 10, maxScore: 100, enableBonuses: true, enablePenalties: true, transparencyLevel: 'detailed', }; return { ...defaultConfig, ...profileConfig, ...config }; } generateConfigChecksum() { // Simple checksum for configuration validation const configStr = JSON.stringify(this.config); let hash = 0; for (let i = 0; i < configStr.length; i++) { const char = configStr.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(16); } /** * Static utility methods */ /** * Get all available scoring profiles */ static getAvailableProfiles() { return Object.keys(AccessibilityScorer.SCORING_PROFILES); } /** * Get profile configuration */ static getProfileConfig(profile) { return AccessibilityScorer.SCORING_PROFILES[profile] || {}; } /** * Create scorer with predefined profile */ static withProfile(profile, overrides) { return new AccessibilityScorer({ profile, ...overrides }); } } exports.AccessibilityScorer = AccessibilityScorer; // Predefined scoring profiles AccessibilityScorer.SCORING_PROFILES = { strict: { wcagLevelWeights: { A: 3.0, AA: 2.5, AAA: 1.5, ARIA: 2.0 }, severityPenalties: { critical: 15, serious: 10, moderate: 6, minor: 3, warning: 1 }, maxPenaltyPerIssue: 20, minScore: 0, enableBonuses: false, enablePenalties: true, }, balanced: { wcagLevelWeights: { A: 2.5, AA: 2.0, AAA: 1.0, ARIA: 1.5 }, severityPenalties: { critical: 12, serious: 8, moderate: 5, minor: 2, warning: 0.5 }, maxPenaltyPerIssue: 15, minScore: 10, enableBonuses: true, enablePenalties: true, }, lenient: { wcagLevelWeights: { A: 2.0, AA: 1.5, AAA: 0.5, ARIA: 1.0 }, severityPenalties: { critical: 8, serious: 5, moderate: 3, minor: 1, warning: 0 }, maxPenaltyPerIssue: 10, minScore: 20, enableBonuses: true, enablePenalties: true, }, enterprise: { wcagLevelWeights: { A: 3.0, AA: 2.5, AAA: 0.8, ARIA: 2.2 }, severityPenalties: { critical: 20, serious: 12, moderate: 6, minor: 2, warning: 0.5 }, maxPenaltyPerIssue: 25, minScore: 0, enableBonuses: true, enablePenalties: true, }, custom: {}, }; //# sourceMappingURL=accessibility-scorer.js.map