mirror of
https://github.com/AgentDeskAI/browser-tools-mcp.git
synced 2025-12-03 10:32:14 +00:00
Refactor Lighthouse audit modules to standardize result processing
This commit is contained in:
parent
1603764d7b
commit
70cb154e8f
@ -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<AuditResult> {
|
||||
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<string, unknown>)
|
||||
?.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<Partial<AuditResult>> {
|
||||
url: string
|
||||
): Promise<LighthouseReport> {
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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<LighthouseResult> {
|
||||
@ -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<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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<AuditResult> {
|
||||
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<Partial<AuditResult>> {
|
||||
url: string
|
||||
): Promise<LighthouseReport> {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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<AuditResult> {
|
||||
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<Partial<AuditResult>> {
|
||||
const FAILED_AUDITS_LIMIT = 5;
|
||||
const MAX_ITEMS_IN_DETAILS = 3;
|
||||
|
||||
export async function runSEOAudit(url: string): Promise<LighthouseReport> {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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<Record<string, unknown>>;
|
||||
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",
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user