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

817 lines (727 loc) 26.6 kB
import { AccessibilityReport, ReportMetadata, ExecutiveSummary, PageReport, SiteReport, ReportIssue, IssueCategory, AffectedElement, ReportOptions, TechnicalDetails, SeverityBreakdown, WCAGLevelBreakdown, CrawlInfo, SiteStatistics, PageSummary, IssuesAnalysis, CommonIssue, IssuePattern, ComplianceAnalysis, ComplianceLevel, SuccessCriteriaAnalysis, DomainBreakdown, PageMetadata, PerformanceMetrics, ReportError, RuleExecutionInfo, ScoreDistribution, ResponseTimeStats } from '../types/output'; import { ScanResult, AccessibilityIssue } from '../types'; import { CrawlSession } from '../types/crawler'; import { WCAGLevel } from '../types/wcag'; import { ErrorLogger, ErrorEntry, TechnicalIssue, ErrorStatistics } from './error-logger'; /** * JSON report generator for accessibility scan results * Converts scan results into comprehensive JSON reports with detailed analysis */ export class JsonReporter { private static readonly REPORT_VERSION = '1.0.0'; private static readonly TOOL_NAME = 'a11yanalyze'; private static readonly TOOL_VERSION = '1.0.0'; /** * Generate a single page accessibility report */ async generatePageReport( scanResult: ScanResult, options: Partial<ReportOptions> = {} ): Promise<AccessibilityReport> { 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: AccessibilityReport = { 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: CrawlSession, scanResults: ScanResult[], options: Partial<ReportOptions> = {} ): Promise<AccessibilityReport> { 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] || {} as ScanResult, scanResults, reportOptions); const report: AccessibilityReport = { 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 */ private generateMetadata( type: 'page' | 'site', target: string | string[], options: ReportOptions ): ReportMetadata { 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 */ private generateExecutiveSummary( scanResults: ScanResult[], type: 'page' | 'site' ): ExecutiveSummary { 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 */ private generatePageReportDetails( scanResult: ScanResult, options: ReportOptions ): PageReport { 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 */ private generateSiteReportDetails( crawlSession: CrawlSession, scanResults: ScanResult[], options: ReportOptions ): SiteReport { 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 */ private convertCrawlInfo(crawlSession: CrawlSession): CrawlInfo { 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 */ private calculateSiteStatistics( crawlSession: CrawlSession, scanResults: ScanResult[] ): SiteStatistics { 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 */ private generatePageSummaries( scanResults: ScanResult[], crawlSession: CrawlSession ): PageSummary[] { 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 */ private analyzeIssues(scanResults: ScanResult[], options: ReportOptions): IssuesAnalysis { 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 */ private analyzeCompliance(scanResults: ScanResult[]): ComplianceAnalysis { 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 */ private calculateComplianceLevel( scanResults: ScanResult[], level: WCAGLevel ): ComplianceLevel { 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 */ private convertToReportIssues( issues: AccessibilityIssue[], options: ReportOptions ): ReportIssue[] { 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 */ private convertToAffectedElement(issue: AccessibilityIssue): AffectedElement { return { selector: issue.selector, html: this.truncateHtml(issue.element), text: this.extractElementText(issue.element), context: `Issue: ${issue.message}`, }; } /** * Calculate severity breakdown * @private */ private calculateSeverityBreakdown(scanResults: ScanResult[]): SeverityBreakdown { 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 */ private calculateWCAGLevelBreakdown(scanResults: ScanResult[]): WCAGLevelBreakdown { 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 */ private calculateAverageScore(scanResults: ScanResult[]): number { 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 */ private determineOverallCompliance(scanResults: ScanResult[]): boolean { return scanResults.every(result => result.compliance?.compliant || false); } /** * Determine achieved WCAG level * @private */ private determineAchievedWCAGLevel(scanResults: ScanResult[]): WCAGLevel | 'None' { 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 */ private generateKeyRecommendations( scanResults: ScanResult[], issuesBySeverity: SeverityBreakdown ): string[] { const recommendations: string[] = []; 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 */ private mergeDefaultOptions(options: Partial<ReportOptions>): ReportOptions { return { includeScreenshots: false, includeRawData: false, includeTrends: false, minSeverity: 'warning', groupIssues: true, includeRemediation: true, version: JsonReporter.REPORT_VERSION, ...options, }; } private generateDefaultTitle(type: 'page' | 'site', target: string | string[]): string { const targetStr = Array.isArray(target) ? target[0] : target; return type === 'page' ? `Accessibility Report - ${targetStr}` : `Site Accessibility Report - ${targetStr}`; } private filterIssuesBySeverity( issues: AccessibilityIssue[], minSeverity: ReportOptions['minSeverity'] ): AccessibilityIssue[] { const severityOrder = ['warning', 'minor', 'moderate', 'serious', 'critical']; const minIndex = severityOrder.indexOf(minSeverity); return issues.filter(issue => severityOrder.indexOf(issue.severity) >= minIndex ); } private categorizeIssue(issue: AccessibilityIssue): IssueCategory { // 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'; } private generateIssueTitle(issue: AccessibilityIssue): string { // Generate a concise title from the issue message const messagePart = issue.message?.split('.')[0]; return messagePart?.trim() || 'Accessibility Issue'; } private truncateHtml(html: string, maxLength = 200): string { return html.length > maxLength ? html.substring(0, maxLength) + '...' : html; } private extractElementText(html: string): string | undefined { // Simple text extraction from HTML const textMatch = html.match(/>([^<]+)</); return textMatch && textMatch[1] ? textMatch[1].trim() : undefined; } // Additional helper methods would be implemented here... private convertPageMetadata(scanResult: ScanResult): PageMetadata { 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 }; } private convertPerformanceMetrics(scanResult: ScanResult): PerformanceMetrics { 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, }, }; } private extractScreenshots(scanResult: ScanResult): any[] { // Screenshots would be extracted from scan result if available return []; } private mapCrawlStatus(status: any): 'completed' | 'partial' | 'failed' | 'cancelled' { // Map internal crawl status to report status return status === 'completed' ? 'completed' : 'partial'; } // Placeholder implementations for complex analysis methods private calculateScoreDistribution(scanResults: ScanResult[]): ScoreDistribution { 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, }; } private calculateResponseTimeStats(scanResults: ScanResult[]): ResponseTimeStats { 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, }; } private findMostCommonIssues(issues: AccessibilityIssue[], totalPages: number): CommonIssue[] { // Group issues by WCAG reference and count occurrences const issueGroups = new Map<string, AccessibilityIssue[]>(); 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); } private categorizeIssues(issues: AccessibilityIssue[]): Record<IssueCategory, number> { const categories: Record<IssueCategory, number> = { 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; } private identifyIssuePatterns(scanResults: ScanResult[]): IssuePattern[] { // Simplified pattern detection return []; } private analyzeSuccessCriteria(scanResults: ScanResult[]): SuccessCriteriaAnalysis[] { // Simplified success criteria analysis return []; } private calculateDomainBreakdown(scanResults: ScanResult[]): DomainBreakdown[] { // Group by domain and calculate statistics return []; } private findBlockingIssues(scanResults: ScanResult[], level: WCAGLevel): string[] { // Find issues that block compliance at this level return []; } private groupIssuesByType(issues: AccessibilityIssue[]): AccessibilityIssue[][] { // Group similar issues together const groups = new Map<string, AccessibilityIssue[]>(); 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()); } private generateTechnicalDetails( primaryResult: ScanResult, allResults: ScanResult[], options: ReportOptions ): TechnicalDetails { 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, }, }; } private extractReportErrors(crawlSession: CrawlSession, scanResults: ScanResult[]): ReportError[] { const errors: ReportError[] = []; // 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; } private validateAndEnhanceReport(report: AccessibilityReport): AccessibilityReport { // Validate report structure and enhance with additional data return report; } private generateErrorReport( error: any, type: 'page' | 'site', target: string | string[], startTime: number ): AccessibilityReport { 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({} as ScanResult, [], this.mergeDefaultOptions({})), errors: [{ type: 'generation', severity: 'critical', message: `Report generation failed: ${errorMessage}`, timestamp: new Date().toISOString(), stack: error instanceof Error ? error.stack : undefined, }], }; } }