Refactor Lighthouse audit utilities and improve error handling

This commit is contained in:
Emil Neander 2025-02-26 18:33:39 +01:00
parent 37269d1e2d
commit 0d995a3219
4 changed files with 219 additions and 182 deletions

View File

@ -2,8 +2,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import path from "path";
import { z } from "zod";
// import { z } from "zod"; // import { z } from "zod";
// import fs from "fs"; // import fs from "fs";
@ -13,14 +11,14 @@ const server = new McpServer({
version: "1.0.9", version: "1.0.9",
}); });
// Define audit categories as constants to avoid importing from the types file // Define audit categories as enum to match the server's AuditCategory enum
const AUDIT_CATEGORIES = { enum AuditCategory {
ACCESSIBILITY: "accessibility", ACCESSIBILITY = "accessibility",
PERFORMANCE: "performance", PERFORMANCE = "performance",
SEO: "seo", SEO = "seo",
BEST_PRACTICES: "best-practices", BEST_PRACTICES = "best-practices",
PWA: "pwa", PWA = "pwa",
}; }
// Function to get the port from the .port file // Function to get the port from the .port file
// function getPort(): number { // function getPort(): number {
@ -197,7 +195,7 @@ server.tool("wipeLogs", "Wipe all browser logs from memory", async () => {
}; };
}); });
// Add tool for accessibility audits // Add tool for accessibility audits, launches a headless browser instance
server.tool( server.tool(
"runAccessibilityAudit", "runAccessibilityAudit",
"Run a WCAG-compliant accessibility audit on the current page", "Run a WCAG-compliant accessibility audit on the current page",
@ -210,7 +208,7 @@ server.tool(
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
category: AUDIT_CATEGORIES.ACCESSIBILITY, category: AuditCategory.ACCESSIBILITY,
}), }),
} }
); );
@ -248,7 +246,7 @@ server.tool(
} }
); );
// Add tool for performance audits // Add tool for performance audits, launches a headless browser instance
server.tool( server.tool(
"runPerformanceAudit", "runPerformanceAudit",
"Run a performance audit on the current page", "Run a performance audit on the current page",
@ -262,7 +260,7 @@ server.tool(
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
category: AUDIT_CATEGORIES.PERFORMANCE, category: AuditCategory.PERFORMANCE,
}), }),
} }
); );

View File

