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
JavaScript
;
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