Add Lighthouse Audits via Puppeteer for Accessibility & Performance

This commit is contained in:
Emil Neander 2025-02-26 17:58:30 +01:00
parent 3a1d35a256
commit 37269d1e2d
10 changed files with 1546 additions and 6 deletions

View File

@ -3,6 +3,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import path from "path";
import { z } from "zod";
// import { z } from "zod";
// import fs from "fs";
@ -12,6 +13,15 @@ const server = new McpServer({
version: "1.0.9",
});
// Define audit categories as constants to avoid importing from the types file
const AUDIT_CATEGORIES = {
ACCESSIBILITY: "accessibility",
PERFORMANCE: "performance",
SEO: "seo",
BEST_PRACTICES: "best-practices",
PWA: "pwa",
};
// Function to get the port from the .port file
// function getPort(): number {
// try {
@ -187,6 +197,109 @@ server.tool("wipeLogs", "Wipe all browser logs from memory", async () => {
};
});
// Add tool for accessibility audits
server.tool(
"runAccessibilityAudit",
"Run a WCAG-compliant accessibility audit on the current page",
{},
async () => {
try {
const response = await fetch(
`http://127.0.0.1:${PORT}/accessibility-audit`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category: AUDIT_CATEGORIES.ACCESSIBILITY,
}),
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`HTTP error! status: ${response.status}, body: ${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 accessibility audit:", errorMessage);
return {
content: [
{
type: "text",
text: `Failed to run accessibility audit: ${errorMessage}`,
},
],
};
}
}
);
// Add tool for performance audits
server.tool(
"runPerformanceAudit",
"Run a performance audit on the current page",
{},
async () => {
try {
const response = await fetch(
`http://127.0.0.1:${PORT}/performance-audit`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category: AUDIT_CATEGORIES.PERFORMANCE,
}),
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`HTTP error! status: ${response.status}, body: ${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 performance audit:", errorMessage);
return {
content: [
{
type: "text",
text: `Failed to run performance audit: ${errorMessage}`,
},
],
};
}
}
);
// Start receiving messages on stdio
(async () => {
try {

View File

@ -32,6 +32,7 @@
"cors": "^2.8.5",
"express": "^4.21.2",
"llm-cost": "^1.0.5",
"node-fetch": "^2.7.0",
"ws": "^8.18.0"
},
"devDependencies": {
@ -40,6 +41,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.1",
"@types/node-fetch": "^2.6.11",
"typescript": "^5.7.3"
}
}

View File

@ -4,12 +4,14 @@ import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { tokenizeAndEstimateCost } from "llm-cost";
import WebSocket from "ws";
import { WebSocketServer, WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { IncomingMessage } from "http";
import { Socket } from "net";
import os from "os";
import { runPerformanceAudit } from "./lighthouse/performance.js";
import { runAccessibilityAudit } from "./lighthouse/accessibility.js";
// Function to get default downloads folder
function getDefaultDownloadsFolder(): string {
@ -26,6 +28,9 @@ const networkErrors: any[] = [];
const networkSuccess: any[] = [];
const allXhr: any[] = [];
// Store the current URL from the extension
let currentUrl: string = "";
// Add settings state
let currentSettings = {
logLimit: 50,
@ -178,6 +183,14 @@ app.post("/extension-log", (req, res) => {
console.log(`Processing ${data.type} log entry`);
switch (data.type) {
case "page-navigated":
// Handle page navigation event via HTTP POST
// Note: This is also handled in the WebSocket message handler
// as the extension may send navigation events through either channel
console.log("Received page navigation event with URL:", data.url);
currentUrl = data.url;
console.log("Updated current URL:", currentUrl);
break;
case "console-log":
console.log("Adding console log:", {
level: data.level,
@ -324,6 +337,26 @@ app.post("/wipelogs", (req, res) => {
res.json({ status: "ok", message: "All logs cleared successfully" });
});
// Add endpoint for the extension to report the current URL
app.post("/current-url", (req, res) => {
console.log("Received current URL update:", req.body);
if (req.body && req.body.url) {
currentUrl = req.body.url;
console.log("Updated current URL via dedicated endpoint:", currentUrl);
res.json({ status: "ok", url: currentUrl });
} else {
console.log("No URL provided in current-url request");
res.status(400).json({ status: "error", message: "No URL provided" });
}
});
// Add endpoint to get the current URL
app.get("/current-url", (req, res) => {
console.log("Current URL requested, returning:", currentUrl);
res.json({ url: currentUrl });
});
interface ScreenshotMessage {
type: "screenshot-data" | "screenshot-error";
data?: string;
@ -332,17 +365,18 @@ interface ScreenshotMessage {
}
export class BrowserConnector {
private wss: WebSocket.Server;
private wss: WebSocketServer;
private activeConnection: WebSocket | null = null;
private app: express.Application;
private server: any;
private urlRequestCallbacks: Map<string, (url: string) => void> = new Map();
constructor(app: express.Application, server: any) {
this.app = app;
this.server = server;
// Initialize WebSocket server using the existing HTTP server
this.wss = new WebSocket.Server({
this.wss = new WebSocketServer({
noServer: true,
path: "/extension-ws",
});
@ -363,19 +397,25 @@ export class BrowserConnector {
}
);
// Set up accessibility audit endpoint
this.setupAccessibilityAudit();
// Set up performance audit endpoint
this.setupPerformanceAudit();
// Handle upgrade requests for WebSocket
this.server.on(
"upgrade",
(request: IncomingMessage, socket: Socket, head: Buffer) => {
if (request.url === "/extension-ws") {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
this.wss.emit("connection", ws, request);
});
}
}
);
this.wss.on("connection", (ws) => {
this.wss.on("connection", (ws: WebSocket) => {
console.log("Chrome extension connected via WebSocket");
this.activeConnection = ws;
@ -388,6 +428,28 @@ export class BrowserConnector {
data: data.data ? "[base64 data]" : undefined,
});
// Handle URL response
if (data.type === "current-url-response" && data.url) {
console.log("Received current URL from browser:", data.url);
currentUrl = data.url;
// Call the callback if exists
if (
data.requestId &&
this.urlRequestCallbacks.has(data.requestId)
) {
const callback = this.urlRequestCallbacks.get(data.requestId);
if (callback) callback(data.url);
this.urlRequestCallbacks.delete(data.requestId);
}
}
// Handle page navigation event via WebSocket
// Note: This is intentionally duplicated from the HTTP handler in /extension-log
// as the extension may send navigation events through either channel
if (data.type === "page-navigated" && data.url) {
console.log("Page navigated to:", data.url);
currentUrl = data.url;
}
// Handle screenshot response
if (data.type === "screenshot-data" && data.data) {
console.log("Received screenshot data");
@ -562,6 +624,66 @@ export class BrowserConnector {
}
}
// Method to request the current URL from the browser
private async requestCurrentUrl(): Promise<string | null> {
if (this.activeConnection) {
console.log("Requesting current URL from browser via WebSocket...");
try {
const requestId = Date.now().toString();
// Create a promise that will resolve when we get the URL
const urlPromise = new Promise<string>((resolve, reject) => {
// Store callback in map
this.urlRequestCallbacks.set(requestId, resolve);
// Set a timeout to reject the promise if we don't get a response
setTimeout(() => {
if (this.urlRequestCallbacks.has(requestId)) {
console.log("URL request timed out");
this.urlRequestCallbacks.delete(requestId);
reject(new Error("URL request timed out"));
}
}, 5000);
});
// Send the request to the browser
this.activeConnection.send(
JSON.stringify({
type: "get-current-url",
requestId,
})
);
// Wait for the response
const url = await urlPromise;
return url;
} catch (error) {
console.error("Error requesting URL from browser:", error);
// Fall back to stored URL if available
if (currentUrl) {
console.log("Falling back to stored URL:", currentUrl);
return currentUrl;
}
return null;
}
} else if (currentUrl) {
// If no active connection but we have a stored URL, use it as fallback
console.log(
"No active connection, using stored URL as fallback:",
currentUrl
);
return currentUrl;
}
console.log("No active connection and no stored URL");
return null;
}
// Public method to check if there's an active connection
public hasActiveConnection(): boolean {
return this.activeConnection !== null;
}
// Add new endpoint for programmatic screenshot capture
async captureScreenshot(req: express.Request, res: express.Response) {
console.log("Browser Connector: Starting captureScreenshot method");
@ -667,6 +789,199 @@ export class BrowserConnector {
});
}
}
// Add a helper method to get the URL for audits
private async getUrlForAudit(): Promise<string | null> {
// Wait for WebSocket connection if not already connected
if (!this.activeConnection) {
console.log("No active WebSocket connection, waiting for connection...");
try {
await this.waitForConnection(15000); // Wait up to 15 seconds for connection
console.log("WebSocket connection established");
} catch (error) {
console.error("Timed out waiting for WebSocket connection");
}
}
console.log("Attempting to get current URL from browser extension");
const browserUrl = await this.requestCurrentUrl();
if (browserUrl) {
try {
// Validate URL format
new URL(browserUrl);
return browserUrl;
} catch (e) {
console.error(`Invalid URL format from browser: ${browserUrl}`);
// Continue to next option
}
}
// Fallback: Use stored URL
if (currentUrl) {
try {
// Validate URL format
new URL(currentUrl);
console.log(`Using stored URL as fallback: ${currentUrl}`);
return currentUrl;
} catch (e) {
console.error(`Invalid stored URL format: ${currentUrl}`);
// Continue to next option
}
}
// Default fallback
console.error("No valid URL available for audit, using about:blank");
return "about:blank";
}
// Method to wait for WebSocket connection, we need this to ensure the browser extension
// is connected before we try to get the current URL
private waitForConnection(timeout: number): Promise<void> {
return new Promise((resolve, reject) => {
// If already connected, resolve immediately
if (this.activeConnection) {
resolve();
return;
}
// Set up a listener for connection
const connectionListener = (ws: WebSocket) => {
this.wss.off("connection", connectionListener);
resolve();
};
// Listen for connection event
this.wss.on("connection", connectionListener);
// Set timeout
const timeoutId = setTimeout(() => {
this.wss.off("connection", connectionListener);
reject(new Error("Connection timeout"));
}, timeout);
});
}
// Sets up the accessibility audit endpoint
private setupAccessibilityAudit() {
this.app.post("/accessibility-audit", async (req: any, res: any) => {
try {
console.log("Accessibility audit request received");
const limit = req.body?.limit || 5;
const detailed = req.body?.detailed || false;
// Get URL using our helper method
const url = await this.getUrlForAudit();
if (!url) {
console.log("No URL available for accessibility audit");
return res.status(400).json({
error:
"URL is required for accessibility audit. Either provide a URL in the request body or navigate to a page in the browser first.",
});
}
// Check if we're using the default URL
if (url === "about:blank") {
console.log("Cannot run accessibility audit on about:blank");
return res.status(400).json({
error:
"Cannot run accessibility audit on about:blank. Please provide a valid URL or navigate to a page in the browser first.",
});
}
// Run the audit using the imported function
try {
const processedResults = await runAccessibilityAudit(
url,
limit,
detailed
);
console.log("Accessibility audit completed successfully");
// Return the results
res.json(processedResults);
} catch (auditError) {
console.error("Accessibility audit failed:", auditError);
const errorMessage =
auditError instanceof Error
? auditError.message
: String(auditError);
res.status(500).json({
error: `Failed to run accessibility audit: ${errorMessage}`,
});
}
} catch (error) {
console.error("Error in accessibility audit endpoint:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
res.status(500).json({
error: `Error in accessibility audit endpoint: ${errorMessage}`,
});
}
});
}
// Sets up the performance audit endpoint
private setupPerformanceAudit() {
this.app.post("/performance-audit", async (req: any, res: any) => {
try {
console.log("Performance audit request received");
const limit = req.body?.limit || 5;
const detailed = req.body?.detailed || false;
console.log("Performance audit request body:", req.body);
// Get URL using our helper method
const url = await this.getUrlForAudit();
if (!url) {
console.log("No URL available for performance audit");
return res.status(400).json({
error:
"URL is required for performance audit. Either provide a URL in the request body or navigate to a page in the browser first.",
});
}
// Check if we're using the default URL
if (url === "about:blank") {
console.log("Cannot run performance audit on about:blank");
return res.status(400).json({
error:
"Cannot run performance audit on about:blank. Please provide a valid URL or navigate to a page in the browser first.",
});
}
// Run the audit using the imported function
try {
const processedResults = await runPerformanceAudit(
url,
limit,
detailed
);
console.log("Performance audit completed successfully");
// Return the results
res.json(processedResults);
} catch (auditError) {
console.error("Performance audit failed:", auditError);
const errorMessage =
auditError instanceof Error
? auditError.message
: String(auditError);
res.status(500).json({
error: `Failed to run performance audit: ${errorMessage}`,
});
}
} catch (error) {
console.error("Error in performance audit endpoint:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
res.status(500).json({
error: `Error in performance audit endpoint: ${errorMessage}`,
});
}
});
}
}
// Move the server creation before BrowserConnector instantiation

View File

@ -0,0 +1,414 @@
import fs from "fs";
import fetch from "node-fetch";
import puppeteer from "puppeteer-core";
import { spawn } from "child_process";
import path from "path";
import os from "os";
// Global variable to store the launched browser's WebSocket endpoint
let launchedBrowserWSEndpoint: string | null = null;
// Global variable to store the browser instance for reuse
let headlessBrowserInstance: puppeteer.Browser | null = null;
// Add a timeout variable to track browser cleanup
let browserCleanupTimeout: NodeJS.Timeout | null = null;
// Default timeout in milliseconds before closing the browser
const BROWSER_CLEANUP_TIMEOUT = 60000; // 60 seconds
/**
* Finds the path to an installed browser (Chrome or Edge)
* @returns Promise resolving to the path of the browser executable
* @throws Error if no compatible browser is found
*/
export async function findBrowserExecutablePath(): Promise<string> {
const platform = process.platform;
if (platform === "darwin") {
// Check for Edge first on macOS
if (fs.existsSync("/Applications/Microsoft Edge.app")) {
return "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge";
}
// Fallback to Chrome
if (fs.existsSync("/Applications/Google Chrome.app")) {
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
}
} else if (platform === "win32") {
// Check for Edge first on Windows
if (
fs.existsSync(
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
)
) {
return "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe";
}
// Fallback to Chrome
if (
fs.existsSync(
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
)
) {
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
}
}
throw new Error(
"No compatible browser found. Please install Microsoft Edge or Google Chrome."
);
}
/**
* Gets the WebSocket debugger URL for a running Chrome/Edge instance
* @returns Promise resolving to the WebSocket URL
* @throws Error if no debugging browser is found
*/
export async function getDebuggerWebSocketUrl(): Promise<string> {
console.log("Attempting to get debugger WebSocket URL...");
try {
// Try Chrome first
try {
console.log("Attempting to connect to Chrome on port 9222...");
// Attempt to connect to Chrome on port 9222 using IPv4 explicitly
const response = await fetch("http://127.0.0.1:9222/json/version");
if (response.ok) {
const data = await response.json();
console.log(
"Successfully connected to Chrome:",
data.webSocketDebuggerUrl
);
return data.webSocketDebuggerUrl;
} else {
console.log("Chrome connection response not OK:", response.status);
}
} catch (error) {
// More detailed error logging
console.log(
"Failed to connect to Chrome:",
error instanceof Error ? error.message : String(error)
);
}
// Try Edge next (it often uses port 9222 as well)
try {
console.log("Attempting to connect to Edge on port 9222...");
const response = await fetch("http://127.0.0.1:9222/json/version");
if (response.ok) {
const data = await response.json();
console.log(
"Successfully connected to Edge:",
data.webSocketDebuggerUrl
);
return data.webSocketDebuggerUrl;
} else {
console.log("Edge connection response not OK:", response.status);
}
} catch (error) {
// More detailed error logging
console.log(
"Failed to connect to Edge:",
error instanceof Error ? error.message : String(error)
);
}
// Try alternative ports
const alternativePorts = [9223, 9224, 9225];
for (const port of alternativePorts) {
try {
console.log(`Attempting to connect on alternative port ${port}...`);
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
if (response.ok) {
const data = await response.json();
console.log(
`Successfully connected on port ${port}:`,
data.webSocketDebuggerUrl
);
return data.webSocketDebuggerUrl;
}
} catch (error) {
console.log(
`Failed to connect on port ${port}:`,
error instanceof Error ? error.message : String(error)
);
}
}
throw new Error("No debugging browser found on any port");
} catch (error) {
console.error(
"Error getting debugger WebSocket URL:",
error instanceof Error ? error.message : String(error)
);
throw new Error(
"Ensure a browser (Chrome or Edge) is running with --remote-debugging-port=9222"
);
}
}
/**
* Launches a new browser instance with remote debugging enabled
* @returns Promise resolving to the port number the browser is running on
* @throws Error if unable to launch browser
*/
export async function launchBrowserWithDebugging(): Promise<number> {
console.log("Attempting to launch a new browser with debugging enabled...");
try {
// Use the singleton browser instance
const browser = await getHeadlessBrowserInstance();
if (!launchedBrowserWSEndpoint) {
throw new Error("Failed to retrieve WebSocket endpoint for browser");
}
// Extract port from WebSocket endpoint
const port = parseInt(
launchedBrowserWSEndpoint.split(":")[2].split("/")[0]
);
console.log(
`Browser launched with WebSocket endpoint: ${launchedBrowserWSEndpoint}, port: ${port}`
);
// Test browser responsiveness
try {
console.log("Testing browser responsiveness...");
const page = await browser.newPage();
await page.goto("about:blank");
console.log("Browser is responsive and ready");
return port;
} catch (pageError: any) {
console.error("Failed to create page in browser:", pageError);
throw new Error(
`Browser launched but is unresponsive: ${pageError.message}`
);
}
} catch (error) {
console.error("Failed to launch browser:", error);
throw new Error(
`Failed to launch browser: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// Function to get the singleton browser instance
async function getHeadlessBrowserInstance(): Promise<puppeteer.Browser> {
console.log("Browser instance request started");
// Clear any existing cleanup timeout when a new request comes in
if (browserCleanupTimeout) {
console.log("Cancelling scheduled browser cleanup");
clearTimeout(browserCleanupTimeout);
browserCleanupTimeout = null;
}
if (headlessBrowserInstance && launchedBrowserWSEndpoint) {
try {
// Check if the browser is still connected
const pages = await headlessBrowserInstance.pages();
console.log(
`Reusing existing headless browser with ${pages.length} pages`
);
return headlessBrowserInstance;
} catch (error) {
console.log(
"Existing browser instance is no longer valid, creating a new one"
);
headlessBrowserInstance = null;
launchedBrowserWSEndpoint = null;
}
}
// Launch a new browser
console.log("Creating new headless browser instance");
const browserPath = await findBrowserExecutablePath();
// Create a unique temporary user data directory
const tempDir = os.tmpdir();
const uniqueId = `${Date.now().toString()}-${Math.random()
.toString(36)
.substring(2)}`;
const userDataDir = path.join(tempDir, `browser-debug-profile-${uniqueId}`);
fs.mkdirSync(userDataDir, { recursive: true });
console.log(`Using temporary user data directory: ${userDataDir}`);
// Launch browser with puppeteer using dynamic port
console.log("Launching browser with puppeteer in headless mode...");
const browser = await puppeteer.launch({
executablePath: browserPath,
args: [
"--remote-debugging-port=0", // Use dynamic port
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage", // Helps with memory issues in Docker
"--disable-extensions",
"--disable-component-extensions-with-background-pages",
"--disable-background-networking",
"--disable-backgrounding-occluded-windows",
"--disable-default-apps",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
"--no-pings",
"--safebrowsing-disable-auto-update",
],
headless: true,
});
// Store the WebSocket endpoint
launchedBrowserWSEndpoint = browser.wsEndpoint();
headlessBrowserInstance = browser;
// Optional cleanup: Remove directory when browser closes
browser.on("disconnected", () => {
console.log(`Cleaning up temporary directory: ${userDataDir}`);
fs.rmSync(userDataDir, { recursive: true, force: true });
launchedBrowserWSEndpoint = null;
headlessBrowserInstance = null;
// Clear any existing cleanup timeout when browser is disconnected
if (browserCleanupTimeout) {
clearTimeout(browserCleanupTimeout);
browserCleanupTimeout = null;
}
});
console.log("Browser ready");
return browser;
}
/**
* Connects to a headless browser specifically for audits
* This function skips all attempts to connect to existing browsers and always launches a new headless browser
* @param url The URL to navigate to
* @param options Options for the audit
* @returns Promise resolving to the browser instance and port
*/
export async function connectToHeadlessBrowser(
url: string,
options: {
blockResources?: boolean;
} = {}
): Promise<{
browser: puppeteer.Browser;
port: number;
page: puppeteer.Page;
}> {
console.log(
`Connecting to headless browser for audit${
options.blockResources ? " (blocking non-essential resources)" : ""
}`
);
try {
// Validate URL format
try {
new URL(url);
} catch (e) {
throw new Error(`Invalid URL format: ${url}`);
}
// Get or create a browser instance
const browser = await getHeadlessBrowserInstance();
if (!launchedBrowserWSEndpoint) {
throw new Error("Failed to retrieve WebSocket endpoint for browser");
}
// Extract port from WebSocket endpoint
const port = parseInt(
launchedBrowserWSEndpoint.split(":")[2].split("/")[0]
);
// Always create a new page for each audit to avoid request interception conflicts
console.log("Creating a new page for this audit");
const page = await browser.newPage();
// Set a longer timeout for navigation
page.setDefaultNavigationTimeout(60000); // 60 seconds
// Check if we should block resources based on the options
if (options.blockResources) {
await page.setRequestInterception(true);
page.on("request", (request) => {
// Block unnecessary resources to speed up loading
const resourceType = request.resourceType();
if (
resourceType === "image" ||
resourceType === "font" ||
resourceType === "media"
) {
request.abort();
} else {
request.continue();
}
});
}
// Navigate to the URL with more flexible options
try {
// First try with domcontentloaded which is faster
await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30000, // 30 seconds
});
} catch (navError: any) {
console.warn(
`Navigation with domcontentloaded failed: ${navError.message}, trying with load event...`
);
// If that fails, try with just load event
try {
await page.goto(url, {
waitUntil: "load",
timeout: 45000, // 45 seconds
});
} catch (loadError: any) {
console.error(
`Navigation with load event also failed: ${loadError.message}`
);
throw loadError; // Re-throw the error
}
}
return { browser, port, page };
} catch (error) {
console.error("Failed to connect to headless browser:", error);
throw new Error(
`Failed to connect to headless browser: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Schedule browser cleanup after a delay
* This allows the browser to be reused for subsequent audits within the timeout period
*/
export function scheduleBrowserCleanup(): void {
// Clear any existing timeout first
if (browserCleanupTimeout) {
clearTimeout(browserCleanupTimeout);
}
// Only schedule cleanup if we have an active browser instance
if (headlessBrowserInstance) {
console.log(
`Scheduling browser cleanup in ${BROWSER_CLEANUP_TIMEOUT / 1000} seconds`
);
browserCleanupTimeout = setTimeout(() => {
console.log("Executing scheduled browser cleanup");
if (headlessBrowserInstance) {
console.log("Closing headless browser instance");
headlessBrowserInstance.close();
headlessBrowserInstance = null;
launchedBrowserWSEndpoint = null;
}
browserCleanupTimeout = null;
}, BROWSER_CLEANUP_TIMEOUT);
}
}

View File

@ -0,0 +1,171 @@
import type { Result as LighthouseResult } from "lighthouse";
import {
AuditResult,
AuditIssue,
LighthouseDetails,
ElementDetails,
ImpactLevel,
AuditCategory,
} from "./types.js";
import { runLighthouseOnExistingTab } 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;
// Extract actionable elements that need fixing
const elements = (details?.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)
);
if (elements.length > 0 || (audit.score || 0) < 1) {
const issue: AuditIssue = {
id: audit.id,
title: audit.title,
description: audit.description,
score: audit.score || 0,
details: detailed ? details : { type: details.type },
category: categoryName,
wcagReference: ref.relevantAudits || [],
impact:
((details?.items?.[0] as Record<string, unknown>)
?.impact as string) || ImpactLevel.MODERATE,
elements: detailed ? elements : elements.slice(0, 3),
failureSummary:
((details?.items?.[0] as Record<string, unknown>)
?.failureSummary as string) ||
audit.explanation ||
"No failure summary available",
recommendations: [],
};
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: {
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,
},
}),
};
}
/**
* 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
*/
export async function runAccessibilityAudit(
url: string,
limit: number = 5,
detailed: boolean = false
): Promise<Partial<AuditResult>> {
try {
// Run Lighthouse audit with accessibility category
const lhr = await runLighthouseOnExistingTab(url, [
AuditCategory.ACCESSIBILITY,
]);
// Extract and process accessibility issues
return extractAccessibilityIssues(lhr, limit, detailed);
} catch (error) {
throw new Error(
`Accessibility audit failed: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}

View File

@ -0,0 +1,125 @@
import lighthouse from "lighthouse";
import type { Result as LighthouseResult, Flags } from "lighthouse";
import {
connectToHeadlessBrowser,
scheduleBrowserCleanup,
} from "../browser-utils.js";
import { LighthouseConfig, AuditCategory } 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;
}
/**
* 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}`);
if (!url) {
console.error("URL is required for Lighthouse audit");
throw new Error("URL is required for Lighthouse audit");
}
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
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}`);
// Create Lighthouse config
const { flags, config } = createLighthouseConfig(categories);
flags.port = port;
console.log(`Running Lighthouse with categories: ${categories.join(", ")}`);
const runnerResult = await lighthouse(url, flags as Flags, config);
console.log("Lighthouse audit completed");
if (!runnerResult?.lhr) {
console.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 (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";
export * from "./types.js";

View File

@ -0,0 +1,228 @@
import type { Result as LighthouseResult } from "lighthouse";
import {
AuditResult,
AuditIssue,
LighthouseDetails,
ElementDetails,
ImpactLevel,
AuditCategory,
} from "./types.js";
import { runLighthouseOnExistingTab } 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> {
console.log("Processing performance audit results");
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)
);
console.log(`Found ${failedAudits.length} performance issues`);
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;
}
// Extract actionable elements that need fixing
const elements = (details.items || []).map(
(item: Record<string, unknown>) => {
try {
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) {
const issue: AuditIssue = {
id: audit.id,
title: audit.title,
description: audit.description,
score: audit.score || 0,
details: detailed ? details : { type: details.type || "unknown" },
category: categoryName,
wcagReference: ref.relevantAudits || [],
impact: getPerformanceImpact(audit.score || 0),
elements: detailed ? elements : elements.slice(0, 3),
failureSummary:
((details?.items?.[0] as Record<string, unknown>)
?.failureSummary as string) ||
audit.explanation ||
"No failure summary available",
recommendations: [],
};
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);
console.log(`Returning ${limitedIssues.length} performance issues`);
return {
score: categoryScores.performance || 0,
categoryScores,
issues: limitedIssues,
...(detailed && {
auditMetadata: {
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,
},
}),
};
}
/**
* 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;
}
/**
* 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
*/
export async function runPerformanceAudit(
url: string,
limit: number = 5,
detailed: boolean = false
): Promise<Partial<AuditResult>> {
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;
} catch (error) {
throw new Error(
`Performance audit failed: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}

View File

@ -0,0 +1,119 @@
/**
* Types for Lighthouse audit results and related data structures
*/
/**
* 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;
}
/**
* Represents a single audit issue found during an audit
*/
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;
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[];
};
}
/**
* Configuration options for Lighthouse audits
*/
export interface LighthouseConfig {
flags: {
output: string[];
onlyCategories: string[];
formFactor: string;
port: number | undefined;
screenEmulation: {
mobile: boolean;
width: number;
height: number;
deviceScaleFactor: number;
disabled: boolean;
};
};
config: {
extends: string;
settings: {
onlyCategories: string[];
emulatedFormFactor: string;
throttling: {
cpuSlowdownMultiplier: number;
};
};
};
}
/**
* 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",
}

View File

@ -2,6 +2,7 @@
"name": "@agentdeskai/browser-tools-server",
"version": "1.0.5",
"description": "A browser tools server for capturing and managing browser events, logs, and screenshots",
"type": "module",
"main": "dist/browser-connector.js",
"bin": {
"browser-tools-server": "./dist/browser-connector.js"
@ -27,7 +28,10 @@
"body-parser": "^1.20.3",
"cors": "^2.8.5",
"express": "^4.21.2",
"lighthouse": "^11.6.0",
"llm-cost": "^1.0.5",
"node-fetch": "^2.7.0",
"puppeteer-core": "^22.4.1",
"ws": "^8.18.0"
},
"devDependencies": {
@ -36,6 +40,8 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.1",
"@types/node-fetch": "^2.6.11",
"@types/puppeteer-core": "^7.0.4",
"typescript": "^5.7.3"
}
}

View File

@ -313,9 +313,24 @@ function wipeLogs() {
}
// Listen for page refreshes
chrome.devtools.network.onNavigated.addListener(() => {
chrome.devtools.network.onNavigated.addListener((url) => {
console.log("Page navigated/refreshed - wiping logs");
wipeLogs();
// Send the new URL to the server
if (ws && ws.readyState === WebSocket.OPEN && url) {
console.log(
"Chrome Extension: Sending page-navigated event with URL:",
url
);
ws.send(
JSON.stringify({
type: "page-navigated",
url: url,
timestamp: Date.now(),
})
);
}
});
// 1) Listen for network requests
@ -567,6 +582,38 @@ function setupWebSocket() {
ws.send(JSON.stringify(response));
});
} else if (message.type === "get-current-url") {
console.log("Chrome Extension: Received request for current URL");
// Get the current URL from the inspected window
chrome.devtools.inspectedWindow.eval(
"window.location.href",
(result, isException) => {
if (isException) {
console.error(
"Chrome Extension: Error getting URL:",
isException
);
ws.send(
JSON.stringify({
type: "current-url-response",
url: null,
error: "Failed to get URL",
requestId: message.requestId,
})
);
return;
}
console.log("Chrome Extension: Current URL:", result);
ws.send(
JSON.stringify({
type: "current-url-response",
url: result,
requestId: message.requestId,
})
);
}
);
}
} catch (error) {
console.error(