UNPKG

@casoon/auditmysite

Version:

A comprehensive command-line tool for automated accessibility, security, performance, and SEO testing using Playwright and pa11y, based on sitemap URLs

743 lines (644 loc) 27.8 kB
import { chromium, Browser, Page } from "playwright"; import pa11y from "pa11y"; import { AccessibilityResult, TestOptions, Pa11yIssue } from "../types"; import { BrowserManager } from './browser-manager'; import { LighthouseIntegration } from './lighthouse-integration'; import { SimpleQueue, QueuedUrl } from './simple-queue'; import { ParallelTestManager, ParallelTestManagerOptions, ParallelTestResult } from './parallel-test-manager'; import { EventDrivenQueue, ProcessOptions } from './event-driven-queue'; import * as fs from 'fs'; import * as path from 'path'; export class AccessibilityChecker { private browserManager: BrowserManager | null = null; private lighthouseIntegration: LighthouseIntegration | null = null; private testQueue: SimpleQueue | null = null; private parallelTestManager: ParallelTestManager | null = null; private eventDrivenQueue: EventDrivenQueue | null = null; async initialize(): Promise<void> { // 🆕 Browser Manager für geteilten Browser this.browserManager = new BrowserManager({ headless: true, port: 9222 }); await this.browserManager.initialize(); // 🆕 Lighthouse Integration this.lighthouseIntegration = new LighthouseIntegration(this.browserManager); } async cleanup(): Promise<void> { if (this.browserManager) { await this.browserManager.cleanup(); } } async testPage( url: string, options: TestOptions = {}, ): Promise<AccessibilityResult> { if (!this.browserManager) { throw new Error("Browser Manager not initialized"); } const startTime = Date.now(); const page = await this.browserManager.getPage(); const result: AccessibilityResult = { url, title: "", imagesWithoutAlt: 0, buttonsWithoutLabel: 0, headingsCount: 0, errors: [], warnings: [], passed: true, duration: 0, }; try { if (options.verbose) console.log(` 🔧 Configuring page...`); // 🆕 Erweiterte Page-Konfiguration await this.configurePage(page, options); if (options.verbose) console.log(` 🌐 Navigating to page...`); await page.goto(url, { waitUntil: options.waitUntil || "domcontentloaded", timeout: options.timeout || 10000, }); // 🆕 Performance-Metriken sammeln if (options.collectPerformanceMetrics) { if (options.verbose) console.log(` 📊 Collecting performance metrics...`); await this.collectPerformanceMetrics(page, result, options); } // Seitentitel prüfen if (options.verbose) console.log(` 📋 Extracting page title...`); result.title = await page.title(); // Bilder ohne alt-Attribut if (options.verbose) console.log(` 🖼️ Checking images for alt attributes...`); result.imagesWithoutAlt = await page.locator("img:not([alt])").count(); if (result.imagesWithoutAlt > 0) { result.warnings.push( `${result.imagesWithoutAlt} images without alt attribute`, ); } // Buttons ohne aria-label if (options.verbose) console.log(` 🔘 Checking buttons for aria labels...`); result.buttonsWithoutLabel = await page .locator("button:not([aria-label])") .filter({ hasText: "" }) .count(); if (result.buttonsWithoutLabel > 0) { result.warnings.push( `${result.buttonsWithoutLabel} buttons without aria-label`, ); } // Überschriften-Hierarchie if (options.verbose) console.log(` 📝 Checking heading hierarchy...`); result.headingsCount = await page .locator("h1, h2, h3, h4, h5, h6") .count(); if (result.headingsCount === 0) { result.errors.push("No headings found"); } // 🆕 Erweiterte Accessibility-Tests if (options.testKeyboardNavigation) { if (options.verbose) console.log(` ⌨️ Testing keyboard navigation...`); await this.testKeyboardNavigation(page, result, options); } if (options.testColorContrast) { if (options.verbose) console.log(` 🎨 Testing color contrast...`); await this.testColorContrast(page, result, options); } if (options.testFocusManagement) { if (options.verbose) console.log(` 🎯 Testing focus management...`); await this.testFocusManagement(page, result, options); } // 🆕 Screenshots if (options.captureScreenshots) { if (options.verbose) console.log(` 📸 Capturing screenshots...`); await this.captureScreenshots(page, url, result, options); } // pa11y Accessibility-Tests durchführen if (options.verbose) console.log(` 🔍 Running pa11y accessibility tests...`); try { // 🆕 Optimierte pa11y-Konfiguration für localhost const pa11yResult = await pa11y(url, { timeout: options.timeout || 15000, // Erhöht für localhost wait: options.wait || 2000, // Länger warten für localhost standard: options.pa11yStandard || 'WCAG2AA', hideElements: options.hideElements || 'iframe[src*="google-analytics"], iframe[src*="doubleclick"]', includeNotices: options.includeNotices !== false, includeWarnings: options.includeWarnings !== false, runners: options.runners || ['axe', 'htmlcs'], // 🆕 Vereinfachte Chrome-Konfiguration für localhost chromeLaunchConfig: { ...options.chromeLaunchConfig, args: [ '--disable-web-security', '--disable-features=VizDisplayCompositor', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding' ] }, log: options.verbose ? console : undefined, }); // pa11y-Ergebnisse in unser Format konvertieren pa11yResult.issues.forEach((issue) => { // Detaillierte Issue-Informationen speichern const detailedIssue: Pa11yIssue = { code: issue.code, message: issue.message, type: issue.type as 'error' | 'warning' | 'notice', selector: issue.selector, context: issue.context, impact: (issue as any).impact, help: (issue as any).help, helpUrl: (issue as any).helpUrl }; result.pa11yIssues = result.pa11yIssues || []; result.pa11yIssues.push(detailedIssue); // Für Kompatibilität auch in errors/warnings const message = `${issue.code}: ${issue.message}`; if (issue.type === 'error') { result.errors.push(message); } else if (issue.type === 'warning') { result.warnings.push(message); } else if (issue.type === 'notice') { result.warnings.push(`Notice: ${message}`); } }); // Zusätzliche pa11y-Metriken if (pa11yResult.documentTitle) { result.title = pa11yResult.documentTitle; } // pa11y Score berechnen if (pa11yResult.issues.length > 0) { const totalIssues = pa11yResult.issues.length; const errorIssues = pa11yResult.issues.filter(issue => issue.type === 'error').length; result.pa11yScore = Math.max(0, 100 - (errorIssues * 10) - (totalIssues - errorIssues) * 2); } else { result.pa11yScore = 100; } } catch (pa11yError) { // 🆕 Bessere Fehlerbehandlung für pa11y const errorMessage = pa11yError instanceof Error ? pa11yError.message : String(pa11yError); // Timeout-Fehler speziell behandeln if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { if (options.verbose) { console.log(` ⚠️ pa11y timeout for ${url} - skipping pa11y tests`); } // Timeout-Fehler nicht als Warning hinzufügen, da sie auf localhost normal sind } else { // Andere pa11y-Fehler als Warning hinzufügen result.warnings.push(`pa11y test failed: ${errorMessage}`); } } // 🆕 Lighthouse Tests durchführen (mit geteiltem Browser) if (options.lighthouse) { if (options.verbose) console.log(` �� Running Lighthouse tests...`); try { const lighthouseResult = await this.lighthouseIntegration!.runLighthouse(url); // Lighthouse-Ergebnisse speichern result.lighthouseScores = { performance: lighthouseResult.performance, accessibility: lighthouseResult.accessibility, bestPractices: lighthouseResult.bestPractices, seo: lighthouseResult.seo }; result.lighthouseMetrics = lighthouseResult.metrics; if (options.verbose) { console.log(` 📊 Lighthouse Scores: P:${lighthouseResult.performance} A:${lighthouseResult.accessibility} BP:${lighthouseResult.bestPractices} SEO:${lighthouseResult.seo}`); } } catch (lighthouseError) { result.warnings.push(`Lighthouse test failed: ${lighthouseError}`); } } // Prüfe auf kritische Fehler if (result.errors.length > 0) { result.passed = false; } } catch (error) { result.errors.push(`Navigation error: ${error}`); result.passed = false; } finally { await page.close(); result.duration = Date.now() - startTime; } return result; } async testMultiplePages( urls: string[], options: TestOptions = {}, ): Promise<AccessibilityResult[]> { const results: AccessibilityResult[] = []; const maxPages = options.maxPages || urls.length; const pagesToTest = urls.slice(0, maxPages); // Einfache Queue erstellen this.testQueue = new SimpleQueue({ maxRetries: 3, maxConcurrent: 1, priorityPatterns: [ { pattern: '/home', priority: 1 }, { pattern: '/', priority: 2 }, { pattern: '/about', priority: 3 }, { pattern: '/contact', priority: 3 }, { pattern: '/blog', priority: 4 }, { pattern: '/products', priority: 4 } ] }); // URLs zur Queue hinzufügen this.testQueue.addUrls(pagesToTest); console.log(`🧪 Testing ${pagesToTest.length} pages using queue system...`); this.testQueue.showStats(); let completedCount = 0; const maxAttempts = pagesToTest.length * 3; // Sicherheitsgrenze let attempts = 0; while (completedCount < pagesToTest.length && attempts < maxAttempts) { attempts++; // Nächste URL aus der Queue holen const queuedUrl = this.testQueue.getNextUrl(); if (!queuedUrl) { // Keine URLs mehr in der Queue break; } const startTime = Date.now(); console.log(`\n📄 Testing page ${completedCount + 1}/${pagesToTest.length}: ${queuedUrl.url}`); console.log(` ⏱️ Starting test (attempt ${queuedUrl.attempts})...`); try { const result = await this.testPage(queuedUrl.url, options); const duration = Date.now() - startTime; result.duration = duration; results.push(result); // URL als abgeschlossen markieren this.testQueue.markCompleted(queuedUrl.url, result); completedCount++; console.log(` ✅ Test completed in ${duration}ms`); if (result.passed) { console.log(` 🎯 Result: PASSED (${result.errors.length} errors, ${result.warnings.length} warnings)`); } else { console.log(` 🎯 Result: FAILED (${result.errors.length} errors, ${result.warnings.length} warnings)`); } // Status alle 5 URLs anzeigen if (completedCount % 5 === 0) { this.testQueue.showStats(); } } catch (error) { const duration = Date.now() - startTime; console.error(` 💥 Error testing page after ${duration}ms: ${error}`); // URL als fehlgeschlagen markieren this.testQueue.markFailed(queuedUrl.url, String(error)); // Error-Result erstellen const errorResult: AccessibilityResult = { url: queuedUrl.url, title: "", imagesWithoutAlt: 0, buttonsWithoutLabel: 0, headingsCount: 0, errors: [`Test failed: ${error}`], warnings: [], passed: false, duration, }; results.push(errorResult); completedCount++; } } // Finale Statistiken anzeigen console.log('\n📊 Final Queue Statistics:'); this.testQueue.showStats(); return results; } /** * 🚀 Parallele Accessibility-Tests mit Event-Driven Queue * * Diese Methode verwendet das Event-Driven Queue System für parallele Tests * mit Echtzeit-Status-Reporting und Resource-Monitoring. */ async testMultiplePagesParallel( urls: string[], options: TestOptions = {}, ): Promise<AccessibilityResult[]> { const maxPages = options.maxPages || urls.length; const pagesToTest = urls.slice(0, maxPages); // Parallele Test-Optionen const parallelOptions: ParallelTestManagerOptions = { maxConcurrent: options.maxConcurrent || 3, maxRetries: options.maxRetries || 3, retryDelay: options.retryDelay || 2000, enableProgressBar: options.enableProgressBar !== false, progressUpdateInterval: options.progressUpdateInterval || 1000, enableResourceMonitoring: options.enableResourceMonitoring !== false, maxMemoryUsage: options.maxMemoryUsage || 512, maxCpuUsage: options.maxCpuUsage || 80, testOptions: options, eventCallbacks: { onUrlStarted: (url: string) => { if (options.verbose) { console.log(`🚀 Starting parallel test: ${url}`); } }, onUrlCompleted: (url: string, result: AccessibilityResult, duration: number) => { const status = result.passed ? '✅ PASSED' : '❌ FAILED'; console.log(`${status} ${url} (${duration}ms) - ${result.errors.length} errors, ${result.warnings.length} warnings`); }, onUrlFailed: (url: string, error: string, attempts: number) => { console.error(`💥 Error testing ${url} (attempt ${attempts}): ${error}`); }, onProgressUpdate: (stats) => { if (options.verbose) { console.log(`📊 Progress: ${stats.progress.toFixed(1)}% | Workers: ${stats.activeWorkers}/${options.maxConcurrent || 3} | Memory: ${stats.memoryUsage}MB`); } }, onQueueEmpty: () => { console.log('🎉 All parallel tests completed!'); } } }; // Parallel Test Manager initialisieren this.parallelTestManager = new ParallelTestManager(parallelOptions); try { console.log(`🚀 Starting parallel accessibility tests for ${pagesToTest.length} pages with ${parallelOptions.maxConcurrent} workers`); console.log(`⚙️ Configuration: maxRetries=${parallelOptions.maxRetries}, retryDelay=${parallelOptions.retryDelay}ms`); // Manager initialisieren await this.parallelTestManager.initialize(); // Tests ausführen const startTime = Date.now(); const result: ParallelTestResult = await this.parallelTestManager.runTests(pagesToTest); const totalDuration = Date.now() - startTime; // Ergebnisse ausgeben console.log('\n📋 Parallel Test Results Summary:'); console.log('=================================='); console.log(`⏱️ Total Duration: ${totalDuration}ms`); console.log(`📄 URLs Tested: ${result.results.length}`); console.log(`✅ Successful: ${result.results.filter(r => r.passed).length}`); console.log(`❌ Failed: ${result.results.filter(r => !r.passed).length}`); console.log(`💥 Errors: ${result.errors.length}`); // Performance-Metriken const avgTimePerUrl = totalDuration / pagesToTest.length; const speedup = avgTimePerUrl > 0 ? (avgTimePerUrl * pagesToTest.length) / totalDuration : 0; console.log('\n🚀 Performance Metrics:'); console.log('======================'); console.log(`Average time per URL: ${avgTimePerUrl.toFixed(0)}ms`); console.log(`Speedup factor: ${speedup.toFixed(1)}x`); console.log(`Throughput: ${(pagesToTest.length / (totalDuration / 1000)).toFixed(1)} URLs/second`); // Detaillierte Statistiken console.log('\n📊 Queue Statistics:'); console.log('==================='); console.log(`Total: ${result.stats.total}`); console.log(`Completed: ${result.stats.completed}`); console.log(`Failed: ${result.stats.failed}`); console.log(`Retrying: ${result.stats.retrying}`); console.log(`Progress: ${result.stats.progress.toFixed(1)}%`); console.log(`Average Duration: ${result.stats.averageDuration}ms`); console.log(`Memory Usage: ${result.stats.memoryUsage}MB`); console.log(`CPU Usage: ${result.stats.cpuUsage}s`); // Fehler-Details if (result.errors.length > 0) { console.log('\n❌ Failed URLs:'); console.log('==============='); result.errors.forEach((error, index) => { console.log(`${index + 1}. ${error.url} (${error.attempts} attempts): ${error.error}`); }); } return result.results; } catch (error) { console.error('❌ Parallel test execution failed:', error); throw error; } finally { // Cleanup if (this.parallelTestManager) { await this.parallelTestManager.cleanup(); this.parallelTestManager = null; } } } /** * 🚀 Integrierte Queue-Verarbeitung mit kurzen Status-Updates * Diese Methode nutzt die Event-Driven Queue direkt für maximale Effizienz */ async testMultiplePagesWithQueue( urls: string[], options: TestOptions = {}, ): Promise<AccessibilityResult[]> { console.log(`🚀 Starte integrierte Queue-Verarbeitung für ${urls.length} URLs`); // Initialisiere Browser if (!this.browserManager) { this.browserManager = new BrowserManager({ headless: true, port: 9222 }); await this.browserManager.initialize(); } // Erstelle Event-Driven Queue this.eventDrivenQueue = new EventDrivenQueue({ maxConcurrent: options.maxConcurrent || 3, maxRetries: options.maxRetries || 3, retryDelay: options.retryDelay || 2000, enableShortStatus: true, statusUpdateInterval: 2000, eventCallbacks: { onShortStatus: (status: string) => { // Überschreibe die aktuelle Zeile mit dem Status process.stdout.write(`\r${status}`); }, onUrlCompleted: (url: string, result: any, duration: number) => { const shortUrl = url.split('/').pop() || url; console.log(`\n✅ ${shortUrl} (${duration}ms)`); }, onUrlFailed: (url: string, error: string, attempts: number) => { const shortUrl = url.split('/').pop() || url; console.log(`\n❌ ${shortUrl} (Versuch ${attempts})`); } } }); // Definiere den Processor für jede URL const processOptions: ProcessOptions = { processor: async (url: string) => { return await this.testPage(url, options); }, onResult: (url: string, result: AccessibilityResult) => { // Optional: Zusätzliche Verarbeitung nach erfolgreichem Test }, onError: (url: string, error: string) => { console.error(`\n💥 Fehler bei ${url}: ${error}`); } }; try { // Verarbeite alle URLs mit der Queue const results = await this.eventDrivenQueue.processUrls(urls, processOptions); console.log(`\n🎉 Queue-Verarbeitung abgeschlossen!`); console.log(`📊 Ergebnisse: ${results.length} URLs getestet`); return results as AccessibilityResult[]; } finally { // Cleanup this.eventDrivenQueue = null; } } // 🆕 Erweiterte Page-Konfiguration private async configurePage(page: Page, options: TestOptions): Promise<void> { // Viewport-Konfiguration const viewportSize = options.viewportSize || { width: 1920, height: 1080 }; await page.setViewportSize(viewportSize); // User-Agent setzen (Standard: auditmysite) const userAgent = options.userAgent || 'auditmysite/1.0 (+https://github.com/casoon/AuditMySite)'; await page.setExtraHTTPHeaders({ 'User-Agent': userAgent }); // Network-Interception für Performance if (options.blockImages) { await page.route('**/*.{png,jpg,jpeg,gif,svg,webp}', route => { route.abort(); }); } if (options.blockCSS) { await page.route('**/*.css', route => { route.abort(); }); } // Console-Logging page.on('console', msg => { if (options.verbose) { console.log(`Browser Console: ${msg.text()}`); } }); // Error-Handling page.on('pageerror', error => { if (options.verbose) { console.log(`JavaScript Error: ${error.message}`); } }); } // 🆕 Performance-Metriken sammeln private async collectPerformanceMetrics(page: Page, result: AccessibilityResult, options: TestOptions): Promise<void> { try { const metrics = await page.evaluate(() => { const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return { loadTime: navigation.loadEventEnd - navigation.loadEventStart, domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart, firstPaint: performance.getEntriesByName('first-paint')[0]?.startTime || 0, firstContentfulPaint: performance.getEntriesByName('first-contentful-paint')[0]?.startTime || 0, largestContentfulPaint: performance.getEntriesByName('largest-contentful-paint')[0]?.startTime || 0 }; }); result.performanceMetrics = metrics; // Performance-Warnungen if (metrics.loadTime > 3000) { result.warnings.push(`Slow page load: ${Math.round(metrics.loadTime)}ms`); } } catch (error) { if (options.verbose) { console.log(`Performance metrics collection failed: ${error}`); } } } // 🆕 Keyboard Navigation Test private async testKeyboardNavigation(page: Page, result: AccessibilityResult, options: TestOptions): Promise<void> { try { const keyboardNavigation = await page.evaluate(() => { const focusableElements = document.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'); const navigation: string[] = []; // Simuliere Tab-Navigation für die ersten 10 Elemente for (let i = 0; i < Math.min(focusableElements.length, 10); i++) { const element = focusableElements[i] as HTMLElement; navigation.push(`${element.tagName.toLowerCase()}: ${element.textContent?.trim().substring(0, 50) || element.outerHTML}`); } return navigation; }); result.keyboardNavigation = keyboardNavigation; } catch (error) { if (options.verbose) { console.log(`Keyboard navigation test failed: ${error}`); } } } // 🆕 Color Contrast Test (vereinfacht) private async testColorContrast(page: Page, result: AccessibilityResult, options: TestOptions): Promise<void> { try { const contrastIssues = await page.evaluate(() => { const elements = document.querySelectorAll('p, span, div, h1, h2, h3, h4, h5, h6, a, button, input, label'); const issues: string[] = []; elements.forEach(el => { const style = window.getComputedStyle(el); const color = style.color; const backgroundColor = style.backgroundColor; // Einfache Kontrast-Prüfung (vereinfacht) if (color && backgroundColor && color !== backgroundColor && color !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'rgba(0, 0, 0, 0)') { issues.push(`${el.tagName}: ${color} on ${backgroundColor}`); } }); return issues.slice(0, 10); // Limitiere auf 10 Issues }); if (contrastIssues.length > 0) { result.colorContrastIssues = contrastIssues; result.warnings.push(`${contrastIssues.length} potential color contrast issues found`); } } catch (error) { if (options.verbose) { console.log(`Color contrast test failed: ${error}`); } } } // 🆕 Focus Management Test private async testFocusManagement(page: Page, result: AccessibilityResult, options: TestOptions): Promise<void> { try { const focusIssues = await page.evaluate(() => { const issues: string[] = []; // Prüfe auf focus-visible const focusableElements = document.querySelectorAll('button, input, select, textarea, a[href]'); focusableElements.forEach(el => { const style = window.getComputedStyle(el); if (style.outline === 'none' && style.border === 'none' && !el.classList.contains('focus-visible') && !el.classList.contains('focus')) { issues.push(`Element without focus indicator: ${el.tagName} - ${el.textContent?.trim().substring(0, 30) || 'no text'}`); } }); return issues.slice(0, 10); // Limitiere auf 10 Issues }); if (focusIssues.length > 0) { result.focusManagementIssues = focusIssues; result.warnings.push(`${focusIssues.length} focus management issues found`); } } catch (error) { if (options.verbose) { console.log(`Focus management test failed: ${error}`); } } } // 🆕 Screenshot-Funktionalität private async captureScreenshots(page: Page, url: string, result: AccessibilityResult, options: TestOptions): Promise<void> { try { // Screenshots-Ordner erstellen const screenshotsDir = './screenshots'; if (!fs.existsSync(screenshotsDir)) { fs.mkdirSync(screenshotsDir, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const domain = new URL(url).hostname.replace(/\./g, '-'); // Desktop Screenshot const desktopPath = path.join(screenshotsDir, `${domain}-desktop-${timestamp}.png`); await page.screenshot({ path: desktopPath, fullPage: true }); result.screenshots = { desktop: desktopPath }; // Mobile Screenshot await page.setViewportSize({ width: 375, height: 667 }); const mobilePath = path.join(screenshotsDir, `${domain}-mobile-${timestamp}.png`); await page.screenshot({ path: mobilePath, fullPage: true }); result.screenshots.mobile = mobilePath; // Reset viewport await page.setViewportSize({ width: 1920, height: 1080 }); } catch (error) { if (options.verbose) { console.log(`Screenshot capture failed: ${error}`); } } } }