UNPKG

@casoon/auditmysite

Version:

Professional website analysis suite with robust accessibility testing, Core Web Vitals performance monitoring, SEO analysis, and content optimization insights. Features isolated browser contexts, retry mechanisms, and comprehensive API endpoints for profe

545 lines 23.1 kB
"use strict"; /** * AccessibilityChecker v2 - Clean Architecture * * This is a complete rewrite of the AccessibilityChecker with proper * separation of concerns, dependency injection, and typed interfaces. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AccessibilityTestError = exports.AccessibilityChecker = void 0; const pa11y_1 = __importDefault(require("pa11y")); const queue_1 = require("../queue"); const analyzer_factory_1 = require("../analyzers/analyzer-factory"); const analysis_orchestrator_1 = require("../analysis/analysis-orchestrator"); const structured_logger_1 = require("../logging/structured-logger"); /** * AccessibilityChecker v2 - Clean, focused implementation * * Responsibilities: * - Core accessibility testing via pa11y * - Basic page analysis (images, buttons, headings) * - Orchestration of comprehensive analysis (via AnalysisOrchestrator) * - Browser pool management coordination */ class AccessibilityChecker { constructor(config) { // Validate required dependencies if (!config.poolManager) { throw new Error('BrowserPoolManager is required'); } // Set up configuration with defaults this.config = { poolManager: config.poolManager, logger: config.logger || (0, structured_logger_1.createLogger)('accessibility-checker'), enableComprehensiveAnalysis: config.enableComprehensiveAnalysis || false, analyzerTypes: config.analyzerTypes || [], qualityAnalysisOptions: config.qualityAnalysisOptions || {} }; this.logger = this.config.logger; // Initialize analyzer factory if comprehensive analysis is enabled if (this.config.enableComprehensiveAnalysis) { try { const factoryConfig = { logger: this.logger.child ? this.logger.child('factory') : this.logger, qualityAnalysisOptions: this.config.qualityAnalysisOptions, enabledAnalyzers: this.config.analyzerTypes.length > 0 ? this.config.analyzerTypes : undefined }; this.analyzerFactory = new analyzer_factory_1.AnalyzerFactory(factoryConfig); // Initialize analysis orchestrator const orchestratorConfig = { analyzerFactory: this.analyzerFactory, logger: this.logger.child ? this.logger.child('orchestrator') : this.logger, defaultTimeout: 30000, failFast: false }; this.analysisOrchestrator = new analysis_orchestrator_1.AnalysisOrchestrator(orchestratorConfig); } catch (error) { this.logger.warn('Failed to initialize comprehensive analysis - continuing with basic accessibility testing only', error); // Cannot modify readonly config - comprehensive analysis will remain disabled for this session } } } /** * Initialize the accessibility checker */ async initialize() { this.logger.info('AccessibilityChecker initialized with browser pooling'); } /** * Cleanup resources */ async cleanup() { if (this.analyzerFactory) { await this.analyzerFactory.cleanup(); } this.logger.info('AccessibilityChecker cleaned up'); } /** * Test a single page for accessibility */ async testPage(url, options = {}) { const startTime = Date.now(); const logger = this.logger.child ? this.logger.child('test-page') : this.logger; logger.info(`Testing page: ${url}`); const { browser, context, release } = await this.config.poolManager.acquire(); try { const page = await context.newPage(); try { // Navigate to page await this.configurePage(page, options); // Observe navigation-level redirects let wasRedirectNav = false; const onResponse = (res) => { try { const req = res.request(); const isNav = typeof req.isNavigationRequest === 'function' ? req.isNavigationRequest() : false; if (isNav && res.status() >= 300 && res.status() < 400) { wasRedirectNav = true; } } catch { } }; page.on('response', onResponse); const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeout || 30000 }); // Stop observing responses page.off('response', onResponse); // Check for errors and redirects if (!response || response.status() >= 400) { throw new Error(`HTTP ${response?.status() || 'unknown'} error`); } // If this navigation was the result of an HTTP redirect and skipping is enabled, short-circuit const skipRedirects = options.skipRedirects !== false; try { const lastRequest = response.request(); if (typeof lastRequest.redirectedFrom === 'function') { wasRedirectNav = wasRedirectNav || !!lastRequest.redirectedFrom(); } } catch { } if (skipRedirects && wasRedirectNav) { const duration = Date.now() - startTime; const titleNow = await page.title(); const minimal = { url, title: titleNow || 'Redirected', accessibilityResult: { url, title: titleNow || 'Redirected', imagesWithoutAlt: 0, buttonsWithoutLabel: 0, headingsCount: 0, errors: [`HTTP Redirect detected (skipped)`], warnings: [], passed: false, crashed: false, skipped: true, duration }, comprehensiveAnalysis: undefined, duration, timestamp: new Date() }; return minimal; } // Run basic accessibility analysis const accessibilityResult = await this.runBasicAccessibilityAnalysis(page, url, options); // Run comprehensive analysis if enabled let comprehensiveAnalysis; if ((options.enableComprehensiveAnalysis ?? this.config.enableComprehensiveAnalysis) && this.analysisOrchestrator) { comprehensiveAnalysis = await this.analysisOrchestrator.runComprehensiveAnalysis(page, url, { timeout: options.timeout }); } const duration = Date.now() - startTime; const result = { url, title: accessibilityResult.title, accessibilityResult, comprehensiveAnalysis, duration, timestamp: new Date() }; logger.info(`Page testing completed`, { url, duration, passed: accessibilityResult.passed }); return result; } finally { await page.close(); } } finally { await release(); } } /** * Test multiple pages in parallel with redirect filtering */ async testMultiplePages(urls, options = {}) { const startTime = Date.now(); const logger = this.logger.child ? this.logger.child('multi-test') : this.logger; const skipRedirects = options.skipRedirects !== false; logger.info(`Testing ${urls.length} pages`, { concurrency: options.maxConcurrent || 3, comprehensive: options.enableComprehensiveAnalysis ?? this.config.enableComprehensiveAnalysis, skipRedirects }); const results = []; const skippedUrls = []; let urlsToProcess = urls; // Pre-filter redirects if enabled if (skipRedirects) { logger.debug('Pre-filtering redirects...'); const filteredUrls = []; for (const url of urls) { try { const minimalResult = await this.testUrlMinimal(url, 8000); if (minimalResult.skipped && minimalResult.errors.some(e => e.includes('Redirect'))) { skippedUrls.push(url); logger.debug(`Skipped redirect: ${url}`); } else { filteredUrls.push(url); } } catch (error) { // If minimal test fails, include URL for full testing filteredUrls.push(url); } } urlsToProcess = filteredUrls; logger.info(`After redirect filtering: ${urlsToProcess.length} URLs to test, ${skippedUrls.length} redirects skipped`); } // Configure queue callbacks const callbacks = { onProgressUpdate: (stats) => { if (stats.progress > 0 && stats.progress % 25 === 0) { logger.info(`Progress: ${stats.progress.toFixed(1)}%`, { completed: stats.completed, total: stats.total, workers: stats.activeWorkers }); } }, onItemCompleted: (item, result) => { logger.debug(`Completed: ${item.data}`, { duration: item.duration }); }, onItemFailed: (item, error) => { logger.warn(`Failed: ${item.data}`, { error, attempts: item.attempts }); }, onQueueEmpty: () => { logger.info('All page tests completed'); } }; // Create optimized queue for accessibility testing const queue = queue_1.Queue.forAccessibilityTesting('parallel', { maxConcurrent: options.maxConcurrent || 3, maxRetries: 3, retryDelay: 2000, timeout: options.timeout || 30000, enableProgressReporting: true, progressUpdateInterval: 3000 }, callbacks); try { // Process filtered URLs with queue const result = await queue.processWithProgress(urlsToProcess, async (url) => { return await this.testPage(url, options); }, { showProgress: !options.verbose, progressInterval: 3000 }); // Extract successful results result.completed.forEach(item => { if (item.result) { results.push(item.result); } }); // Add failed items as error results result.failed.forEach(failedItem => { results.push({ url: failedItem.data, title: 'Error', accessibilityResult: { url: failedItem.data, title: 'Error', imagesWithoutAlt: 0, buttonsWithoutLabel: 0, headingsCount: 0, errors: [`Test failed: ${failedItem.error}`], warnings: [], passed: false, crashed: true, duration: failedItem.duration || 0 }, duration: failedItem.duration || 0, timestamp: new Date() }); }); const totalDuration = Date.now() - startTime; logger.info(`Multi-page testing completed`, { total: urls.length, tested: results.length, skipped: skippedUrls.length, failed: result.failed.length, totalDuration }); return { results, skippedUrls, totalDuration, timestamp: new Date() }; } catch (error) { logger.error('Multi-page testing failed', error); throw error; } } /** * Test URL for basic connectivity (used by SmartUrlSampler) */ async testUrlMinimal(url, timeout = 5000) { const startTime = Date.now(); const logger = this.logger.child ? this.logger.child('minimal-test') : this.logger; logger.debug(`Minimal test: ${url}`); const { browser, context, release } = await this.config.poolManager.acquire(); try { const page = await context.newPage(); try { page.setDefaultTimeout(timeout); // Observe navigation-level redirects let wasRedirect = false; const onResponse = (res) => { try { const req = res.request(); const isNav = typeof req.isNavigationRequest === 'function' ? req.isNavigationRequest() : false; if (isNav && res.status() >= 300 && res.status() < 400) { wasRedirect = true; } } catch { } }; page.on('response', onResponse); const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout }); // Stop observing responses page.off('response', onResponse); const finalUrl = page.url(); const title = await page.title(); const status = response?.status() || 0; // Detect redirects even when Playwright follows them to a final 200 try { const lastRequest = response?.request(); if (lastRequest) { // If the last request was created via a redirect chain, redirectedFrom() will be non-null if (typeof lastRequest.redirectedFrom === 'function') { wasRedirect = wasRedirect || !!lastRequest.redirectedFrom(); } } } catch { } // Only treat real HTTP redirects as redirects, not arbitrary URL changes const isHttpRedirect = wasRedirect || (status >= 300 && status < 400); const urlChanged = finalUrl !== url; const result = { url, title: title || 'Untitled', imagesWithoutAlt: 0, buttonsWithoutLabel: 0, headingsCount: 0, errors: [], warnings: [], passed: status >= 200 && status < 300, // Only 2xx are successful crashed: false, skipped: isHttpRedirect || status === 404, duration: Date.now() - startTime }; // Add status-specific errors if (status === 404) { result.errors.push('HTTP 404 Not Found'); } else if (status >= 300 && status < 400) { result.errors.push(`HTTP ${status} Redirect`); } else if (status >= 400) { result.errors.push(`HTTP ${status} Error`); } // Add redirect info if needed if (isHttpRedirect) { result.redirectInfo = { status: status >= 300 && status < 400 ? status : 0, originalUrl: url, finalUrl, type: 'http_redirect' }; } return result; } finally { await page.close(); } } catch (error) { logger.debug(`Minimal test failed: ${url}`, error); return { url, title: 'Error', imagesWithoutAlt: 0, buttonsWithoutLabel: 0, headingsCount: 0, errors: [`Navigation failed: ${error}`], warnings: [], passed: false, crashed: true, duration: Date.now() - startTime }; } finally { await release(); } } /** * Get available analysis types */ getAvailableAnalysisTypes() { if (!this.analysisOrchestrator) { return []; } return this.analysisOrchestrator.getAvailableAnalyzers(); } // Private helper methods async configurePage(page, options) { // Set viewport await page.setViewportSize({ width: 1920, height: 1080 }); // Set user agent await page.setExtraHTTPHeaders({ 'User-Agent': 'auditmysite/2.0.0 (+https://github.com/casoon/AuditMySite)' }); // Configure console/error logging based on verbose setting if (options.verbose) { page.on('console', msg => this.logger.debug(`Browser: ${msg.text()}`)); page.on('pageerror', error => this.logger.warn(`JS Error: ${error.message}`)); } } async runBasicAccessibilityAnalysis(page, url, options) { const startTime = Date.now(); const result = { url, title: await page.title(), imagesWithoutAlt: 0, buttonsWithoutLabel: 0, headingsCount: 0, errors: [], warnings: [], passed: true, duration: 0 }; // Basic element checks result.imagesWithoutAlt = await page.locator('img:not([alt])').count(); if (result.imagesWithoutAlt > 0) { result.warnings.push(`${result.imagesWithoutAlt} images without alt attribute`); } 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`); } result.headingsCount = await page.locator('h1, h2, h3, h4, h5, h6').count(); if (result.headingsCount === 0) { result.errors.push('No headings found'); } // Run pa11y tests await this.runPa11yTests(result, options, page); result.duration = Date.now() - startTime; result.passed = result.errors.length === 0; return result; } async runPa11yTests(result, options, page) { try { this.logger.debug('Running pa11y accessibility tests'); const pa11yResult = await (0, pa11y_1.default)(result.url, { timeout: options.timeout || 15000, wait: options.wait || 1000, standard: options.pa11yStandard || 'WCAG2AA', hideElements: options.hideElements || 'iframe[src*="google-analytics"], iframe[src*="doubleclick"]', includeNotices: options.includeNotices !== false, includeWarnings: options.includeWarnings !== false, runners: ['axe'] // Always use axe for pooled browsers }); // Process pa11y issues if (pa11yResult.issues) { pa11yResult.issues.forEach((issue) => { const detailedIssue = { code: issue.code, message: issue.message, type: issue.type, selector: issue.selector, context: issue.context, impact: issue.impact, help: issue.help, helpUrl: issue.helpUrl }; result.pa11yIssues = result.pa11yIssues || []; result.pa11yIssues.push(detailedIssue); // Add to appropriate array 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}`); } }); // Calculate pa11y score const totalIssues = pa11yResult.issues.length; const errors = pa11yResult.issues.filter((i) => i.type === 'error').length; const warnings = pa11yResult.issues.filter((i) => i.type === 'warning').length; let score = 100; score -= errors * 10; // 10 points per error score -= warnings * 2; // 2 points per warning result.pa11yScore = Math.max(0, score); } else { result.pa11yScore = 100; } } catch (error) { this.logger.warn('pa11y test failed, using fallback scoring', error); // Fallback score calculation let score = 100; score -= result.errors.length * 15; score -= result.warnings.length * 5; score -= result.imagesWithoutAlt * 3; score -= result.buttonsWithoutLabel * 5; if (result.headingsCount === 0) score -= 20; result.pa11yScore = Math.max(0, score); } } } exports.AccessibilityChecker = AccessibilityChecker; /** * Error classes */ class AccessibilityTestError extends Error { constructor(url, message) { super(`Accessibility test failed for ${url}: ${message}`); this.name = 'AccessibilityTestError'; } } exports.AccessibilityTestError = AccessibilityTestError; //# sourceMappingURL=accessibility-checker.js.map