@ -3,11 +3,15 @@ import {
AuditResult, AuditResult,
AuditIssue, AuditIssue,
LighthouseDetails, LighthouseDetails,
ElementDetails,
ImpactLevel, ImpactLevel,
AuditCategory, AuditCategory,
} from "./types.js"; } from "./types.js";
import { runLighthouseOnExistingTab } from "./index.js"; import {
runLighthouseOnExistingTab,
mapAuditItemsToElements,
createAuditIssue,
createAuditMetadata,
} from "./index.js";
/** /**
* Extracts simplified accessibility issues from Lighthouse results * Extracts simplified accessibility issues from Lighthouse results
@ -44,56 +48,30 @@ export function extractAccessibilityIssues(
failedAudits.forEach(({ ref, audit }) => { failedAudits.forEach(({ ref, audit }) => {
const details = audit.details as LighthouseDetails; const details = audit.details as LighthouseDetails;
// Extract actionable elements that need fixing // Use the shared helper function to extract elements
const elements = (details?.items || []).map( const elements = mapAuditItemsToElements(
(item: Record<string, unknown>) => details?.items || [],
({ detailed
selector:
((item.node as Record<string, unknown>)?.selector as string) ||
(item.selector as string) ||
"Unknown selector",
snippet:
((item.node as Record<string, unknown>)?.snippet as string) ||
(item.snippet as string) ||
"No snippet available",
explanation:
((item.node as Record<string, unknown>)
?.explanation as string) ||
(item.explanation as string) ||
"No explanation available",
url:
(item.url as string) ||
((item.node as Record<string, unknown>)?.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)
); );
if (elements.length > 0 || (audit.score || 0) < 1) { if (elements.length > 0 || (audit.score || 0) < 1) {
const issue: AuditIssue = { // Use the shared helper function to create an audit issue
id: audit.id, const impact =
title: audit.title, ((details?.items?.[0] as Record<string, unknown>)
description: audit.description, ?.impact as string) || ImpactLevel.MODERATE;
score: audit.score || 0, const issue = createAuditIssue(
details: detailed ? details : { type: details.type }, audit,
category: categoryName, ref,
wcagReference: ref.relevantAudits || [], details,
impact: elements,
((details?.items?.[0] as Record<string, unknown>) categoryName,
?.impact as string) || ImpactLevel.MODERATE, impact
elements: detailed ? elements : elements.slice(0, 3), );
failureSummary:
((details?.items?.[0] as Record<string, unknown>) // Add detailed details if requested
?.failureSummary as string) || if (detailed) {
audit.explanation || issue.details = details;
"No failure summary available", }
recommendations: [],
};
allIssues.push(issue); allIssues.push(issue);
} }
@ -124,19 +102,7 @@ export function extractAccessibilityIssues(
categoryScores, categoryScores,
issues: limitedIssues, issues: limitedIssues,
...(detailed && { ...(detailed && {
auditMetadata: { auditMetadata: createAuditMetadata(lhr),
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,
},
}), }),
}; };
} }

View File

@ -4,7 +4,13 @@ import {
connectToHeadlessBrowser, connectToHeadlessBrowser,
scheduleBrowserCleanup, scheduleBrowserCleanup,
} from "../browser-utils.js"; } from "../browser-utils.js";
import { LighthouseConfig, AuditCategory } from "./types.js"; import {
LighthouseConfig,
AuditCategory,
AuditIssue,
LighthouseDetails,
ImpactLevel,
} from "./types.js";
// ===== Type Definitions ===== // ===== Type Definitions =====
@ -67,9 +73,11 @@ export async function runLighthouseOnExistingTab(
): Promise<LighthouseResult> { ): Promise<LighthouseResult> {
console.log(`Starting Lighthouse ${categories.join(", ")} audit for: ${url}`); console.log(`Starting Lighthouse ${categories.join(", ")} audit for: ${url}`);
if (!url) { if (!url || url === "about:blank") {
console.error("URL is required for Lighthouse audit"); console.error("Invalid URL for Lighthouse audit");
throw new Error("URL is required for Lighthouse audit"); throw new Error(
"Cannot run audit on an empty page or about:blank. Please navigate to a valid URL first."
);
} }
try { try {
@ -81,32 +89,53 @@ export async function runLighthouseOnExistingTab(
// For performance audits, we want to load all resources // For performance audits, we want to load all resources
// For accessibility or other audits, we can block non-essential resources // For accessibility or other audits, we can block non-essential resources
const { port } = await connectToHeadlessBrowser(url, { try {
blockResources: !isPerformanceAudit, const { port } = await connectToHeadlessBrowser(url, {
// Don't pass an audit type - the blockResources flag is what matters blockResources: !isPerformanceAudit,
}); // Don't pass an audit type - the blockResources flag is what matters
});
console.log(`Connected to browser on port: ${port}`); console.log(`Connected to browser on port: ${port}`);
// Create Lighthouse config // Create Lighthouse config
const { flags, config } = createLighthouseConfig(categories); const { flags, config } = createLighthouseConfig(categories);
flags.port = port; flags.port = port;
console.log(`Running Lighthouse with categories: ${categories.join(", ")}`); console.log(
const runnerResult = await lighthouse(url, flags as Flags, config); `Running Lighthouse with categories: ${categories.join(", ")}`
console.log("Lighthouse audit completed"); );
const runnerResult = await lighthouse(url, flags as Flags, config);
console.log("Lighthouse audit completed");
if (!runnerResult?.lhr) { if (!runnerResult?.lhr) {
console.error("Lighthouse audit failed to produce results"); console.error("Lighthouse audit failed to produce results");
throw new Error("Lighthouse audit failed to produce results"); throw new Error("Lighthouse audit failed to produce results");
}
// Schedule browser cleanup after a delay to allow for subsequent audits
scheduleBrowserCleanup();
// Return the result
const result = runnerResult.lhr;
return result;
} catch (browserError) {
// Check if the error is related to Chrome/Edge not being available
const errorMessage =
browserError instanceof Error
? browserError.message
: String(browserError);
if (
errorMessage.includes("Chrome could not be found") ||
errorMessage.includes("Failed to launch browser") ||
errorMessage.includes("spawn ENOENT")
) {
throw new Error(
"Chrome or Edge browser could not be found. Please ensure that Chrome or Edge is installed on your system to run audits."
);
}
// Re-throw other errors
throw browserError;
} }
// Schedule browser cleanup after a delay to allow for subsequent audits
scheduleBrowserCleanup();
// Return the result
const result = runnerResult.lhr;
return result;
} catch (error) { } catch (error) {
console.error("Lighthouse audit failed:", error); console.error("Lighthouse audit failed:", error);
// Schedule browser cleanup even if the audit fails // Schedule browser cleanup even if the audit fails
@ -123,3 +152,102 @@ export async function runLighthouseOnExistingTab(
export * from "./accessibility.js"; export * from "./accessibility.js";
export * from "./performance.js"; export * from "./performance.js";
export * from "./types.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<string, unknown>[] = [],
detailed: boolean = false
): ElementDetails[] {
const elements = items.map(
(item: Record<string, unknown>) =>
({
selector:
((item.node as Record<string, unknown>)?.selector as string) ||
(item.selector as string) ||
"Unknown selector",
snippet:
((item.node as Record<string, unknown>)?.snippet as string) ||
(item.snippet as string) ||
"No snippet available",
explanation:
((item.node as Record<string, unknown>)?.explanation as string) ||
(item.explanation as string) ||
"No explanation available",
url:
(item.url as string) ||
((item.node as Record<string, unknown>)?.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<string, unknown>)?.impact as string) ||
ImpactLevel.MODERATE,
elements: elements,
failureSummary:
((details?.items?.[0] as Record<string, unknown>)
?.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,
};
}

View File

@ -7,7 +7,12 @@ import {
ImpactLevel, ImpactLevel,
AuditCategory, AuditCategory,
} from "./types.js"; } from "./types.js";
import { runLighthouseOnExistingTab } from "./index.js"; import {
runLighthouseOnExistingTab,
mapAuditItemsToElements,
createAuditIssue,
createAuditMetadata,
} from "./index.js";
/** /**
* Extracts performance issues from Lighthouse results * Extracts performance issues from Lighthouse results
@ -21,8 +26,6 @@ export function extractPerformanceIssues(
limit: number = 5, limit: number = 5,
detailed: boolean = false detailed: boolean = false
): Partial<AuditResult> { ): Partial<AuditResult> {
console.log("Processing performance audit results");
const allIssues: AuditIssue[] = []; const allIssues: AuditIssue[] = [];
const categoryScores: { [key: string]: number } = {}; const categoryScores: { [key: string]: number } = {};
@ -67,8 +70,6 @@ export function extractPerformanceIssues(
((item.audit.details as LighthouseDetails)?.items?.length || 0) > 0) ((item.audit.details as LighthouseDetails)?.items?.length || 0) > 0)
); );
console.log(`Found ${failedAudits.length} performance issues`);
if (failedAudits.length > 0) { if (failedAudits.length > 0) {
failedAudits.forEach(({ ref, audit }) => { failedAudits.forEach(({ ref, audit }) => {
try { try {
@ -80,70 +81,28 @@ export function extractPerformanceIssues(
return; return;
} }
// Extract actionable elements that need fixing // Use the shared helper function to extract elements
const elements = (details.items || []).map( const elements = mapAuditItemsToElements(
(item: Record<string, unknown>) => { details.items || [],
try { detailed
return {
selector:
((item.node as Record<string, unknown>)
?.selector as string) ||
(item.selector as string) ||
"Unknown selector",
snippet:
((item.node as Record<string, unknown>)
?.snippet as string) ||
(item.snippet as string) ||
"No snippet available",
explanation:
((item.node as Record<string, unknown>)
?.explanation as string) ||
(item.explanation as string) ||
"No explanation available",
url:
(item.url as string) ||
((item.node as Record<string, unknown>)?.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;
} catch (error) {
console.error(`Error processing element: ${error}`);
return {
selector: "Error processing element",
snippet: "Error",
explanation: "Error processing element details",
url: "",
size: 0,
wastedMs: 0,
wastedBytes: 0,
} as ElementDetails;
}
}
); );
if (elements.length > 0 || (audit.score || 0) < 0.9) { if (elements.length > 0 || (audit.score || 0) < 0.9) {
const issue: AuditIssue = { // Use the shared helper function to create an audit issue
id: audit.id, const impact = getPerformanceImpact(audit.score || 0);
title: audit.title, const issue = createAuditIssue(
description: audit.description, audit,
score: audit.score || 0, ref,
details: detailed ? details : { type: details.type || "unknown" }, details,
category: categoryName, elements,
wcagReference: ref.relevantAudits || [], categoryName,
impact: getPerformanceImpact(audit.score || 0), impact
elements: detailed ? elements : elements.slice(0, 3), );
failureSummary:
((details?.items?.[0] as Record<string, unknown>) // Add detailed details if requested
?.failureSummary as string) || if (detailed) {
audit.explanation || issue.details = details;
"No failure summary available", }
recommendations: [],
};
allIssues.push(issue); allIssues.push(issue);
} }
@ -160,26 +119,12 @@ export function extractPerformanceIssues(
// Return only the specified number of issues // Return only the specified number of issues
const limitedIssues = allIssues.slice(0, limit); const limitedIssues = allIssues.slice(0, limit);
console.log(`Returning ${limitedIssues.length} performance issues`);
return { return {
score: categoryScores.performance || 0, score: categoryScores.performance || 0,
categoryScores, categoryScores,
issues: limitedIssues, issues: limitedIssues,
...(detailed && { ...(detailed && {
auditMetadata: { auditMetadata: createAuditMetadata(lhr),
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,
},
}), }),
}; };
} }