2025-02-26 17:58:30 +01:00
|
|
|
import lighthouse from "lighthouse";
|
|
|
|
import type { Result as LighthouseResult, Flags } from "lighthouse";
|
|
|
|
import {
|
|
|
|
connectToHeadlessBrowser,
|
|
|
|
scheduleBrowserCleanup,
|
|
|
|
} from "../browser-utils.js";
|
2025-02-26 18:33:39 +01:00
|
|
|
import {
|
|
|
|
LighthouseConfig,
|
|
|
|
AuditCategory,
|
|
|
|
AuditIssue,
|
|
|
|
LighthouseDetails,
|
|
|
|
ImpactLevel,
|
|
|
|
} from "./types.js";
|
2025-02-26 17:58:30 +01:00
|
|
|
|
|
|
|
// ===== 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;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a Lighthouse configuration object
|
|
|
|
* @param categories Array of categories to audit
|
|
|
|
* @returns Lighthouse configuration and flags
|
|
|
|
*/
|
|
|
|
export function createLighthouseConfig(
|
|
|
|
categories: string[] = [AuditCategory.ACCESSIBILITY]
|
|
|
|
): LighthouseConfig {
|
|
|
|
return {
|
|
|
|
flags: {
|
|
|
|
output: ["json"],
|
|
|
|
onlyCategories: categories,
|
|
|
|
formFactor: "desktop",
|
|
|
|
port: undefined as number | undefined,
|
|
|
|
screenEmulation: {
|
|
|
|
mobile: false,
|
|
|
|
width: 1350,
|
|
|
|
height: 940,
|
|
|
|
deviceScaleFactor: 1,
|
|
|
|
disabled: false,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
config: {
|
|
|
|
extends: "lighthouse:default",
|
|
|
|
settings: {
|
|
|
|
onlyCategories: categories,
|
|
|
|
emulatedFormFactor: "desktop",
|
|
|
|
throttling: { cpuSlowdownMultiplier: 1 },
|
|
|
|
},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Runs a Lighthouse audit on the specified URL via CDP
|
|
|
|
* @param url The URL to audit
|
|
|
|
* @param categories Array of categories to audit, defaults to ["accessibility"]
|
|
|
|
* @returns Promise resolving to the Lighthouse result
|
|
|
|
* @throws Error if the URL is invalid or if the audit fails
|
|
|
|
*/
|
|
|
|
export async function runLighthouseOnExistingTab(
|
|
|
|
url: string,
|
|
|
|
categories: string[] = [AuditCategory.ACCESSIBILITY]
|
|
|
|
): Promise<LighthouseResult> {
|
|
|
|
console.log(`Starting Lighthouse ${categories.join(", ")} audit for: ${url}`);
|
|
|
|
|
2025-02-26 18:33:39 +01:00
|
|
|
if (!url || url === "about:blank") {
|
|
|
|
console.error("Invalid URL for Lighthouse audit");
|
|
|
|
throw new Error(
|
|
|
|
"Cannot run audit on an empty page or about:blank. Please navigate to a valid URL first."
|
|
|
|
);
|
2025-02-26 17:58:30 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Always use a dedicated headless browser for audits
|
|
|
|
console.log("Using dedicated headless browser for audit");
|
|
|
|
|
|
|
|
// Determine if this is a performance audit - we need to load all resources for performance audits
|
|
|
|
const isPerformanceAudit = categories.includes(AuditCategory.PERFORMANCE);
|
|
|
|
|
|
|
|
// For performance audits, we want to load all resources
|
|
|
|
// For accessibility or other audits, we can block non-essential resources
|
2025-02-26 18:33:39 +01:00
|
|
|
try {
|
|
|
|
const { port } = await connectToHeadlessBrowser(url, {
|
|
|
|
blockResources: !isPerformanceAudit,
|
|
|
|
// Don't pass an audit type - the blockResources flag is what matters
|
|
|
|
});
|
2025-02-26 17:58:30 +01:00
|
|
|
|
2025-02-26 18:33:39 +01:00
|
|
|
console.log(`Connected to browser on port: ${port}`);
|
2025-02-26 17:58:30 +01:00
|
|
|
|
2025-02-26 18:33:39 +01:00
|
|
|
// Create Lighthouse config
|
|
|
|
const { flags, config } = createLighthouseConfig(categories);
|
|
|
|
flags.port = port;
|
2025-02-26 17:58:30 +01:00
|
|
|
|
2025-02-26 18:33:39 +01:00
|
|
|
console.log(
|
|
|
|
`Running Lighthouse with categories: ${categories.join(", ")}`
|
|
|
|
);
|
|
|
|
const runnerResult = await lighthouse(url, flags as Flags, config);
|
|
|
|
console.log("Lighthouse audit completed");
|
2025-02-26 17:58:30 +01:00
|
|
|
|
2025-02-26 18:33:39 +01:00
|
|
|
if (!runnerResult?.lhr) {
|
|
|
|
console.error("Lighthouse audit failed to produce results");
|
|
|
|
throw new Error("Lighthouse audit failed to produce results");
|
|
|
|
}
|
2025-02-26 17:58:30 +01:00
|
|
|
|
2025-02-26 18:33:39 +01:00
|
|
|
// Schedule browser cleanup after a delay to allow for subsequent audits
|
|
|
|
scheduleBrowserCleanup();
|
2025-02-26 17:58:30 +01:00
|
|
|
|
2025-02-26 18:33:39 +01:00
|
|
|
// 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;
|
|
|
|
}
|
2025-02-26 17:58:30 +01:00
|
|
|
} catch (error) {
|
|
|
|
console.error("Lighthouse audit failed:", error);
|
|
|
|
// Schedule browser cleanup even if the audit fails
|
|
|
|
scheduleBrowserCleanup();
|
|
|
|
throw new Error(
|
|
|
|
`Lighthouse audit failed: ${
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Export from specific audit modules
|
|
|
|
export * from "./accessibility.js";
|
|
|
|
export * from "./performance.js";
|
2025-02-26 19:57:18 +01:00
|
|
|
export * from "./seo.js";
|
2025-02-26 17:58:30 +01:00
|
|
|
export * from "./types.js";
|
2025-02-26 18:33:39 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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,
|
|
|
|
};
|
|
|
|
}
|