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

723 lines 31.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.WebVitalsCollector = exports.BUDGET_TEMPLATES = void 0; const logging_1 = require("../logging"); // Predefined budget templates exports.BUDGET_TEMPLATES = { ecommerce: { lcp: { good: 2000, poor: 3000 }, // Stricter for conversion cls: { good: 0.05, poor: 0.15 }, fcp: { good: 1500, poor: 2500 }, ttfb: { good: 300, poor: 600 } }, blog: { lcp: { good: 2500, poor: 4000 }, // Standard thresholds cls: { good: 0.1, poor: 0.25 }, fcp: { good: 1800, poor: 3000 }, ttfb: { good: 400, poor: 800 } }, corporate: { lcp: { good: 2200, poor: 3500 }, // Professional standards cls: { good: 0.08, poor: 0.2 }, fcp: { good: 1600, poor: 2800 }, ttfb: { good: 350, poor: 700 } }, default: { lcp: { good: 2500, poor: 4000 }, // Google's standard thresholds cls: { good: 0.1, poor: 0.25 }, fcp: { good: 1800, poor: 3000 }, ttfb: { good: 400, poor: 800 } } }; class WebVitalsCollector { constructor(budget, options) { this.budget = budget || exports.BUDGET_TEMPLATES.default; this.maxRetries = options?.maxRetries || 3; this.retryDelay = options?.retryDelay || 1000; this.verbose = options?.verbose || false; } /** * Collect Core Web Vitals using robust browser synchronization * Prevents timing issues and execution context destruction */ async collectMetrics(page) { try { // Use isolated context collection for maximum stability const metrics = await this.collectWithIsolatedContext(page); // Apply fallback strategies for missing metrics const enhancedMetrics = this.applyFallbackStrategies(metrics); // Calculate performance score and grade const score = this.calculateScore(enhancedMetrics); const grade = this.calculateGrade(score); const recommendations = this.generateRecommendations(enhancedMetrics); const budgetStatus = this.evaluateBudget(enhancedMetrics); return { ...enhancedMetrics, score, grade, recommendations, budgetStatus }; } catch (error) { // Always show fallback warnings, even in quiet mode - indicates potential implementation issues logging_1.log.fallback('Web Vitals Collection', 'collection failed', 'using basic navigation timing', error); return this.getFallbackMetrics(); } } /** * Collect metrics using isolated browser context for maximum stability * Each measurement runs in its own clean environment */ async collectWithIsolatedContext(page) { try { const browser = page.context().browser(); if (!browser) { // Use shared page instead of failing if (this.verbose) console.log('🔄 No browser for isolated context, using shared page'); return this.collectWithRetry(page, this.maxRetries); } // Create isolated context for performance measurement with minimal config const isolatedContext = await browser.newContext({ // Basic config only to avoid failures viewport: page.viewportSize() || { width: 1280, height: 720 }, javaScriptEnabled: true, ignoreHTTPSErrors: true }); try { const isolatedPage = await isolatedContext.newPage(); // Navigate to the same URL as the original page const currentUrl = page.url(); if (this.verbose) { console.log(`🔄 Creating isolated context for: ${currentUrl}`); } await isolatedPage.goto(currentUrl, { waitUntil: 'networkidle', timeout: 30000 }); // Collect metrics with enhanced retry mechanism const metrics = await this.collectWithAdvancedRetry(isolatedPage); return metrics; } finally { // Always clean up the isolated context try { await isolatedContext.close(); } catch (e) { if (this.verbose) console.warn('Error closing isolated context:', e); } } } catch (isolatedError) { // If isolated context fails, fallback to shared page if (this.verbose) console.log('🔄 Isolated context failed, using shared page:', isolatedError); return this.collectWithRetry(page, this.maxRetries); } } /** * Advanced retry mechanism with exponential backoff and different strategies */ async collectWithAdvancedRetry(page) { const strategies = [ { name: 'web-vitals-library', method: this.collectWithWebVitalsLibrary.bind(this) }, { name: 'performance-observer', method: this.collectWithPerformanceObserver.bind(this) }, { name: 'navigation-timing', method: this.getNavigationTimingFallback.bind(this) } ]; let lastError = null; for (let attempt = 1; attempt <= this.maxRetries; attempt++) { for (const strategy of strategies) { try { if (this.verbose) { console.log(`🔍 Attempt ${attempt}/${this.maxRetries} using ${strategy.name}`); } // Ensure page stability before each attempt await page.waitForLoadState('networkidle'); await page.waitForTimeout(Math.min(attempt * 200, 1000)); const metrics = await strategy.method(page); // Validate metrics quality if (this.hasValidMetrics(metrics)) { if (this.verbose) { console.log(`✅ Success with ${strategy.name} on attempt ${attempt}`); } return metrics; } else if (this.verbose) { console.log(`⚠️ ${strategy.name} returned incomplete metrics`); } } catch (error) { lastError = error; // Always show performance strategy failures - critical for debugging performance issues logging_1.log.fallback('Performance Strategy', `${strategy.name} failed on attempt ${attempt}`, 'trying next strategy', error); } } // Wait before next attempt with exponential backoff if (attempt < this.maxRetries) { const delay = this.retryDelay * Math.pow(2, attempt - 1); console.log(`⏱️ Waiting ${delay}ms before next attempt...`); await page.waitForTimeout(delay); } } // Always show this critical fallback - indicates serious performance measurement issues logging_1.log.fallback('Performance Collection', 'all strategies exhausted', 'using basic navigation timing (least accurate)'); return this.getNavigationTimingFallback(page); } /** * Collect metrics with retry mechanism and robust error handling */ async collectWithRetry(page, maxRetries) { let lastError = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const metrics = await this.collectMetricsOnce(page); // Validate metrics - if we get some valid data, use it if (this.hasValidMetrics(metrics)) { return metrics; } if (this.verbose) { console.log(`Attempt ${attempt} got incomplete metrics, retrying...`); } if (attempt < maxRetries) { // Wait before retry, with exponential backoff await page.waitForTimeout(attempt * 500); } } catch (error) { lastError = error; // Always show retry failures - helps identify patterns logging_1.log.fallback('Performance Collection', `attempt ${attempt} failed`, 'retrying with different strategy', error); if (attempt < maxRetries) { // Wait before retry await page.waitForTimeout(attempt * 1000); } } } // All retries failed, return fallback metrics - Always show this critical fallback logging_1.log.fallback('Performance Collection', 'all retry attempts failed', 'using navigation timing fallback'); return this.getNavigationTimingFallback(page); } /** * Single attempt at collecting metrics with better synchronization */ async collectMetricsOnce(page) { // First try the modern web-vitals library approach try { return await this.collectWithWebVitalsLibrary(page); } catch (error) { console.warn('Web-vitals library failed, trying PerformanceObserver approach:', error); return await this.collectWithPerformanceObserver(page); } } /** * Collect using Google's web-vitals library with better error handling */ async collectWithWebVitalsLibrary(page) { // Check if we can inject the library safely const canInject = await page.evaluate(() => { return !!(window && document && document.readyState); }); if (!canInject) { throw new Error('Page context not ready for script injection'); } // Inject library with timeout await Promise.race([ page.addScriptTag({ url: 'https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js' }), new Promise((_, reject) => setTimeout(() => reject(new Error('Script injection timeout')), 10000)) ]); // Collect metrics with timeout and better state management return await page.evaluate(() => { return new Promise((resolve, reject) => { const results = { lcp: 0, cls: 0, fcp: 0, ttfb: 0, loadTime: 0, domContentLoaded: 0, renderTime: 0 }; let resolved = false; let collectedMetrics = 0; const expectedMetrics = 4; // LCP, CLS, FCP, TTFB const finishCollection = (reason) => { if (!resolved) { resolved = true; // Add navigation timing metrics const navigation = performance.getEntriesByType('navigation')[0]; if (navigation) { results.loadTime = navigation.loadEventEnd; results.domContentLoaded = navigation.domContentLoadedEventEnd; results.renderTime = navigation.domContentLoadedEventEnd - navigation.responseEnd; } console.log(`Web Vitals collection finished (${reason}):`, results); resolve(results); } }; // Set up metric collectors with error handling try { if (!window.webVitals) { throw new Error('web-vitals library not available'); } const webVitals = window.webVitals; // Collect each metric with individual error handling try { webVitals.onLCP((metric) => { results.lcp = metric.value; collectedMetrics++; console.log(`LCP: ${metric.value}ms`); }); } catch (e) { console.warn('LCP collection failed:', e); } try { webVitals.onCLS((metric) => { results.cls = metric.value; collectedMetrics++; console.log(`CLS: ${metric.value}`); }); } catch (e) { console.warn('CLS collection failed:', e); } try { webVitals.onFCP((metric) => { results.fcp = metric.value; collectedMetrics++; console.log(`FCP: ${metric.value}ms`); }); } catch (e) { console.warn('FCP collection failed:', e); } try { webVitals.onTTFB((metric) => { results.ttfb = metric.value; collectedMetrics++; console.log(`TTFB: ${metric.value}ms`); }); } catch (e) { console.warn('TTFB collection failed:', e); } // Set timeout based on page state const timeout = document.readyState === 'complete' ? 3000 : 8000; setTimeout(() => { finishCollection(`timeout after ${timeout}ms`); }, timeout); // If page is already loaded, give it less time if (document.readyState === 'complete') { setTimeout(() => { if (collectedMetrics >= expectedMetrics * 0.6) { // If we have 60% of metrics finishCollection('sufficient metrics collected'); } }, 2000); } } catch (error) { reject(error); } }); }); } /** * Fallback collection using PerformanceObserver API directly */ async collectWithPerformanceObserver(page) { return await page.evaluate(() => { return new Promise((resolve) => { const results = { lcp: 0, cls: 0, fcp: 0, ttfb: 0, loadTime: 0, domContentLoaded: 0, renderTime: 0 }; let resolved = false; const finishCollection = () => { if (!resolved) { resolved = true; // Add navigation timing const navigation = performance.getEntriesByType('navigation')[0]; if (navigation) { results.loadTime = navigation.loadEventEnd; results.domContentLoaded = navigation.domContentLoadedEventEnd; results.renderTime = navigation.domContentLoadedEventEnd - navigation.responseEnd; results.ttfb = navigation.responseStart - navigation.requestStart; } // Add paint metrics const paintEntries = performance.getEntriesByType('paint'); const fcpEntry = paintEntries.find(entry => entry.name === 'first-contentful-paint'); if (fcpEntry) { results.fcp = fcpEntry.startTime; } resolve(results); } }; try { // Try to collect LCP const lcpObserver = new PerformanceObserver((list) => { const entries = list.getEntries(); const lastEntry = entries[entries.length - 1]; if (lastEntry) { results.lcp = lastEntry.startTime; } }); lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] }); // Try to collect CLS let clsValue = 0; const clsObserver = new PerformanceObserver((list) => { const entries = list.getEntries(); entries.forEach((entry) => { if (!entry.hadRecentInput) { clsValue += entry.value; } }); results.cls = clsValue; }); clsObserver.observe({ entryTypes: ['layout-shift'] }); // Set timeout setTimeout(finishCollection, 5000); } catch (error) { console.warn('PerformanceObserver setup failed:', error); setTimeout(finishCollection, 1000); } }); }); } /** * Get navigation timing as fallback when all else fails */ async getNavigationTimingFallback(page) { return await page.evaluate(() => { const navigation = performance.getEntriesByType('navigation')[0]; const paintEntries = performance.getEntriesByType('paint'); const fcp = paintEntries.find(entry => entry.name === 'first-contentful-paint')?.startTime || 0; return { lcp: fcp || navigation?.loadEventEnd || 0, cls: 0, // Can't measure without PerformanceObserver fcp: fcp, ttfb: navigation ? navigation.responseStart - navigation.requestStart : 0, loadTime: navigation?.loadEventEnd || 0, domContentLoaded: navigation?.domContentLoadedEventEnd || 0, renderTime: navigation ? navigation.domContentLoadedEventEnd - navigation.responseEnd : 0 }; }); } /** * Enhanced metrics validation with quality scoring */ hasValidMetrics(metrics) { const quality = this.assessMetricsQuality(metrics); return quality.score >= 0.4; // At least 40% quality required } /** * Assess the quality of collected metrics */ assessMetricsQuality(metrics) { let score = 0; const issues = []; const strengths = []; const maxScore = 10; // Core metrics availability (4 points) if (metrics.lcp > 0) { score += 1.5; strengths.push('LCP available'); } else issues.push('LCP missing'); if (metrics.fcp > 0) { score += 1; strengths.push('FCP available'); } else issues.push('FCP missing'); if (metrics.cls >= 0) { score += 0.5; strengths.push('CLS available'); } else issues.push('CLS missing'); if (metrics.ttfb > 0) { score += 1; strengths.push('TTFB available'); } else issues.push('TTFB missing'); // Navigation timing availability (2 points) if (metrics.loadTime > 0) { score += 1; strengths.push('Load time available'); } else issues.push('Load time missing'); if (metrics.domContentLoaded > 0) { score += 1; strengths.push('DOM timing available'); } else issues.push('DOM timing missing'); // Reasonableness checks (4 points) if (metrics.loadTime > 0 && metrics.loadTime < 60000) { score += 1; strengths.push('Reasonable load time'); } else if (metrics.loadTime >= 60000) issues.push('Unreasonable load time (>60s)'); if (metrics.lcp > 0 && metrics.lcp < 30000) { score += 1; strengths.push('Reasonable LCP'); } else if (metrics.lcp >= 30000) issues.push('Unreasonable LCP (>30s)'); if (metrics.fcp > 0 && metrics.fcp < 20000) { score += 1; strengths.push('Reasonable FCP'); } else if (metrics.fcp >= 20000) issues.push('Unreasonable FCP (>20s)'); if (metrics.cls >= 0 && metrics.cls < 5) { score += 1; strengths.push('Reasonable CLS'); } else if (metrics.cls >= 5) issues.push('Unreasonable CLS (>5)'); const normalizedScore = Math.max(0, Math.min(1, score / maxScore)); return { score: normalizedScore, issues, strengths }; } /** * Get metrics quality report for debugging */ getMetricsQualityReport(metrics) { const quality = this.assessMetricsQuality(metrics); const percentage = Math.round(quality.score * 100); let report = `Performance Metrics Quality: ${percentage}%\n`; if (quality.strengths.length > 0) { report += `✅ Strengths: ${quality.strengths.join(', ')}\n`; } if (quality.issues.length > 0) { report += `❌ Issues: ${quality.issues.join(', ')}\n`; } return report; } /** * Apply fallback strategies when Web Vitals metrics are missing or zero * Provides alternative calculations for small/static sites */ applyFallbackStrategies(metrics) { const enhanced = { ...metrics }; // LCP Fallback: Use navigation timing if LCP is 0 if (enhanced.lcp === 0 && enhanced.loadTime > 0) { // For small pages, LCP often equals load time or FCP enhanced.lcp = enhanced.fcp > 0 ? enhanced.fcp * 1.2 : enhanced.loadTime * 0.8; } // Additional LCP fallback using document timing if (enhanced.lcp === 0 && enhanced.domContentLoaded > 0) { // Estimate LCP as slightly after DOM ready for text-heavy pages enhanced.lcp = enhanced.domContentLoaded + 200; console.log(`LCP fallback from DOM timing: ${enhanced.lcp}ms`); } // CLS Fallback: Static pages often have 0 CLS, which is actually good if (enhanced.cls === 0) { // 0 CLS is perfect for static content, only log in verbose mode if (process.env.VERBOSE) { console.log('CLS is 0 - excellent layout stability for static content'); } } else if (enhanced.cls > 0 && enhanced.cls < 0.001) { // Very small CLS values are often measurement artifacts enhanced.cls = 0; console.log('CLS below threshold, normalized to 0'); } // TTFB Fallback: Calculate from navigation timing if available if (enhanced.ttfb === 0 && enhanced.domContentLoaded > 0) { // Rough estimate from navigation timing enhanced.ttfb = Math.max(100, enhanced.domContentLoaded * 0.3); console.log('TTFB fallback applied:', enhanced.ttfb); } // FCP Fallback: Very important metric, try to calculate if (enhanced.fcp === 0 && enhanced.domContentLoaded > 0) { // Estimate FCP from DOM ready time enhanced.fcp = enhanced.domContentLoaded * 0.7; console.log('FCP fallback applied:', enhanced.fcp); } return enhanced; } /** * Calculate performance score based on configurable budget thresholds * Uses custom scoring methodology based on user-defined budgets */ calculateScore(metrics) { let score = 100; // LCP scoring (25% weight) if (metrics.lcp > this.budget.lcp.poor) score -= 25; else if (metrics.lcp > this.budget.lcp.good) score -= 15; // CLS scoring (25% weight) if (metrics.cls > this.budget.cls.poor) score -= 25; else if (metrics.cls > this.budget.cls.good) score -= 15; // FCP scoring (35% weight) - increased from 20% if (metrics.fcp > this.budget.fcp.poor) score -= 35; else if (metrics.fcp > this.budget.fcp.good) score -= 18; // TTFB scoring (15% weight) if (metrics.ttfb > this.budget.ttfb.poor) score -= 15; else if (metrics.ttfb > this.budget.ttfb.good) score -= 8; return Math.max(0, Math.round(score)); } calculateGrade(score) { if (score >= 90) return 'A'; if (score >= 80) return 'B'; if (score >= 70) return 'C'; if (score >= 60) return 'D'; return 'F'; } generateRecommendations(metrics) { const recommendations = []; // LCP recommendations if (metrics.lcp > this.budget.lcp.good) { const status = metrics.lcp > this.budget.lcp.poor ? 'CRITICAL' : 'WARNING'; recommendations.push(`${status}: LCP (${metrics.lcp}ms) exceeds budget (${this.budget.lcp.good}ms good, ${this.budget.lcp.poor}ms poor). Compress images, use CDN, enable lazy loading`); } // CLS recommendations if (metrics.cls > this.budget.cls.good) { const status = metrics.cls > this.budget.cls.poor ? 'CRITICAL' : 'WARNING'; recommendations.push(`${status}: CLS (${metrics.cls.toFixed(3)}) exceeds budget (${this.budget.cls.good} good, ${this.budget.cls.poor} poor). Set explicit dimensions for images and ads`); } // FCP recommendations if (metrics.fcp > this.budget.fcp.good) { const status = metrics.fcp > this.budget.fcp.poor ? 'CRITICAL' : 'WARNING'; recommendations.push(`${status}: FCP (${metrics.fcp}ms) exceeds budget (${this.budget.fcp.good}ms good, ${this.budget.fcp.poor}ms poor). Minimize CSS, optimize fonts, reduce JavaScript`); } // TTFB recommendations if (metrics.ttfb > this.budget.ttfb.good) { const status = metrics.ttfb > this.budget.ttfb.poor ? 'CRITICAL' : 'WARNING'; recommendations.push(`${status}: TTFB (${metrics.ttfb}ms) exceeds budget (${this.budget.ttfb.good}ms good, ${this.budget.ttfb.poor}ms poor). Optimize backend, use CDN, enable compression`); } if (recommendations.length === 0) { recommendations.push('🎉 Excellent performance! All Core Web Vitals meet your performance budget.'); } return recommendations; } /** * Evaluate performance against budget and return status */ evaluateBudget(metrics) { const violations = []; // Check each metric against budget if (metrics.lcp > this.budget.lcp.good) { violations.push({ metric: 'lcp', actual: metrics.lcp, threshold: metrics.lcp > this.budget.lcp.poor ? this.budget.lcp.poor : this.budget.lcp.good, severity: metrics.lcp > this.budget.lcp.poor ? 'error' : 'warning', message: `LCP ${metrics.lcp}ms exceeds ${metrics.lcp > this.budget.lcp.poor ? 'poor' : 'good'} threshold` }); } if (metrics.cls > this.budget.cls.good) { violations.push({ metric: 'cls', actual: metrics.cls, threshold: metrics.cls > this.budget.cls.poor ? this.budget.cls.poor : this.budget.cls.good, severity: metrics.cls > this.budget.cls.poor ? 'error' : 'warning', message: `CLS ${metrics.cls.toFixed(3)} exceeds ${metrics.cls > this.budget.cls.poor ? 'poor' : 'good'} threshold` }); } if (metrics.fcp > this.budget.fcp.good) { violations.push({ metric: 'fcp', actual: metrics.fcp, threshold: metrics.fcp > this.budget.fcp.poor ? this.budget.fcp.poor : this.budget.fcp.good, severity: metrics.fcp > this.budget.fcp.poor ? 'error' : 'warning', message: `FCP ${metrics.fcp}ms exceeds ${metrics.fcp > this.budget.fcp.poor ? 'poor' : 'good'} threshold` }); } if (metrics.ttfb > this.budget.ttfb.good) { violations.push({ metric: 'ttfb', actual: metrics.ttfb, threshold: metrics.ttfb > this.budget.ttfb.poor ? this.budget.ttfb.poor : this.budget.ttfb.good, severity: metrics.ttfb > this.budget.ttfb.poor ? 'error' : 'warning', message: `TTFB ${metrics.ttfb}ms exceeds ${metrics.ttfb > this.budget.ttfb.poor ? 'poor' : 'good'} threshold` }); } const passed = violations.length === 0; const criticalViolations = violations.filter(v => v.severity === 'error').length; const warningViolations = violations.filter(v => v.severity === 'warning').length; let summary; if (passed) { summary = '🎉 All metrics within budget'; } else if (criticalViolations > 0) { summary = `❌ Budget failed: ${criticalViolations} critical, ${warningViolations} warnings`; } else { summary = `⚠️ Budget warnings: ${warningViolations} metrics exceed thresholds`; } return { passed, violations, summary }; } getFallbackMetrics(error) { const recommendations = [ 'Performance metrics collection failed.', 'This may be due to network issues, blocked resources, or browser restrictions.', 'Consider running the audit again or check your network connection.' ]; if (error) { recommendations.push(`Error details: ${error.message}`); } return { lcp: 0, cls: 0, fcp: 0, ttfb: 0, loadTime: 0, domContentLoaded: 0, renderTime: 0, score: 0, grade: 'F', recommendations, budgetStatus: { passed: false, violations: [{ metric: 'lcp', actual: 0, threshold: this.budget.lcp.good, severity: 'error', message: 'Unable to measure performance metrics' }], summary: '❌ Performance measurement failed' } }; } /** * Update retry configuration */ updateRetryConfig(maxRetries, retryDelay) { this.maxRetries = Math.max(1, Math.min(10, maxRetries)); // Limit between 1-10 this.retryDelay = Math.max(100, Math.min(10000, retryDelay)); // Limit between 100ms-10s } /** * Get current retry configuration */ getRetryConfig() { return { maxRetries: this.maxRetries, retryDelay: this.retryDelay }; } } exports.WebVitalsCollector = WebVitalsCollector; //# sourceMappingURL=web-vitals-collector.js.map