feat: Add Best Practices audit to Lighthouse tools

This commit is contained in:
Emil Neander 2025-03-06 18:17:44 +01:00
parent 7a7bf8c137
commit 0e3614b778
3 changed files with 447 additions and 13 deletions

View File

@ -356,6 +356,7 @@ server.tool(
async () => { async () => {
return await withServerConnection(async () => { return await withServerConnection(async () => {
try { try {
// Simplified approach - let the browser connector handle the current tab and URL
console.log( console.log(
`Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit` `Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit`
); );
@ -368,21 +369,24 @@ server.tool(
Accept: "application/json", Accept: "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
category: AuditCategory.ACCESSIBILITY,
source: "mcp_tool", source: "mcp_tool",
timestamp: Date.now(), timestamp: Date.now(),
}), }),
} }
); );
// Check for errors // Log the response status
console.log(`Accessibility audit response status: ${response.status}`);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error(`Accessibility audit error: ${errorText}`);
throw new Error(`Server returned ${response.status}: ${errorText}`); throw new Error(`Server returned ${response.status}: ${errorText}`);
} }
const json = await response.json(); const json = await response.json();
// If the response is in the new format with a nested 'report',
// flatten it by merging metadata with the report contents // flatten it by merging metadata with the report contents
if (json.report) { if (json.report) {
const { metadata, report } = json; const { metadata, report } = json;
@ -466,14 +470,33 @@ server.tool(
const json = await response.json(); const json = await response.json();
return { // flatten it by merging metadata with the report contents
content: [ if (json.report) {
{ const { metadata, report } = json;
type: "text", const flattened = {
text: JSON.stringify(json, null, 2), ...metadata,
}, ...report,
], };
};
return {
content: [
{
type: "text",
text: JSON.stringify(flattened, null, 2),
},
],
};
} else {
// Return as-is if it's not in the new format
return {
content: [
{
type: "text",
text: JSON.stringify(json, null, 2),
},
],
};
}
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
@ -504,6 +527,69 @@ server.tool(
); );
const response = await fetch( const response = await fetch(
`http://${discoveredHost}:${discoveredPort}/seo-audit`, `http://${discoveredHost}:${discoveredPort}/seo-audit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
category: AuditCategory.SEO,
source: "mcp_tool",
timestamp: Date.now(),
}),
}
);
// Log the response status
console.log(`SEO audit response status: ${response.status}`);
if (!response.ok) {
const errorText = await response.text();
console.error(`SEO audit error: ${errorText}`);
throw new Error(`Server returned ${response.status}: ${errorText}`);
}
const json = await response.json();
return {
content: [
{
type: "text",
text: JSON.stringify(json, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error("Error in SEO audit:", errorMessage);
return {
content: [
{
type: "text",
text: `Failed to run SEO audit: ${errorMessage}`,
},
],
};
}
});
}
);
// Add tool for Best Practices audits, launches a headless browser instance
server.tool(
"runBestPracticesAudit",
"Run a best practices audit on the current page",
{},
async () => {
return await withServerConnection(async () => {
try {
console.log(
`Sending POST request to http://${discoveredHost}:${discoveredPort}/best-practices-audit`
);
const response = await fetch(
`http://${discoveredHost}:${discoveredPort}/best-practices-audit`,
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -525,7 +611,6 @@ server.tool(
const json = await response.json(); const json = await response.json();
// If the response is in the new format with a nested 'report',
// flatten it by merging metadata with the report contents // flatten it by merging metadata with the report contents
if (json.report) { if (json.report) {
const { metadata, report } = json; const { metadata, report } = json;
@ -556,12 +641,12 @@ server.tool(
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
console.error("Error in SEO audit:", errorMessage); console.error("Error in Best Practices audit:", errorMessage);
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Failed to run SEO audit: ${errorMessage}`, text: `Failed to run Best Practices audit: ${errorMessage}`,
}, },
], ],
}; };

View File

@ -18,6 +18,7 @@ import {
LighthouseReport, LighthouseReport,
} from "./lighthouse/index.js"; } from "./lighthouse/index.js";
import * as net from "net"; import * as net from "net";
import { runBestPracticesAudit } from "./lighthouse/best-practices.js";
/** /**
* Converts a file path to the appropriate format for the current platform * Converts a file path to the appropriate format for the current platform
@ -634,6 +635,9 @@ export class BrowserConnector {
// Set up SEO audit endpoint // Set up SEO audit endpoint
this.setupSEOAudit(); this.setupSEOAudit();
// Set up Best Practices audit endpoint
this.setupBestPracticesAudit();
// Handle upgrade requests for WebSocket // Handle upgrade requests for WebSocket
this.server.on( this.server.on(
"upgrade", "upgrade",
@ -1083,6 +1087,15 @@ export class BrowserConnector {
this.setupAuditEndpoint(AuditCategory.SEO, "/seo-audit", runSEOAudit); this.setupAuditEndpoint(AuditCategory.SEO, "/seo-audit", runSEOAudit);
} }
// Add a setup method for Best Practices audit
private setupBestPracticesAudit() {
this.setupAuditEndpoint(
AuditCategory.BEST_PRACTICES,
"/best-practices-audit",
runBestPracticesAudit
);
}
/** /**
* Generic method to set up an audit endpoint * Generic method to set up an audit endpoint
* @param auditType The type of audit (accessibility, performance, SEO) * @param auditType The type of audit (accessibility, performance, SEO)

View File

@ -0,0 +1,336 @@
import { Result as LighthouseResult } from "lighthouse";
import { AuditCategory, LighthouseReport } from "./types.js";
import { runLighthouseAudit } from "./index.js";
// === Best Practices Report Types ===
/**
* Best Practices-specific report content structure
*/
export interface BestPracticesReportContent {
score: number; // Overall score (0-100)
audit_counts: {
// Counts of different audit types
failed: number;
passed: number;
manual: number;
informative: number;
not_applicable: number;
};
issues: AIBestPracticesIssue[];
categories: {
[category: string]: {
score: number;
issues_count: number;
};
};
prioritized_recommendations?: string[]; // Ordered list of recommendations
}
/**
* Full Best Practices report implementing the base LighthouseReport interface
*/
export type AIOptimizedBestPracticesReport =
LighthouseReport<BestPracticesReportContent>;
/**
* AI-optimized Best Practices issue
*/
interface AIBestPracticesIssue {
id: string; // e.g., "js-libraries"
title: string; // e.g., "Detected JavaScript libraries"
impact: "critical" | "serious" | "moderate" | "minor";
category: string; // e.g., "security", "trust", "user-experience", "browser-compat"
details?: {
name?: string; // Name of the item (e.g., library name, vulnerability)
version?: string; // Version information if applicable
value?: string; // Current value or status
issue?: string; // Description of the issue
}[];
score: number | null; // 0-1 or null
}
// Original interfaces for backward compatibility
interface BestPracticesAudit {
id: string;
title: string;
description: string;
score: number | null;
scoreDisplayMode: string;
details?: BestPracticesAuditDetails;
}
interface BestPracticesAuditDetails {
items?: Array<Record<string, unknown>>;
type?: string; // e.g., "table"
}
// This ensures we always include critical issues while limiting less important ones
const DETAIL_LIMITS: Record<string, number> = {
critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues
serious: 15, // Up to 15 items for serious issues
moderate: 10, // Up to 10 items for moderate issues
minor: 3, // Up to 3 items for minor issues
};
/**
* Runs a Best Practices audit on the specified URL
* @param url The URL to audit
* @returns Promise resolving to AI-optimized Best Practices audit results
*/
export async function runBestPracticesAudit(
url: string
): Promise<AIOptimizedBestPracticesReport> {
try {
const lhr = await runLighthouseAudit(url, [AuditCategory.BEST_PRACTICES]);
return extractAIOptimizedData(lhr, url);
} catch (error) {
throw new Error(
`Best Practices audit failed: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Extract AI-optimized Best Practices data from Lighthouse results
*/
const extractAIOptimizedData = (
lhr: LighthouseResult,
url: string
): AIOptimizedBestPracticesReport => {
const categoryData = lhr.categories[AuditCategory.BEST_PRACTICES];
const audits = lhr.audits || {};
// Add metadata
const metadata = {
url,
timestamp: lhr.fetchTime || new Date().toISOString(),
device: lhr.configSettings?.formFactor || "desktop",
lighthouseVersion: lhr.lighthouseVersion || "unknown",
};
// Process audit results
const issues: AIBestPracticesIssue[] = [];
const categories: { [key: string]: { score: number; issues_count: number } } =
{
security: { score: 0, issues_count: 0 },
trust: { score: 0, issues_count: 0 },
"user-experience": { score: 0, issues_count: 0 },
"browser-compat": { score: 0, issues_count: 0 },
other: { score: 0, issues_count: 0 },
};
// Counters for audit types
let failedCount = 0;
let passedCount = 0;
let manualCount = 0;
let informativeCount = 0;
let notApplicableCount = 0;
// Process failed audits (score < 1)
const failedAudits = Object.entries(audits)
.filter(([, audit]) => {
const score = audit.score;
return (
score !== null &&
score < 1 &&
audit.scoreDisplayMode !== "manual" &&
audit.scoreDisplayMode !== "notApplicable"
);
})
.map(([auditId, audit]) => ({ auditId, ...audit }));
// Update counters
Object.values(audits).forEach((audit) => {
const { score, scoreDisplayMode } = audit;
if (scoreDisplayMode === "manual") {
manualCount++;
} else if (scoreDisplayMode === "informative") {
informativeCount++;
} else if (scoreDisplayMode === "notApplicable") {
notApplicableCount++;
} else if (score === 1) {
passedCount++;
} else if (score !== null && score < 1) {
failedCount++;
}
});
// Process failed audits into AI-friendly format
failedAudits.forEach((ref: any) => {
// Determine impact level based on audit score and weight
let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
const score = ref.score || 0;
// Use a more reliable approach to determine impact
if (score === 0) {
impact = "critical";
} else if (score < 0.5) {
impact = "serious";
} else if (score < 0.9) {
impact = "moderate";
} else {
impact = "minor";
}
// Categorize the issue
let category = "other";
// Security-related issues
if (
ref.auditId.includes("csp") ||
ref.auditId.includes("security") ||
ref.auditId.includes("vulnerab") ||
ref.auditId.includes("password") ||
ref.auditId.includes("cert") ||
ref.auditId.includes("deprecat")
) {
category = "security";
}
// Trust and legitimacy issues
else if (
ref.auditId.includes("doctype") ||
ref.auditId.includes("charset") ||
ref.auditId.includes("legit") ||
ref.auditId.includes("trust")
) {
category = "trust";
}
// User experience issues
else if (
ref.auditId.includes("user") ||
ref.auditId.includes("experience") ||
ref.auditId.includes("console") ||
ref.auditId.includes("errors") ||
ref.auditId.includes("paste")
) {
category = "user-experience";
}
// Browser compatibility issues
else if (
ref.auditId.includes("compat") ||
ref.auditId.includes("browser") ||
ref.auditId.includes("vendor") ||
ref.auditId.includes("js-lib")
) {
category = "browser-compat";
}
// Count issues by category
categories[category].issues_count++;
// Create issue object
const issue: AIBestPracticesIssue = {
id: ref.auditId,
title: ref.title,
impact,
category,
score: ref.score,
details: [],
};
// Extract details if available
const refDetails = ref.details as BestPracticesAuditDetails | undefined;
if (refDetails?.items && Array.isArray(refDetails.items)) {
const itemLimit = DETAIL_LIMITS[impact];
const detailItems = refDetails.items.slice(0, itemLimit);
detailItems.forEach((item: Record<string, unknown>) => {
issue.details = issue.details || [];
// Different audits have different detail structures
const detail: Record<string, string> = {};
if (typeof item.name === "string") detail.name = item.name;
if (typeof item.version === "string") detail.version = item.version;
if (typeof item.issue === "string") detail.issue = item.issue;
if (item.value !== undefined) detail.value = String(item.value);
// For JS libraries, extract name and version
if (
ref.auditId === "js-libraries" &&
typeof item.name === "string" &&
typeof item.version === "string"
) {
detail.name = item.name;
detail.version = item.version;
}
// Add other generic properties that might exist
for (const [key, value] of Object.entries(item)) {
if (!detail[key] && typeof value === "string") {
detail[key] = value;
}
}
issue.details.push(detail as any);
});
}
issues.push(issue);
});
// Calculate category scores (0-100)
Object.keys(categories).forEach((category) => {
// Simplified scoring: if there are issues in this category, score is reduced proportionally
const issueCount = categories[category].issues_count;
if (issueCount > 0) {
// More issues = lower score, max penalty of 25 points per issue
const penalty = Math.min(100, issueCount * 25);
categories[category].score = Math.max(0, 100 - penalty);
} else {
categories[category].score = 100;
}
});
// Generate prioritized recommendations
const prioritized_recommendations: string[] = [];
// Prioritize recommendations by category with most issues
Object.entries(categories)
.filter(([_, data]) => data.issues_count > 0)
.sort(([_, a], [__, b]) => b.issues_count - a.issues_count)
.forEach(([category, data]) => {
let recommendation = "";
switch (category) {
case "security":
recommendation = `Address ${data.issues_count} security issues: vulnerabilities, CSP, deprecations`;
break;
case "trust":
recommendation = `Fix ${data.issues_count} trust & legitimacy issues: doctype, charset`;
break;
case "user-experience":
recommendation = `Improve ${data.issues_count} user experience issues: console errors, user interactions`;
break;
case "browser-compat":
recommendation = `Resolve ${data.issues_count} browser compatibility issues: outdated libraries, vendor prefixes`;
break;
default:
recommendation = `Fix ${data.issues_count} other best practice issues`;
}
prioritized_recommendations.push(recommendation);
});
// Return the optimized report
return {
metadata,
report: {
score: categoryData?.score ? Math.round(categoryData.score * 100) : 0,
audit_counts: {
failed: failedCount,
passed: passedCount,
manual: manualCount,
informative: informativeCount,
not_applicable: notApplicableCount,
},
issues,
categories,
prioritized_recommendations,
},
};
};