diff --git a/browser-tools-server/lighthouse/performance.ts b/browser-tools-server/lighthouse/performance.ts index c4adbd9..3f20e9c 100644 --- a/browser-tools-server/lighthouse/performance.ts +++ b/browser-tools-server/lighthouse/performance.ts @@ -2,18 +2,6 @@ import { Result as LighthouseResult } from "lighthouse"; import { AuditCategory, LighthouseReport } from "./types.js"; import { runLighthouseAudit } from "./index.js"; -interface PerformanceAudit { - id: string; // e.g., "first-contentful-paint" - title: string; // e.g., "First Contentful Paint" - description: string; // e.g., "Time to first contentful paint..." - score: number | null; // 0-1 or null - scoreDisplayMode: string; // e.g., "numeric" - numericValue?: number; // e.g., 1.8 (seconds) or 200 (ms) - numericUnit?: string; // e.g., "s" or "ms" - details?: PerformanceAuditDetails; // Optional, structured details - weight?: number; // For prioritization -} - interface PerformanceAuditDetails { items?: Array<{ resourceUrl?: string; // e.g., "https://example.com/script.js" (for render-blocking resources) @@ -24,15 +12,82 @@ interface PerformanceAuditDetails { type?: string; // e.g., "opportunity" or "table" } -const FAILED_AUDITS_LIMIT = 5; -const MAX_ITEMS_IN_DETAILS = 3; +// AI-optimized performance metric format +interface AIOptimizedMetric { + id: string; // Short ID like "lcp", "fcp" + score: number | null; // 0-1 score + value_ms: number; // Value in milliseconds + element_type?: string; // For LCP: "image", "text", etc. + element_selector?: string; // DOM selector for the element + element_url?: string; // For images/videos + element_content?: string; // For text content (truncated) + passes_core_web_vital?: boolean; // Whether this metric passes as a Core Web Vital +} -export async function runPerformanceAudit( - url: string -): Promise { +// AI-optimized opportunity format +interface AIOptimizedOpportunity { + id: string; // Like "render_blocking", "http2" + savings_ms: number; // Time savings in ms + severity?: "critical" | "major" | "minor"; // Severity classification + resources: Array<{ + url: string; // Resource URL + savings_ms?: number; // Individual resource savings + size_kb?: number; // Size in KB + type?: string; // Resource type (js, css, img, etc.) + is_third_party?: boolean; // Whether this is a third-party resource + }>; +} + +// Page stats for AI analysis +interface AIPageStats { + total_size_kb: number; // Total page weight in KB + total_requests: number; // Total number of requests + resource_counts: { + // Count by resource type + js: number; + css: number; + img: number; + font: number; + other: number; + }; + third_party_size_kb: number; // Size of third-party resources + main_thread_blocking_time_ms: number; // Time spent blocking the main thread +} + +// AI-optimized performance report +interface AIOptimizedPerformanceReport { + metadata: { + url: string; // Page URL + timestamp: string; // ISO timestamp + device: string; // Device type (desktop, mobile) + lighthouseVersion: string; // Lighthouse version used + }; + score: number; // Overall score (0-100) + audit_counts: { + // Counts of different audit types + failed: number; + passed: number; + manual: number; + informative: number; + not_applicable: number; + }; + metrics: AIOptimizedMetric[]; + opportunities: AIOptimizedOpportunity[]; + page_stats?: AIPageStats; // Optional page statistics + prioritized_recommendations?: string[]; // Ordered list of recommendations +} + +/** + * Performance audit adapted for AI consumption + * This format is optimized for AI agents with: + * - Concise, relevant information without redundant descriptions + * - Key metrics and opportunities clearly structured + * - Only actionable data that an AI can use for recommendations + */ +export async function runPerformanceAudit(url: string): Promise { try { const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]); - return extractPerformanceResult(lhr, url); + return extractAIOptimizedData(lhr, url); } catch (error) { throw new Error( `Performance audit failed: ${ @@ -42,106 +97,625 @@ export async function runPerformanceAudit( } } -const extractPerformanceResult = ( +/** + * Extract AI-optimized performance data from Lighthouse results + */ +const extractAIOptimizedData = ( lhr: LighthouseResult, url: string -): LighthouseReport => { +): AIOptimizedPerformanceReport => { + const audits = lhr.audits || {}; const categoryData = lhr.categories[AuditCategory.PERFORMANCE]; + const score = Math.round((categoryData?.score || 0) * 100); + + // Add metadata const metadata = { url, - timestamp: lhr.fetchTime - ? new Date(lhr.fetchTime).toISOString() - : new Date().toISOString(), - device: "desktop", // TODO: pass device from the request instead of hardcoding + timestamp: lhr.fetchTime || new Date().toISOString(), + device: "desktop", // This could be made configurable lighthouseVersion: lhr.lighthouseVersion, }; - if (!categoryData) { - return { - metadata, - failedAudits: [], - overallScore: 0, - failedAuditsCount: 0, - passedAuditsCount: 0, - manualAuditsCount: 0, - informativeAuditsCount: 0, - notApplicableAuditsCount: 0, - }; - } + // Count audits by type + const auditRefs = categoryData?.auditRefs || []; + let failedCount = 0; + let passedCount = 0; + let manualCount = 0; + let informativeCount = 0; + let notApplicableCount = 0; - const overallScore = Math.round((categoryData.score || 0) * 100); - const auditRefs = categoryData.auditRefs || []; - const audits = lhr.audits || {}; - - const performanceAudits: PerformanceAudit[] = auditRefs.map((ref) => { + auditRefs.forEach((ref) => { const audit = audits[ref.id]; - let simplifiedDetails: PerformanceAuditDetails | undefined; + if (!audit) return; - if (audit.details) { - simplifiedDetails = {}; - if ( - (audit.details as any).items && - Array.isArray((audit.details as any).items) - ) { - const limitedItems = (audit.details as any).items.slice( - 0, - MAX_ITEMS_IN_DETAILS - ); - simplifiedDetails.items = limitedItems.map((item: any) => { - const simplifiedItem: any = {}; - if (item.url) simplifiedItem.resourceUrl = item.url; // For render-blocking resources - if (item.wastedMs) simplifiedItem.wastedMs = item.wastedMs; // Potential savings - if (item.node?.selector) - simplifiedItem.elementSelector = item.node.selector; // For LCP element - if (item.timing) simplifiedItem.timing = item.timing; // Specific timing - return simplifiedItem; - }); + if (audit.scoreDisplayMode === "manual") { + manualCount++; + } else if (audit.scoreDisplayMode === "informative") { + informativeCount++; + } else if (audit.scoreDisplayMode === "notApplicable") { + notApplicableCount++; + } else if (audit.score !== null) { + if (audit.score >= 0.9) { + passedCount++; + } else { + failedCount++; } - if (audit.details.type) simplifiedDetails.type = audit.details.type; } - - return { - id: ref.id, - title: audit.title || "Untitled", - description: audit.description || "No description", - score: audit.score, - scoreDisplayMode: audit.scoreDisplayMode || "numeric", - numericValue: audit.numericValue, - numericUnit: audit.numericUnit, - details: simplifiedDetails, - weight: ref.weight || 1, - }; }); - const failedAudits = performanceAudits - .filter((audit) => audit.score !== null && audit.score < 1) - .sort( - (a, b) => - b.weight! * (1 - (b.score || 0)) - a.weight! * (1 - (a.score || 0)) - ) - .slice(0, FAILED_AUDITS_LIMIT); + const audit_counts = { + failed: failedCount, + passed: passedCount, + manual: manualCount, + informative: informativeCount, + not_applicable: notApplicableCount, + }; - const passedAudits = performanceAudits.filter( - (audit) => audit.score !== null && audit.score >= 1 - ); - const manualAudits = performanceAudits.filter( - (audit) => audit.scoreDisplayMode === "manual" - ); - const informativeAudits = performanceAudits.filter( - (audit) => audit.scoreDisplayMode === "informative" - ); - const notApplicableAudits = performanceAudits.filter( - (audit) => audit.scoreDisplayMode === "notApplicable" - ); + const metrics: AIOptimizedMetric[] = []; + const opportunities: AIOptimizedOpportunity[] = []; + // Extract core metrics + if (audits["largest-contentful-paint"]) { + const lcp = audits["largest-contentful-paint"]; + const lcpElement = audits["largest-contentful-paint-element"]; + + const metric: AIOptimizedMetric = { + id: "lcp", + score: lcp.score, + value_ms: Math.round(lcp.numericValue || 0), + passes_core_web_vital: lcp.score !== null && lcp.score >= 0.9, + }; + + // Enhanced LCP element detection + console.log("DEBUG: Attempting to find LCP element information"); + + // 1. Try from largest-contentful-paint-element audit + if (lcpElement && lcpElement.details) { + console.log("DEBUG: Found LCP element audit with details"); + const lcpDetails = lcpElement.details as any; + console.log( + "DEBUG: LCP element details:", + JSON.stringify(lcpDetails).substring(0, 500) + ); + + // First attempt - try to get directly from items + if ( + lcpDetails.items && + Array.isArray(lcpDetails.items) && + lcpDetails.items.length > 0 + ) { + const item = lcpDetails.items[0]; + console.log( + "DEBUG: Found LCP element item:", + JSON.stringify(item).substring(0, 500) + ); + + // For text elements in tables format + if (item.type === "table" && item.items && item.items.length > 0) { + const firstTableItem = item.items[0]; + console.log( + "DEBUG: Found table format item:", + JSON.stringify(firstTableItem).substring(0, 500) + ); + + if (firstTableItem.node) { + if (firstTableItem.node.selector) { + metric.element_selector = firstTableItem.node.selector; + console.log( + "DEBUG: Found LCP selector from table:", + metric.element_selector + ); + } + + // Determine element type based on path or selector + const path = firstTableItem.node.path; + const selector = firstTableItem.node.selector || ""; + + if (path) { + console.log("DEBUG: Element path:", path); + if ( + selector.includes(" > img") || + selector.includes(" img") || + selector.endsWith("img") || + path.includes(",IMG") + ) { + metric.element_type = "image"; + console.log( + "DEBUG: Element type set to image based on path/selector" + ); + + // Try to extract image name from selector + const imgMatch = selector.match(/img[.][^> ]+/); + if (imgMatch && !metric.element_url) { + metric.element_url = imgMatch[0]; + console.log( + "DEBUG: Extracted image class name as URL fallback:", + metric.element_url + ); + } + } else if ( + path.includes(",SPAN") || + path.includes(",P") || + path.includes(",H") + ) { + metric.element_type = "text"; + console.log("DEBUG: Element type set to text based on path"); + } + } + + // Try to extract text content if available + if (firstTableItem.node.nodeLabel) { + console.log("DEBUG: Node label:", firstTableItem.node.nodeLabel); + metric.element_content = firstTableItem.node.nodeLabel.substring( + 0, + 100 + ); + } + } + } + // Original handling for direct items + else if (item.node?.nodeLabel) { + console.log("DEBUG: LCP element node label:", item.node.nodeLabel); + // Determine element type from node label + if (item.node.nodeLabel.startsWith(" 0 + ) { + const item = lcpImageDetails.items[0]; + console.log( + "DEBUG: LCP lazy-loaded item:", + JSON.stringify(item).substring(0, 500) + ); + + if (item.url) { + metric.element_type = "image"; + metric.element_url = item.url; + console.log( + "DEBUG: Found LCP image URL from lazy-loaded:", + metric.element_url + ); + } + } + } else { + console.log("DEBUG: No LCP lazy-loaded audit with details found"); + } + + // 3. Try directly from the LCP audit details + if (!metric.element_url && lcp.details) { + console.log("DEBUG: Trying to extract from LCP audit details directly"); + const lcpDirectDetails = lcp.details as any; + console.log( + "DEBUG: LCP audit details:", + JSON.stringify(lcpDirectDetails).substring(0, 500) + ); + + if (lcpDirectDetails.items && Array.isArray(lcpDirectDetails.items)) { + for (const item of lcpDirectDetails.items) { + console.log( + "DEBUG: LCP direct item:", + JSON.stringify(item).substring(0, 500) + ); + + if (item.url || (item.node && item.node.path)) { + if (item.url) { + metric.element_url = item.url; + metric.element_type = item.url.match( + /\.(jpg|jpeg|png|gif|webp|svg)$/i + ) + ? "image" + : "resource"; + console.log( + "DEBUG: Found LCP URL from direct details:", + metric.element_url + ); + } + if (item.node && item.node.selector) { + metric.element_selector = item.node.selector; + console.log( + "DEBUG: Found LCP selector from direct details:", + metric.element_selector + ); + } + break; + } + } + } + } + + // 4. Check for specific audit that might contain image info + const largestImageAudit = audits["largest-image-paint"]; + if (largestImageAudit && largestImageAudit.details) { + console.log("DEBUG: Found largest-image-paint audit"); + const imageDetails = largestImageAudit.details as any; + + if ( + imageDetails.items && + Array.isArray(imageDetails.items) && + imageDetails.items.length > 0 + ) { + const item = imageDetails.items[0]; + console.log( + "DEBUG: Largest image item:", + JSON.stringify(item).substring(0, 500) + ); + + if (item.url) { + // If we have a large image that's close in time to LCP, it's likely the LCP element + metric.element_type = "image"; + metric.element_url = item.url; + console.log( + "DEBUG: Found image URL from largest-image-paint:", + metric.element_url + ); + } + } + } + + // 5. Check for network requests audit to find image resources + if (!metric.element_url) { + console.log("DEBUG: Checking network requests for potential LCP images"); + const networkRequests = audits["network-requests"]; + + if (networkRequests && networkRequests.details) { + const networkDetails = networkRequests.details as any; + + if (networkDetails.items && Array.isArray(networkDetails.items)) { + // Get all image resources loaded close to the LCP time + const lcpTime = lcp.numericValue || 0; + const imageResources = networkDetails.items + .filter( + (item: any) => + item.url && + item.mimeType && + item.mimeType.startsWith("image/") && + item.endTime && + Math.abs(item.endTime - lcpTime) < 500 // Within 500ms of LCP + ) + .sort( + (a: any, b: any) => + Math.abs(a.endTime - lcpTime) - Math.abs(b.endTime - lcpTime) + ); + + if (imageResources.length > 0) { + const closestImage = imageResources[0]; + console.log( + "DEBUG: Found potential LCP image from network:", + closestImage.url + ); + + if (!metric.element_type) { + metric.element_type = "image"; + metric.element_url = closestImage.url; + } + } + } + } + } + + console.log( + "DEBUG: Final LCP element info:", + metric.element_type || "unknown type", + metric.element_url || "no URL", + metric.element_selector || "no selector" + ); + + metrics.push(metric); + } + + if (audits["first-contentful-paint"]) { + const fcp = audits["first-contentful-paint"]; + metrics.push({ + id: "fcp", + score: fcp.score, + value_ms: Math.round(fcp.numericValue || 0), + passes_core_web_vital: fcp.score !== null && fcp.score >= 0.9, + }); + } + + if (audits["speed-index"]) { + const si = audits["speed-index"]; + metrics.push({ + id: "si", + score: si.score, + value_ms: Math.round(si.numericValue || 0), + }); + } + + if (audits["interactive"]) { + const tti = audits["interactive"]; + metrics.push({ + id: "tti", + score: tti.score, + value_ms: Math.round(tti.numericValue || 0), + }); + } + + // Add CLS (Cumulative Layout Shift) + if (audits["cumulative-layout-shift"]) { + const cls = audits["cumulative-layout-shift"]; + metrics.push({ + id: "cls", + score: cls.score, + // CLS is not in ms, but a unitless value + value_ms: Math.round((cls.numericValue || 0) * 1000) / 1000, // Convert to 3 decimal places + passes_core_web_vital: cls.score !== null && cls.score >= 0.9, + }); + } + + // Add TBT (Total Blocking Time) + if (audits["total-blocking-time"]) { + const tbt = audits["total-blocking-time"]; + metrics.push({ + id: "tbt", + score: tbt.score, + value_ms: Math.round(tbt.numericValue || 0), + passes_core_web_vital: tbt.score !== null && tbt.score >= 0.9, + }); + } + + // Extract opportunities + if (audits["render-blocking-resources"]) { + const rbrAudit = audits["render-blocking-resources"]; + const opportunity: AIOptimizedOpportunity = { + id: "render_blocking_resources", + savings_ms: Math.round(rbrAudit.numericValue || 0), + resources: [], + }; + + const rbrDetails = rbrAudit.details as any; + if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) { + rbrDetails.items.forEach((item: { url?: string; wastedMs?: number }) => { + if (item.url) { + // Extract file name from full URL + const fileName = item.url.split("/").pop() || item.url; + opportunity.resources.push({ + url: fileName, + savings_ms: Math.round(item.wastedMs || 0), + }); + } + }); + } + + if (opportunity.resources.length > 0) { + opportunities.push(opportunity); + } + } + + if (audits["uses-http2"]) { + const http2Audit = audits["uses-http2"]; + const opportunity: AIOptimizedOpportunity = { + id: "http2", + savings_ms: Math.round(http2Audit.numericValue || 0), + resources: [], + }; + + const http2Details = http2Audit.details as any; + if ( + http2Details && + http2Details.items && + Array.isArray(http2Details.items) + ) { + http2Details.items.forEach((item: { url?: string }) => { + if (item.url) { + // Extract file name from full URL + const fileName = item.url.split("/").pop() || item.url; + opportunity.resources.push({ url: fileName }); + } + }); + } + + if (opportunity.resources.length > 0) { + opportunities.push(opportunity); + } + } + + // After extracting all metrics and opportunities, collect page stats + // Extract page stats + let page_stats: AIPageStats | undefined; + + // Total page stats + const totalByteWeight = audits["total-byte-weight"]; + const networkRequests = audits["network-requests"]; + const thirdPartyAudit = audits["third-party-summary"]; + const mainThreadWork = audits["mainthread-work-breakdown"]; + + if (networkRequests && networkRequests.details) { + const resourceDetails = networkRequests.details as any; + + if (resourceDetails.items && Array.isArray(resourceDetails.items)) { + const resources = resourceDetails.items; + const totalRequests = resources.length; + + // Calculate total size and counts by type + let totalSizeKb = 0; + let jsCount = 0, + cssCount = 0, + imgCount = 0, + fontCount = 0, + otherCount = 0; + + resources.forEach((resource: any) => { + const sizeKb = resource.transferSize + ? Math.round(resource.transferSize / 1024) + : 0; + totalSizeKb += sizeKb; + + // Count by mime type + const mimeType = resource.mimeType || ""; + if (mimeType.includes("javascript") || resource.url.endsWith(".js")) { + jsCount++; + } else if (mimeType.includes("css") || resource.url.endsWith(".css")) { + cssCount++; + } else if ( + mimeType.includes("image") || + /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(resource.url) + ) { + imgCount++; + } else if ( + mimeType.includes("font") || + /\.(woff|woff2|ttf|otf|eot)$/i.test(resource.url) + ) { + fontCount++; + } else { + otherCount++; + } + }); + + // Calculate third-party size + let thirdPartySizeKb = 0; + if (thirdPartyAudit && thirdPartyAudit.details) { + const thirdPartyDetails = thirdPartyAudit.details as any; + if (thirdPartyDetails.items && Array.isArray(thirdPartyDetails.items)) { + thirdPartyDetails.items.forEach((item: any) => { + if (item.transferSize) { + thirdPartySizeKb += Math.round(item.transferSize / 1024); + } + }); + } + } + + // Get main thread blocking time + let mainThreadBlockingTimeMs = 0; + if (mainThreadWork && mainThreadWork.numericValue) { + mainThreadBlockingTimeMs = Math.round(mainThreadWork.numericValue); + } + + // Create page stats object + page_stats = { + total_size_kb: totalSizeKb, + total_requests: totalRequests, + resource_counts: { + js: jsCount, + css: cssCount, + img: imgCount, + font: fontCount, + other: otherCount, + }, + third_party_size_kb: thirdPartySizeKb, + main_thread_blocking_time_ms: mainThreadBlockingTimeMs, + }; + } + } + + // Generate prioritized recommendations + const prioritized_recommendations: string[] = []; + + // Add key recommendations based on failed audits with high impact + if ( + audits["render-blocking-resources"] && + audits["render-blocking-resources"].score !== null && + audits["render-blocking-resources"].score === 0 + ) { + prioritized_recommendations.push("Eliminate render-blocking resources"); + } + + if ( + audits["uses-responsive-images"] && + audits["uses-responsive-images"].score !== null && + audits["uses-responsive-images"].score === 0 + ) { + prioritized_recommendations.push("Properly size images"); + } + + if ( + audits["uses-optimized-images"] && + audits["uses-optimized-images"].score !== null && + audits["uses-optimized-images"].score === 0 + ) { + prioritized_recommendations.push("Efficiently encode images"); + } + + if ( + audits["uses-text-compression"] && + audits["uses-text-compression"].score !== null && + audits["uses-text-compression"].score === 0 + ) { + prioritized_recommendations.push("Enable text compression"); + } + + if ( + audits["uses-http2"] && + audits["uses-http2"].score !== null && + audits["uses-http2"].score === 0 + ) { + prioritized_recommendations.push("Use HTTP/2"); + } + + // Add more specific recommendations based on Core Web Vitals + if ( + audits["largest-contentful-paint"] && + audits["largest-contentful-paint"].score !== null && + audits["largest-contentful-paint"].score < 0.5 + ) { + prioritized_recommendations.push("Improve Largest Contentful Paint (LCP)"); + } + + if ( + audits["cumulative-layout-shift"] && + audits["cumulative-layout-shift"].score !== null && + audits["cumulative-layout-shift"].score < 0.5 + ) { + prioritized_recommendations.push("Reduce layout shifts (CLS)"); + } + + if ( + audits["total-blocking-time"] && + audits["total-blocking-time"].score !== null && + audits["total-blocking-time"].score < 0.5 + ) { + prioritized_recommendations.push("Reduce JavaScript execution time"); + } + + // Add to the return object return { metadata, - overallScore, - failedAuditsCount: failedAudits.length, - passedAuditsCount: passedAudits.length, - manualAuditsCount: manualAudits.length, - informativeAuditsCount: informativeAudits.length, - notApplicableAuditsCount: notApplicableAudits.length, - failedAudits, + score, + audit_counts, + metrics, + opportunities, + page_stats, + prioritized_recommendations: + prioritized_recommendations.length > 0 + ? prioritized_recommendations + : undefined, }; };