a11yanalyze
Version:
A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance
650 lines • 26.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.JsonReporter = void 0;
/**
* JSON report generator for accessibility scan results
* Converts scan results into comprehensive JSON reports with detailed analysis
*/
class JsonReporter {
/**
* Generate a single page accessibility report
*/
async generatePageReport(scanResult, options = {}) {
const startTime = Date.now();
const reportOptions = this.mergeDefaultOptions(options);
try {
const metadata = this.generateMetadata('page', scanResult.url, reportOptions);
const summary = this.generateExecutiveSummary([scanResult], 'page');
const pageReport = this.generatePageReportDetails(scanResult, reportOptions);
const technical = this.generateTechnicalDetails(scanResult, [], reportOptions);
const report = {
metadata: {
...metadata,
generationTime: Date.now() - startTime,
},
summary,
page: pageReport,
technical,
errors: [],
};
return this.validateAndEnhanceReport(report);
}
catch (error) {
return this.generateErrorReport(error, 'page', scanResult.url, startTime);
}
}
/**
* Generate a multi-page site accessibility report
*/
async generateSiteReport(crawlSession, scanResults, options = {}) {
const startTime = Date.now();
const reportOptions = this.mergeDefaultOptions(options);
try {
const metadata = this.generateMetadata('site', crawlSession.startUrls, reportOptions);
const summary = this.generateExecutiveSummary(scanResults, 'site');
const siteReport = this.generateSiteReportDetails(crawlSession, scanResults, reportOptions);
const technical = this.generateTechnicalDetails(scanResults[0] || {}, scanResults, reportOptions);
const report = {
metadata: {
...metadata,
generationTime: Date.now() - startTime,
},
summary,
site: siteReport,
technical,
errors: this.extractReportErrors(crawlSession, scanResults),
};
return this.validateAndEnhanceReport(report);
}
catch (error) {
return this.generateErrorReport(error, 'site', crawlSession.startUrls, startTime);
}
}
/**
* Generate report metadata
* @private
*/
generateMetadata(type, target, options) {
return {
version: JsonReporter.REPORT_VERSION,
type,
generatedAt: new Date().toISOString(),
generationTime: 0, // Will be updated by caller
target,
title: options.title || this.generateDefaultTitle(type, target),
description: options.description,
tool: {
name: JsonReporter.TOOL_NAME,
version: JsonReporter.TOOL_VERSION,
userAgent: `${JsonReporter.TOOL_NAME}/${JsonReporter.TOOL_VERSION}`,
},
};
}
/**
* Generate executive summary
* @private
*/
generateExecutiveSummary(scanResults, type) {
const totalIssues = scanResults.reduce((sum, result) => sum + result.issues.length, 0);
const issuesBySeverity = this.calculateSeverityBreakdown(scanResults);
const wcagLevelBreakdown = this.calculateWCAGLevelBreakdown(scanResults);
const averageScore = this.calculateAverageScore(scanResults);
const overallCompliant = this.determineOverallCompliance(scanResults);
const achievedLevel = this.determineAchievedWCAGLevel(scanResults);
return {
overallScore: averageScore,
compliant: overallCompliant,
achievedLevel,
pagesScanned: scanResults.length,
totalIssues,
issuesBySeverity,
wcagLevelBreakdown,
keyRecommendations: this.generateKeyRecommendations(scanResults, issuesBySeverity),
status: scanResults.length > 0 ? 'completed' : 'failed',
};
}
/**
* Generate page report details
* @private
*/
generatePageReportDetails(scanResult, options) {
const filteredIssues = this.filterIssuesBySeverity(scanResult.issues, options.minSeverity);
const reportIssues = this.convertToReportIssues(filteredIssues, options);
return {
url: scanResult.url,
title: 'Unknown Title', // Title would come from actual page scan
score: scanResult.score,
compliant: scanResult.compliance?.compliant || false,
issues: reportIssues,
metadata: this.convertPageMetadata(scanResult),
performance: this.convertPerformanceMetrics(scanResult),
screenshots: options.includeScreenshots ? this.extractScreenshots(scanResult) : undefined,
};
}
/**
* Generate site report details
* @private
*/
generateSiteReportDetails(crawlSession, scanResults, options) {
const crawlInfo = this.convertCrawlInfo(crawlSession);
const statistics = this.calculateSiteStatistics(crawlSession, scanResults);
const pages = this.generatePageSummaries(scanResults, crawlSession);
const issuesAnalysis = this.analyzeIssues(scanResults, options);
const complianceAnalysis = this.analyzeCompliance(scanResults);
const domainBreakdown = this.calculateDomainBreakdown(scanResults);
return {
baseUrl: crawlSession.startUrls[0] || '',
crawlInfo,
statistics,
pages,
issuesAnalysis,
complianceAnalysis,
domainBreakdown: domainBreakdown.length > 1 ? domainBreakdown : undefined,
};
}
/**
* Convert crawl session to crawl info
* @private
*/
convertCrawlInfo(crawlSession) {
return {
sessionId: crawlSession.id,
startUrls: crawlSession.startUrls,
startTime: crawlSession.startTime.toISOString(),
endTime: crawlSession.endTime?.toISOString() || new Date().toISOString(),
duration: crawlSession.endTime
? crawlSession.endTime.getTime() - crawlSession.startTime.getTime()
: Date.now() - crawlSession.startTime.getTime(),
config: {
maxDepth: crawlSession.config.maxDepth,
maxPages: crawlSession.config.maxPages,
allowedDomains: crawlSession.config.allowedDomains,
excludedPaths: crawlSession.config.excludedPaths,
},
status: this.mapCrawlStatus(crawlSession.status),
};
}
/**
* Calculate site statistics
* @private
*/
calculateSiteStatistics(crawlSession, scanResults) {
const urls = Array.from(crawlSession.urls.values());
const scoreDistribution = this.calculateScoreDistribution(scanResults);
const responseTimeStats = this.calculateResponseTimeStats(scanResults);
return {
pagesDiscovered: urls.length,
pagesScanned: scanResults.length,
pagesFailed: urls.filter(u => u.status === 'failed').length,
pagesSkipped: urls.filter(u => u.status === 'skipped').length,
averageScore: this.calculateAverageScore(scanResults),
scoreDistribution,
responseTimeStats,
};
}
/**
* Generate page summaries
* @private
*/
generatePageSummaries(scanResults, crawlSession) {
return scanResults.map(result => {
const urlEntry = crawlSession.urls.get(result.url);
const issuesBySeverity = this.calculateSeverityBreakdown([result]);
return {
url: result.url,
title: 'Unknown Title', // Would be extracted from page content during scanning
score: result.score,
compliant: result.compliance?.compliant || false,
issuesBySeverity,
loadTime: result.metadata?.pageLoadTime || 0,
scanTime: result.metadata?.scanDuration || 0,
depth: urlEntry?.depth || 0,
errors: result.errors?.map(e => e.message) || [],
};
});
}
/**
* Analyze issues across the site
* @private
*/
analyzeIssues(scanResults, options) {
const allIssues = scanResults.flatMap(result => result.issues);
const filteredIssues = this.filterIssuesBySeverity(allIssues, options.minSeverity);
const mostCommonIssues = this.findMostCommonIssues(filteredIssues, scanResults.length);
const issuesByCategory = this.categorizeIssues(filteredIssues);
const criticalIssues = this.convertToReportIssues(filteredIssues.filter(issue => issue.severity === 'critical'), options);
const patterns = this.identifyIssuePatterns(scanResults);
return {
mostCommonIssues,
issuesByCategory,
criticalIssues,
patterns,
};
}
/**
* Analyze WCAG compliance
* @private
*/
analyzeCompliance(scanResults) {
const levelCompliance = {
A: this.calculateComplianceLevel(scanResults, 'A'),
AA: this.calculateComplianceLevel(scanResults, 'AA'),
AAA: this.calculateComplianceLevel(scanResults, 'AAA'),
};
const successCriteria = this.analyzeSuccessCriteria(scanResults);
return {
levelCompliance,
successCriteria,
};
}
/**
* Calculate compliance level statistics
* @private
*/
calculateComplianceLevel(scanResults, level) {
const relevantResults = scanResults.filter(result => result.compliance?.levelBreakdown[level] !== undefined);
const passingPages = relevantResults.filter(result => {
const levelIssues = result.issues.filter(issue => issue.level === level && issue.severity !== 'warning');
return levelIssues.length === 0;
}).length;
const failingPages = relevantResults.length - passingPages;
const compliant = failingPages === 0;
const percentage = relevantResults.length > 0 ? (passingPages / relevantResults.length) * 100 : 0;
const blockingIssues = this.findBlockingIssues(scanResults, level);
return {
compliant,
percentage: Math.round(percentage),
passingPages,
failingPages,
blockingIssues,
};
}
/**
* Convert scan result issues to report issues
* @private
*/
convertToReportIssues(issues, options) {
const issueGroups = options.groupIssues
? this.groupIssuesByType(issues)
: issues.map(issue => [issue]);
return issueGroups.map(group => {
const primaryIssue = group[0];
if (!primaryIssue) {
throw new Error('Issue group is empty');
}
const elements = group.map(issue => this.convertToAffectedElement(issue));
return {
id: primaryIssue.id,
wcagReference: primaryIssue.wcagReference,
level: primaryIssue.level,
severity: primaryIssue.severity,
category: this.categorizeIssue(primaryIssue),
title: this.generateIssueTitle(primaryIssue),
description: primaryIssue.message,
impact: primaryIssue.impact,
remediation: options.includeRemediation ? primaryIssue.remediation : '',
helpUrl: primaryIssue.helpUrl,
occurrences: group.length,
elements,
};
});
}
/**
* Convert accessibility issue to affected element
* @private
*/
convertToAffectedElement(issue) {
return {
selector: issue.selector,
html: this.truncateHtml(issue.element),
text: this.extractElementText(issue.element),
context: `Issue: ${issue.message}`,
};
}
/**
* Calculate severity breakdown
* @private
*/
calculateSeverityBreakdown(scanResults) {
const allIssues = scanResults.flatMap(result => result.issues);
return {
critical: allIssues.filter(issue => issue.severity === 'critical').length,
serious: allIssues.filter(issue => issue.severity === 'serious').length,
moderate: allIssues.filter(issue => issue.severity === 'moderate').length,
minor: allIssues.filter(issue => issue.severity === 'minor').length,
warning: allIssues.filter(issue => issue.severity === 'warning').length,
};
}
/**
* Calculate WCAG level breakdown
* @private
*/
calculateWCAGLevelBreakdown(scanResults) {
const allIssues = scanResults.flatMap(result => result.issues);
return {
A: allIssues.filter(issue => issue.level === 'A').length,
AA: allIssues.filter(issue => issue.level === 'AA').length,
AAA: allIssues.filter(issue => issue.level === 'AAA').length,
ARIA: allIssues.filter(issue => issue.level === 'ARIA').length,
};
}
/**
* Calculate average accessibility score
* @private
*/
calculateAverageScore(scanResults) {
if (scanResults.length === 0)
return 0;
const totalScore = scanResults.reduce((sum, result) => sum + result.score, 0);
return Math.round(totalScore / scanResults.length);
}
/**
* Determine overall compliance status
* @private
*/
determineOverallCompliance(scanResults) {
return scanResults.every(result => result.compliance?.compliant || false);
}
/**
* Determine achieved WCAG level
* @private
*/
determineAchievedWCAGLevel(scanResults) {
const hasA = scanResults.every(result => result.issues.filter(issue => issue.level === 'A' && issue.severity !== 'warning').length === 0);
const hasAA = hasA && scanResults.every(result => result.issues.filter(issue => issue.level === 'AA' && issue.severity !== 'warning').length === 0);
const hasAAA = hasAA && scanResults.every(result => result.issues.filter(issue => issue.level === 'AAA' && issue.severity !== 'warning').length === 0);
if (hasAAA)
return 'AAA';
if (hasAA)
return 'AA';
if (hasA)
return 'A';
return 'None';
}
/**
* Generate key recommendations
* @private
*/
generateKeyRecommendations(scanResults, issuesBySeverity) {
const recommendations = [];
if (issuesBySeverity.critical > 0) {
recommendations.push(`Address ${issuesBySeverity.critical} critical accessibility issues immediately`);
}
if (issuesBySeverity.serious > 0) {
recommendations.push(`Fix ${issuesBySeverity.serious} serious issues to improve compliance`);
}
const averageScore = this.calculateAverageScore(scanResults);
if (averageScore < 80) {
recommendations.push('Implement systematic accessibility testing in development workflow');
}
if (recommendations.length === 0) {
recommendations.push('Continue monitoring and maintaining current accessibility standards');
}
return recommendations.slice(0, 5); // Limit to top 5 recommendations
}
/**
* Helper methods for data processing
* @private
*/
mergeDefaultOptions(options) {
return {
includeScreenshots: false,
includeRawData: false,
includeTrends: false,
minSeverity: 'warning',
groupIssues: true,
includeRemediation: true,
version: JsonReporter.REPORT_VERSION,
...options,
};
}
generateDefaultTitle(type, target) {
const targetStr = Array.isArray(target) ? target[0] : target;
return type === 'page'
? `Accessibility Report - ${targetStr}`
: `Site Accessibility Report - ${targetStr}`;
}
filterIssuesBySeverity(issues, minSeverity) {
const severityOrder = ['warning', 'minor', 'moderate', 'serious', 'critical'];
const minIndex = severityOrder.indexOf(minSeverity);
return issues.filter(issue => severityOrder.indexOf(issue.severity) >= minIndex);
}
categorizeIssue(issue) {
// Simple categorization based on WCAG reference or tags
if (issue.wcagReference.startsWith('1.1'))
return 'images';
if (issue.wcagReference.startsWith('1.3'))
return 'structure';
if (issue.wcagReference.startsWith('1.4'))
return 'color';
if (issue.wcagReference.startsWith('2.1'))
return 'keyboard';
if (issue.wcagReference.startsWith('2.4'))
return 'navigation';
if (issue.wcagReference.startsWith('3.1'))
return 'content';
if (issue.wcagReference.startsWith('3.2'))
return 'navigation';
if (issue.wcagReference.startsWith('3.3'))
return 'forms';
if (issue.wcagReference.startsWith('4.1'))
return 'aria';
return 'other';
}
generateIssueTitle(issue) {
// Generate a concise title from the issue message
const messagePart = issue.message?.split('.')[0];
return messagePart?.trim() || 'Accessibility Issue';
}
truncateHtml(html, maxLength = 200) {
return html.length > maxLength ? html.substring(0, maxLength) + '...' : html;
}
extractElementText(html) {
// Simple text extraction from HTML
const textMatch = html.match(/>([^<]+)</);
return textMatch && textMatch[1] ? textMatch[1].trim() : undefined;
}
// Additional helper methods would be implemented here...
convertPageMetadata(scanResult) {
return {
title: 'Unknown', // Would be extracted from page content during scanning
language: 'en', // Default, should be extracted from scan
viewport: '1280x720', // Default, should come from scan metadata
charset: 'UTF-8', // Default, should be extracted
size: {
totalElements: scanResult.metadata?.totalElements || 0,
interactiveElements: scanResult.metadata?.testedElements || 0,
images: 0, // Should be extracted from scan
links: 0, // Should be extracted from scan
formControls: 0, // Should be extracted from scan
headings: 0, // Should be extracted from scan
},
accessibilityFeatures: [], // Should be detected during scan
};
}
convertPerformanceMetrics(scanResult) {
return {
loadTime: scanResult.metadata?.pageLoadTime || 0,
scanTime: scanResult.metadata?.scanDuration || 0,
totalTime: (scanResult.metadata?.pageLoadTime || 0) + (scanResult.metadata?.scanDuration || 0),
resources: {
total: 0, // Should come from browser manager
failed: 0,
totalSize: 0,
},
};
}
extractScreenshots(scanResult) {
// Screenshots would be extracted from scan result if available
return [];
}
mapCrawlStatus(status) {
// Map internal crawl status to report status
return status === 'completed' ? 'completed' : 'partial';
}
// Placeholder implementations for complex analysis methods
calculateScoreDistribution(scanResults) {
const scores = scanResults.map(r => r.score);
return {
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,
};
}
calculateResponseTimeStats(scanResults) {
const loadTimes = scanResults.map(r => r.metadata?.pageLoadTime || 0);
const scanTimes = scanResults.map(r => r.metadata?.scanDuration || 0);
return {
averageLoadTime: loadTimes.reduce((a, b) => a + b, 0) / loadTimes.length,
fastestLoadTime: Math.min(...loadTimes),
slowestLoadTime: Math.max(...loadTimes),
averageScanTime: scanTimes.reduce((a, b) => a + b, 0) / scanTimes.length,
};
}
findMostCommonIssues(issues, totalPages) {
// Group issues by WCAG reference and count occurrences
const issueGroups = new Map();
issues.forEach(issue => {
const key = issue.wcagReference;
if (!issueGroups.has(key)) {
issueGroups.set(key, []);
}
issueGroups.get(key).push(issue);
});
return Array.from(issueGroups.entries())
.filter(([, groupIssues]) => groupIssues.length > 0)
.map(([wcagRef, groupIssues]) => ({
id: groupIssues[0].id,
title: this.generateIssueTitle(groupIssues[0]),
wcagReference: wcagRef,
occurrences: groupIssues.length,
affectedPages: 1, // Simplified - would need page tracking
affectedPercentage: Math.round((1 / totalPages) * 100),
}))
.sort((a, b) => b.occurrences - a.occurrences)
.slice(0, 10);
}
categorizeIssues(issues) {
const categories = {
images: 0, forms: 0, navigation: 0, content: 0, color: 0,
keyboard: 0, focus: 0, aria: 0, structure: 0, multimedia: 0,
timing: 0, input: 0, error: 0, other: 0
};
issues.forEach(issue => {
const category = this.categorizeIssue(issue);
categories[category]++;
});
return categories;
}
identifyIssuePatterns(scanResults) {
// Simplified pattern detection
return [];
}
analyzeSuccessCriteria(scanResults) {
// Simplified success criteria analysis
return [];
}
calculateDomainBreakdown(scanResults) {
// Group by domain and calculate statistics
return [];
}
findBlockingIssues(scanResults, level) {
// Find issues that block compliance at this level
return [];
}
groupIssuesByType(issues) {
// Group similar issues together
const groups = new Map();
issues.forEach(issue => {
const key = `${issue.wcagReference}-${issue.severity}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(issue);
});
return Array.from(groups.values());
}
generateTechnicalDetails(primaryResult, allResults, options) {
return {
scanConfig: {
wcagLevel: 'AA', // Should come from actual config
includeAAA: true,
includeARIA: true,
browser: {
name: 'chromium',
version: '1.0.0',
headless: true,
viewport: { width: 1280, height: 720 },
},
},
rulesExecuted: [], // Should be populated from actual rule execution
environment: {
toolVersion: JsonReporter.TOOL_VERSION,
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
},
processing: {
totalRules: 0,
rulesPassed: 0,
rulesFailed: 0,
customRules: 0,
},
};
}
extractReportErrors(crawlSession, scanResults) {
const errors = [];
// Extract errors from scan results
scanResults.forEach(result => {
if (result.errors) {
result.errors.forEach(error => {
errors.push({
type: 'scan',
severity: error.type === 'timeout' ? 'error' : 'warning',
message: error.message,
details: error.details,
url: result.url,
timestamp: new Date().toISOString(),
});
});
}
});
return errors;
}
validateAndEnhanceReport(report) {
// Validate report structure and enhance with additional data
return report;
}
generateErrorReport(error, type, target, startTime) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
metadata: this.generateMetadata(type, target, this.mergeDefaultOptions({})),
summary: {
overallScore: 0,
compliant: false,
achievedLevel: 'None',
pagesScanned: 0,
totalIssues: 0,
issuesBySeverity: { critical: 0, serious: 0, moderate: 0, minor: 0, warning: 0 },
wcagLevelBreakdown: { A: 0, AA: 0, AAA: 0, ARIA: 0 },
keyRecommendations: ['Report generation failed - please try again'],
status: 'failed',
},
technical: this.generateTechnicalDetails({}, [], this.mergeDefaultOptions({})),
errors: [{
type: 'generation',
severity: 'critical',
message: `Report generation failed: ${errorMessage}`,
timestamp: new Date().toISOString(),
stack: error instanceof Error ? error.stack : undefined,
}],
};
}
}
exports.JsonReporter = JsonReporter;
JsonReporter.REPORT_VERSION = '1.0.0';
JsonReporter.TOOL_NAME = 'a11yanalyze';
JsonReporter.TOOL_VERSION = '1.0.0';
//# sourceMappingURL=json-reporter.js.map