diff --git a/browser-tools-server/lighthouse/accessibility.ts b/browser-tools-server/lighthouse/accessibility.ts index 2bf7288..1ac9252 100644 --- a/browser-tools-server/lighthouse/accessibility.ts +++ b/browser-tools-server/lighthouse/accessibility.ts @@ -1,132 +1,50 @@ -import type { Result as LighthouseResult } from "lighthouse"; -import { - AuditResult, - AuditIssue, - LighthouseDetails, - ImpactLevel, - AuditCategory, -} from "./types.js"; -import { - runLighthouseOnExistingTab, - mapAuditItemsToElements, - createAuditIssue, - createAuditMetadata, -} from "./index.js"; +import { Result as LighthouseResult } from "lighthouse"; +import { AuditCategory, LighthouseReport } from "./types.js"; +import { runLighthouseAudit } from "./index.js"; -/** - * Extracts simplified accessibility issues from Lighthouse results - * @param lhr The Lighthouse result object - * @param limit Maximum number of issues to return - * @param detailed Whether to include detailed information about each issue - * @returns Processed audit result with categorized issues - */ -export function extractAccessibilityIssues( - lhr: LighthouseResult, - limit: number = 5, - detailed: boolean = false -): Partial { - const allIssues: AuditIssue[] = []; - const categoryScores: { [key: string]: number } = {}; - - // Process each category - Object.entries(lhr.categories).forEach(([categoryName, category]) => { - const score = (category.score || 0) * 100; - categoryScores[categoryName] = score; - - // Only process audits that actually failed or have warnings - const failedAudits = (category.auditRefs || []) - .map((ref) => ({ ref, audit: lhr.audits[ref.id] })) - .filter( - ({ audit }) => - // Include if score is less than 100% or has actual items to fix - audit?.score !== null && - (audit.score < 1 || - ((audit.details as LighthouseDetails)?.items?.length || 0) > 0) - ); - - if (failedAudits.length > 0) { - failedAudits.forEach(({ ref, audit }) => { - const details = audit.details as LighthouseDetails; - - // Use the shared helper function to extract elements - const elements = mapAuditItemsToElements( - details?.items || [], - detailed - ); - - if (elements.length > 0 || (audit.score || 0) < 1) { - // Use the shared helper function to create an audit issue - const impact = - ((details?.items?.[0] as Record) - ?.impact as string) || ImpactLevel.MODERATE; - const issue = createAuditIssue( - audit, - ref, - details, - elements, - categoryName, - impact - ); - - // Add detailed details if requested - if (detailed) { - issue.details = details; - } - - allIssues.push(issue); - } - }); - } - }); - - // Sort issues by impact and score - allIssues.sort((a, b) => { - const impactOrder = { - [ImpactLevel.CRITICAL]: 0, - [ImpactLevel.SERIOUS]: 1, - [ImpactLevel.MODERATE]: 2, - [ImpactLevel.MINOR]: 3, - }; - const aImpact = impactOrder[a.impact as keyof typeof impactOrder] || 4; - const bImpact = impactOrder[b.impact as keyof typeof impactOrder] || 4; - - if (aImpact !== bImpact) return aImpact - bImpact; - return a.score - b.score; - }); - - // Return only the specified number of issues - const limitedIssues = allIssues.slice(0, limit); - - return { - score: categoryScores.accessibility || 0, - categoryScores, - issues: limitedIssues, - ...(detailed && { - auditMetadata: createAuditMetadata(lhr), - }), - }; +interface AccessibilityAudit { + id: string; // e.g., "color-contrast" + title: string; // e.g., "Color contrast is sufficient" + description: string; // e.g., "Ensures text is readable..." + score: number | null; // 0-1 (normalized), null for manual/informative + scoreDisplayMode: string; // e.g., "binary", "numeric", "manual" + details?: AuditDetails; // Optional, structured details + weight?: number; // Optional, audit weight for impact calculation } +type AuditDetails = { + items?: Array<{ + node?: { + selector: string; // e.g., ".my-class" + snippet?: string; // HTML snippet + nodeLabel?: string; // e.g., "Modify logging size limits / truncation" + explanation?: string; // Explanation of why the node fails the audit + }; + value?: string | number; // Specific value (e.g., contrast ratio) + explanation?: string; // Explanation at the item level + }>; + debugData?: string; // Optional, debug information + [key: string]: any; // Flexible for other detail types (tables, etc.) +}; + +const FAILED_AUDITS_LIMIT = 5; + +// Define a maximum number of items to include in the audit details +const MAX_ITEMS_IN_DETAILS = 3; + /** * Runs an accessibility audit on the specified URL * @param url The URL to audit * @param limit Maximum number of issues to return - * @param detailed Whether to include detailed information about each issue - * @returns Promise resolving to the processed accessibility audit results + * @returns Promise resolving to simplified accessibility audit results */ export async function runAccessibilityAudit( - url: string, - limit: number = 5, - detailed: boolean = false -): Promise> { + url: string +): Promise { try { - // Run Lighthouse audit with accessibility category - const lhr = await runLighthouseOnExistingTab(url, [ - AuditCategory.ACCESSIBILITY, - ]); - - // Extract and process accessibility issues - return extractAccessibilityIssues(lhr, limit, detailed); + const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]); + const accessibilityReport = extractLhrResult(lhr, url); + return accessibilityReport; } catch (error) { throw new Error( `Accessibility audit failed: ${ @@ -135,3 +53,148 @@ export async function runAccessibilityAudit( ); } } + +const extractLhrResult = ( + lhr: LighthouseResult, + url: string +): LighthouseReport => { + const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY]; + console.log(categoryData); + console.log(lhr); + + 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 + lighthouseVersion: lhr.lighthouseVersion, + }; + + if (!categoryData) + return { + metadata, + failedAudits: [], + overallScore: 0, + failedAuditsCount: 0, + passedAuditsCount: 0, + manualAuditsCount: 0, + informativeAuditsCount: 0, + notApplicableAuditsCount: 0, + }; + + const overallScore = Math.round((categoryData.score || 0) * 100); + const auditRefs = categoryData.auditRefs || []; + const audits = lhr.audits || {}; + + const accessibilityAudits: AccessibilityAudit[] = auditRefs.map((ref) => { + const audit = audits[ref.id]; + + // Create a simplified version of the audit details + let simplifiedDetails: AuditDetails | undefined; + + if (audit.details) { + simplifiedDetails = {}; + + // Only copy the items array if it exists + if ( + (audit.details as any).items && + Array.isArray((audit.details as any).items) + ) { + // Limit the number of items to MAX_ITEMS_IN_DETAILS + const limitedItems = (audit.details as any).items.slice( + 0, + MAX_ITEMS_IN_DETAILS + ); + + simplifiedDetails.items = limitedItems.map((item: any) => { + const simplifiedItem: any = {}; + + // Only include node with selector if they exist + if (item.node) { + // Include the node with all its properties + simplifiedItem.node = { + selector: item.node.selector || null, + nodeLabel: item.node.nodeLabel || null, + snippet: item.node.snippet || null, + }; + // Include explanation if it exists + if (item.node.explanation) { + simplifiedItem.node.explanation = item.node.explanation; + } + } + + // Include value if it exists + if (item.value !== undefined) { + simplifiedItem.value = item.value; + } + + // Include explanation at the item level if it exists + if (item.explanation) { + simplifiedItem.explanation = item.explanation; + } + + return simplifiedItem; + }); + } + + // Copy any other essential properties that might be needed + if ((audit.details as any).type) { + simplifiedDetails.type = (audit.details as any).type; + } + + // Include debugData if it exists + if ((audit.details as any).debugData) { + simplifiedDetails.debugData = (audit.details as any).debugData; + } + } + + return { + id: ref.id, + title: audit.title || "Untitled", + description: audit.description || "No description", + score: audit.score, // Individual audit score (0-1 or null) + scoreDisplayMode: audit.scoreDisplayMode || "numeric", + details: simplifiedDetails, + weight: ref.weight || 1, + }; + }); + + const failedAudits = accessibilityAudits + .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 passedAudits = accessibilityAudits.filter( + (audit) => audit.score !== null && audit.score >= 1 + ); + const manualAudits = accessibilityAudits.filter( + (audit) => audit.scoreDisplayMode === "manual" + ); + const informativeAudits = accessibilityAudits.filter( + (audit) => audit.scoreDisplayMode === "informative" + ); + const notApplicableAudits = accessibilityAudits.filter( + (audit) => audit.scoreDisplayMode === "notApplicable" + ); + + const result = { + metadata, + overallScore, + failedAuditsCount: failedAudits.length, + passedAuditsCount: passedAudits.length, + manualAuditsCount: manualAudits.length, + informativeAuditsCount: informativeAudits.length, + notApplicableAuditsCount: notApplicableAudits.length, + failedAudits, + } as LighthouseReport; + + console.log(result); + failedAudits.forEach((audit) => { + console.log(JSON.stringify(audit.details, null, 2)); + }); + return result; +}; diff --git a/browser-tools-server/lighthouse/index.ts b/browser-tools-server/lighthouse/index.ts index dbe02b0..aebb465 100644 --- a/browser-tools-server/lighthouse/index.ts +++ b/browser-tools-server/lighthouse/index.ts @@ -4,28 +4,7 @@ import { connectToHeadlessBrowser, scheduleBrowserCleanup, } from "../browser-utils.js"; -import { - LighthouseConfig, - AuditCategory, - AuditIssue, - LighthouseDetails, - ImpactLevel, -} from "./types.js"; - -// ===== Type Definitions ===== - -/** - * Details about an HTML element that has accessibility issues - */ -export interface ElementDetails { - selector: string; - snippet: string; - explanation: string; - url: string; - size: number; - wastedMs: number; - wastedBytes: number; -} +import { LighthouseConfig, AuditCategory } from "./types.js"; /** * Creates a Lighthouse configuration object @@ -67,7 +46,7 @@ export function createLighthouseConfig( * @returns Promise resolving to the Lighthouse result * @throws Error if the URL is invalid or if the audit fails */ -export async function runLighthouseOnExistingTab( +export async function runLighthouseAudit( url: string, categories: string[] = [AuditCategory.ACCESSIBILITY] ): Promise { @@ -92,7 +71,6 @@ export async function runLighthouseOnExistingTab( try { const { port } = await connectToHeadlessBrowser(url, { blockResources: !isPerformanceAudit, - // Don't pass an audit type - the blockResources flag is what matters }); console.log(`Connected to browser on port: ${port}`); @@ -153,102 +131,3 @@ export * from "./accessibility.js"; export * from "./performance.js"; export * from "./seo.js"; export * from "./types.js"; - -/** - * Maps Lighthouse audit items to ElementDetails objects - * @param items Array of audit items from Lighthouse - * @param detailed Whether to include all items or limit them - * @returns Array of ElementDetails objects - */ -export function mapAuditItemsToElements( - items: Record[] = [], - detailed: boolean = false -): ElementDetails[] { - const elements = items.map( - (item: Record) => - ({ - selector: - ((item.node as Record)?.selector as string) || - (item.selector as string) || - "Unknown selector", - snippet: - ((item.node as Record)?.snippet as string) || - (item.snippet as string) || - "No snippet available", - explanation: - ((item.node as Record)?.explanation as string) || - (item.explanation as string) || - "No explanation available", - url: - (item.url as string) || - ((item.node as Record)?.url as string) || - "", - size: (item.totalBytes as number) || (item.transferSize as number) || 0, - wastedMs: (item.wastedMs as number) || 0, - wastedBytes: (item.wastedBytes as number) || 0, - } as ElementDetails) - ); - - return detailed ? elements : elements.slice(0, 3); -} - -/** - * Creates an AuditIssue object from Lighthouse audit data - * @param audit The Lighthouse audit object - * @param ref The audit reference object - * @param details The audit details object - * @param elements Array of ElementDetails objects - * @param categoryName The category name - * @param impact The impact level (optional) - * @returns An AuditIssue object - */ -export function createAuditIssue( - audit: any, - ref: any, - details: LighthouseDetails, - elements: ElementDetails[], - categoryName: string, - impact?: string -): AuditIssue { - return { - id: audit.id, - title: audit.title, - description: audit.description, - score: audit.score || 0, - details: { type: details?.type || "unknown" }, - category: categoryName, - wcagReference: ref.relevantAudits || [], - impact: - impact || - ((details?.items?.[0] as Record)?.impact as string) || - ImpactLevel.MODERATE, - elements: elements, - failureSummary: - ((details?.items?.[0] as Record) - ?.failureSummary as string) || - audit.explanation || - "No failure summary available", - recommendations: [], - }; -} - -/** - * Creates audit metadata from Lighthouse results - * @param lhr The Lighthouse result object - * @returns Audit metadata object - */ -export function createAuditMetadata(lhr: LighthouseResult): any { - return { - fetchTime: lhr.fetchTime || new Date().toISOString(), - url: lhr.finalUrl || "Unknown URL", - deviceEmulation: "desktop", - categories: Object.keys(lhr.categories), - totalAudits: Object.keys(lhr.audits || {}).length, - passedAudits: Object.values(lhr.audits || {}).filter( - (audit) => audit.score === 1 - ).length, - failedAudits: Object.values(lhr.audits || {}).filter( - (audit) => audit.score !== null && audit.score < 1 - ).length, - }; -} diff --git a/browser-tools-server/lighthouse/performance.ts b/browser-tools-server/lighthouse/performance.ts index ba3bab0..c4adbd9 100644 --- a/browser-tools-server/lighthouse/performance.ts +++ b/browser-tools-server/lighthouse/performance.ts @@ -1,168 +1,38 @@ -import type { Result as LighthouseResult } from "lighthouse"; -import { - AuditResult, - AuditIssue, - LighthouseDetails, - ElementDetails, - ImpactLevel, - AuditCategory, -} from "./types.js"; -import { - runLighthouseOnExistingTab, - mapAuditItemsToElements, - createAuditIssue, - createAuditMetadata, -} from "./index.js"; +import { Result as LighthouseResult } from "lighthouse"; +import { AuditCategory, LighthouseReport } from "./types.js"; +import { runLighthouseAudit } from "./index.js"; -/** - * Extracts performance issues from Lighthouse results - * @param lhr The Lighthouse result object - * @param limit Maximum number of issues to return - * @param detailed Whether to include detailed information about each issue - * @returns Processed audit result with performance issues - */ -export function extractPerformanceIssues( - lhr: LighthouseResult, - limit: number = 5, - detailed: boolean = false -): Partial { - const allIssues: AuditIssue[] = []; - const categoryScores: { [key: string]: number } = {}; - - // Check if lhr and categories exist - if (!lhr || !lhr.categories) { - console.error("Invalid Lighthouse result: missing categories"); - return { - score: 0, - categoryScores: {}, - issues: [], - }; - } - - // Process performance category - Object.entries(lhr.categories).forEach(([categoryName, category]) => { - if (categoryName !== AuditCategory.PERFORMANCE) return; - - const score = (category.score || 0) * 100; - categoryScores[categoryName] = score; - - // Check if auditRefs exists - if (!category.auditRefs) { - console.error(`No auditRefs found for category: ${categoryName}`); - return; - } - - // Only process audits that actually failed or have warnings - const failedAudits = category.auditRefs - .map((ref) => { - const audit = lhr.audits?.[ref.id]; - if (!audit) { - console.error(`Audit not found for ref.id: ${ref.id}`); - return null; - } - return { ref, audit }; - }) - .filter( - (item): item is { ref: any; audit: any } => - item !== null && - item.audit?.score !== null && - (item.audit.score < 0.9 || - ((item.audit.details as LighthouseDetails)?.items?.length || 0) > 0) - ); - - if (failedAudits.length > 0) { - failedAudits.forEach(({ ref, audit }) => { - try { - const details = audit.details as LighthouseDetails; - - // Check if details exists - if (!details) { - console.error(`No details found for audit: ${audit.id}`); - return; - } - - // Use the shared helper function to extract elements - const elements = mapAuditItemsToElements( - details.items || [], - detailed - ); - - if (elements.length > 0 || (audit.score || 0) < 0.9) { - // Use the shared helper function to create an audit issue - const impact = getPerformanceImpact(audit.score || 0); - const issue = createAuditIssue( - audit, - ref, - details, - elements, - categoryName, - impact - ); - - // Add detailed details if requested - if (detailed) { - issue.details = details; - } - - allIssues.push(issue); - } - } catch (error) { - console.error(`Error processing audit ${audit.id}: ${error}`); - } - }); - } - }); - - // Sort issues by score (lowest first) - allIssues.sort((a, b) => a.score - b.score); - - // Return only the specified number of issues - const limitedIssues = allIssues.slice(0, limit); - - return { - score: categoryScores.performance || 0, - categoryScores, - issues: limitedIssues, - ...(detailed && { - auditMetadata: createAuditMetadata(lhr), - }), - }; +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 } -/** - * Determines the impact level based on the performance score - * @param score The performance score (0-1) - * @returns Impact level string - */ -function getPerformanceImpact(score: number): string { - if (score < 0.5) return ImpactLevel.CRITICAL; - if (score < 0.7) return ImpactLevel.SERIOUS; - if (score < 0.9) return ImpactLevel.MODERATE; - return ImpactLevel.MINOR; +interface PerformanceAuditDetails { + items?: Array<{ + resourceUrl?: string; // e.g., "https://example.com/script.js" (for render-blocking resources) + wastedMs?: number; // e.g., 150 (potential savings) + elementSelector?: string; // e.g., "img.hero" (for LCP element) + timing?: number; // e.g., 2.5 (specific timing value) + }>; + type?: string; // e.g., "opportunity" or "table" } -/** - * Runs a performance audit on the specified URL - * @param url The URL to audit - * @param limit Maximum number of issues to return - * @param detailed Whether to include detailed information about each issue - * @returns Promise resolving to the processed performance audit results - */ +const FAILED_AUDITS_LIMIT = 5; +const MAX_ITEMS_IN_DETAILS = 3; + export async function runPerformanceAudit( - url: string, - limit: number = 5, - detailed: boolean = false -): Promise> { + url: string +): Promise { try { - // Run Lighthouse audit with performance category - const lhr = await runLighthouseOnExistingTab(url, [ - AuditCategory.PERFORMANCE, - ]); - - // Extract and process performance issues - const result = extractPerformanceIssues(lhr, limit, detailed); - - return result; + const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]); + return extractPerformanceResult(lhr, url); } catch (error) { throw new Error( `Performance audit failed: ${ @@ -171,3 +41,107 @@ export async function runPerformanceAudit( ); } } + +const extractPerformanceResult = ( + lhr: LighthouseResult, + url: string +): LighthouseReport => { + const categoryData = lhr.categories[AuditCategory.PERFORMANCE]; + 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 + lighthouseVersion: lhr.lighthouseVersion, + }; + + if (!categoryData) { + return { + metadata, + failedAudits: [], + overallScore: 0, + failedAuditsCount: 0, + passedAuditsCount: 0, + manualAuditsCount: 0, + informativeAuditsCount: 0, + notApplicableAuditsCount: 0, + }; + } + + const overallScore = Math.round((categoryData.score || 0) * 100); + const auditRefs = categoryData.auditRefs || []; + const audits = lhr.audits || {}; + + const performanceAudits: PerformanceAudit[] = auditRefs.map((ref) => { + const audit = audits[ref.id]; + let simplifiedDetails: PerformanceAuditDetails | undefined; + + 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.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 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" + ); + + return { + metadata, + overallScore, + failedAuditsCount: failedAudits.length, + passedAuditsCount: passedAudits.length, + manualAuditsCount: manualAudits.length, + informativeAuditsCount: informativeAudits.length, + notApplicableAuditsCount: notApplicableAudits.length, + failedAudits, + }; +}; diff --git a/browser-tools-server/lighthouse/seo.ts b/browser-tools-server/lighthouse/seo.ts index ae6830e..85dc0e1 100644 --- a/browser-tools-server/lighthouse/seo.ts +++ b/browser-tools-server/lighthouse/seo.ts @@ -1,177 +1,33 @@ -import type { Result as LighthouseResult } from "lighthouse"; -import { - AuditResult, - AuditIssue, - LighthouseDetails, - ImpactLevel, - AuditCategory, -} from "./types.js"; -import { - runLighthouseOnExistingTab, - mapAuditItemsToElements, - createAuditIssue, - createAuditMetadata, -} from "./index.js"; +import { Result as LighthouseResult } from "lighthouse"; +import { AuditCategory, LighthouseReport } from "./types.js"; +import { runLighthouseAudit } from "./index.js"; -/** - * Extracts SEO issues from Lighthouse results - * @param lhr The Lighthouse result object - * @param limit Maximum number of issues to return - * @param detailed Whether to include detailed information about each issue - * @returns Processed audit result with SEO issues - */ -export function extractSEOIssues( - lhr: LighthouseResult, - limit: number = 5, - detailed: boolean = false -): Partial { - const allIssues: AuditIssue[] = []; - const categoryScores: { [key: string]: number } = {}; - - // Check if lhr and categories exist - if (!lhr || !lhr.categories) { - console.error("Invalid Lighthouse result: missing categories"); - return { - score: 0, - categoryScores: {}, - issues: [], - }; - } - - // Process SEO category - Object.entries(lhr.categories).forEach(([categoryName, category]) => { - if (categoryName !== AuditCategory.SEO) return; - - const score = (category.score || 0) * 100; - categoryScores[categoryName] = score; - - // Check if auditRefs exists - if (!category.auditRefs) { - console.error(`No auditRefs found for category: ${categoryName}`); - return; - } - - // Only process audits that actually failed or have warnings - const failedAudits = category.auditRefs - .map((ref) => { - const audit = lhr.audits?.[ref.id]; - if (!audit) { - console.error(`Audit not found for ref.id: ${ref.id}`); - return null; - } - return { ref, audit }; - }) - .filter( - (item): item is { ref: any; audit: any } => - item !== null && - item.audit?.score !== null && - (item.audit.score < 1 || - ((item.audit.details as LighthouseDetails)?.items?.length || 0) > 0) - ); - - if (failedAudits.length > 0) { - failedAudits.forEach(({ ref, audit }) => { - try { - const details = audit.details as LighthouseDetails; - - // Check if details exists - if (!details) { - console.error(`No details found for audit: ${audit.id}`); - return; - } - - // Use the shared helper function to extract elements - const elements = mapAuditItemsToElements( - details.items || [], - detailed - ); - - if (elements.length > 0 || (audit.score || 0) < 1) { - // Use the shared helper function to create an audit issue - const impact = getSEOImpact(audit.score || 0); - const issue = createAuditIssue( - audit, - ref, - details, - elements, - categoryName, - impact - ); - - // Add detailed details if requested - if (detailed) { - issue.details = details; - } - - allIssues.push(issue); - } - } catch (error) { - console.error(`Error processing audit ${audit.id}: ${error}`); - } - }); - } - }); - - // Sort issues by impact and score - allIssues.sort((a, b) => { - const impactOrder = { - [ImpactLevel.CRITICAL]: 0, - [ImpactLevel.SERIOUS]: 1, - [ImpactLevel.MODERATE]: 2, - [ImpactLevel.MINOR]: 3, - }; - const aImpact = impactOrder[a.impact as keyof typeof impactOrder] || 4; - const bImpact = impactOrder[b.impact as keyof typeof impactOrder] || 4; - - if (aImpact !== bImpact) return aImpact - bImpact; - return a.score - b.score; - }); - - // Return only the specified number of issues - const limitedIssues = allIssues.slice(0, limit); - - return { - score: categoryScores.seo || 0, - categoryScores, - issues: limitedIssues, - ...(detailed && { - auditMetadata: createAuditMetadata(lhr), - }), - }; +interface SEOAudit { + id: string; // e.g., "meta-description" + title: string; // e.g., "Document has a meta description" + description: string; // e.g., "Meta descriptions improve SEO..." + score: number | null; // 0-1 or null + scoreDisplayMode: string; // e.g., "binary" + details?: SEOAuditDetails; // Optional, structured details + weight?: number; // For prioritization } -/** - * Determines the impact level based on the SEO score - * @param score The SEO score (0-1) - * @returns Impact level string - */ -function getSEOImpact(score: number): string { - if (score < 0.5) return ImpactLevel.CRITICAL; - if (score < 0.7) return ImpactLevel.SERIOUS; - if (score < 0.9) return ImpactLevel.MODERATE; - return ImpactLevel.MINOR; +interface SEOAuditDetails { + items?: Array<{ + selector?: string; // e.g., "meta[name='description']" + issue?: string; // e.g., "Meta description is missing" + value?: string; // e.g., Current meta description text + }>; + type?: string; // e.g., "table" } -/** - * Runs an SEO audit on the specified URL - * @param url The URL to audit - * @param limit Maximum number of issues to return - * @param detailed Whether to include detailed information about each issue - * @returns Promise resolving to the processed SEO audit results - */ -export async function runSEOAudit( - url: string, - limit: number = 5, - detailed: boolean = false -): Promise> { +const FAILED_AUDITS_LIMIT = 5; +const MAX_ITEMS_IN_DETAILS = 3; + +export async function runSEOAudit(url: string): Promise { try { - // Run Lighthouse audit with SEO category - const lhr = await runLighthouseOnExistingTab(url, [AuditCategory.SEO]); - - // Extract and process SEO issues - const result = extractSEOIssues(lhr, limit, detailed); - - return result; + const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]); + return extractSEOResult(lhr, url); } catch (error) { throw new Error( `SEO audit failed: ${ @@ -180,3 +36,104 @@ export async function runSEOAudit( ); } } + +const extractSEOResult = ( + lhr: LighthouseResult, + url: string +): LighthouseReport => { + const categoryData = lhr.categories[AuditCategory.SEO]; + 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 + lighthouseVersion: lhr.lighthouseVersion, + }; + + if (!categoryData) { + return { + metadata, + failedAudits: [], + overallScore: 0, + failedAuditsCount: 0, + passedAuditsCount: 0, + manualAuditsCount: 0, + informativeAuditsCount: 0, + notApplicableAuditsCount: 0, + }; + } + + const overallScore = Math.round((categoryData.score || 0) * 100); + const auditRefs = categoryData.auditRefs || []; + const audits = lhr.audits || {}; + + const seoAudits: SEOAudit[] = auditRefs.map((ref) => { + const audit = audits[ref.id]; + let simplifiedDetails: SEOAuditDetails | undefined; + + 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.node?.selector) simplifiedItem.selector = item.node.selector; + if (item.explanation) + simplifiedItem.issue = item.explanation.split("\n")[0]; // First line for brevity + if (item.value) simplifiedItem.value = item.value; // e.g., meta description text + return simplifiedItem; + }); + } + 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", + details: simplifiedDetails, + weight: ref.weight || 1, + }; + }); + + const failedAudits = seoAudits + .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 passedAudits = seoAudits.filter( + (audit) => audit.score !== null && audit.score >= 1 + ); + const manualAudits = seoAudits.filter( + (audit) => audit.scoreDisplayMode === "manual" + ); + const informativeAudits = seoAudits.filter( + (audit) => audit.scoreDisplayMode === "informative" + ); + const notApplicableAudits = seoAudits.filter( + (audit) => audit.scoreDisplayMode === "notApplicable" + ); + + return { + metadata, + overallScore, + failedAuditsCount: failedAudits.length, + passedAuditsCount: passedAudits.length, + manualAuditsCount: manualAudits.length, + informativeAuditsCount: informativeAudits.length, + notApplicableAuditsCount: notApplicableAudits.length, + failedAudits, + }; +}; diff --git a/browser-tools-server/lighthouse/types.ts b/browser-tools-server/lighthouse/types.ts index c4b4019..b4c6780 100644 --- a/browser-tools-server/lighthouse/types.ts +++ b/browser-tools-server/lighthouse/types.ts @@ -1,71 +1,31 @@ /** - * Types for Lighthouse audit results and related data structures + * Audit categories available in Lighthouse */ - -/** - * Details about an HTML element that has accessibility or performance issues - */ -export interface ElementDetails { - selector: string; - snippet: string; - explanation: string; - url: string; - size: number; - wastedMs: number; - wastedBytes: number; +export enum AuditCategory { + ACCESSIBILITY = "accessibility", + PERFORMANCE = "performance", + SEO = "seo", + BEST_PRACTICES = "best-practices", + PWA = "pwa", } /** - * Represents a single audit issue found during an audit + * Base interface for Lighthouse report metadata */ -export interface AuditIssue { - id: string; - title: string; - description: string; - score: number; - details: LighthouseDetails; - wcagReference: string[]; - impact: string; - elements: ElementDetails[]; - failureSummary: string; - recommendations?: string[]; - category?: string; -} - -/** - * The complete result of an audit - */ -export interface AuditResult { - score: number; - categoryScores: { [key: string]: number }; - issues: AuditIssue[]; - auditMetadata?: { - fetchTime: string; +export interface LighthouseReport { + metadata: { url: string; - deviceEmulation: string; - categories: string[]; - totalAudits: number; - passedAudits: number; - failedAudits: number; - }; -} - -/** - * Details structure from Lighthouse audit results - */ -export interface LighthouseDetails { - type: string; - headings?: Array<{ - key?: string; - itemType?: string; - text?: string; - }>; - items?: Array>; - debugData?: { - type: string; - impact?: string; - tags?: string[]; + timestamp: string; // ISO 8601, e.g., "2025-02-27T14:30:00Z" + device: string; // e.g., "mobile", "desktop" + lighthouseVersion: string; // e.g., "10.4.0" }; + overallScore: number; + failedAuditsCount: number; + passedAuditsCount: number; + manualAuditsCount: number; + informativeAuditsCount: number; + notApplicableAuditsCount: number; + failedAudits: any[]; } /** @@ -96,24 +56,3 @@ export interface LighthouseConfig { }; }; } - -/** - * Audit categories available in Lighthouse - */ -export enum AuditCategory { - ACCESSIBILITY = "accessibility", - PERFORMANCE = "performance", - SEO = "seo", - BEST_PRACTICES = "best-practices", - PWA = "pwa", -} - -/** - * Impact levels for audit issues - */ -export enum ImpactLevel { - CRITICAL = "critical", - SERIOUS = "serious", - MODERATE = "moderate", - MINOR = "minor", -}