diff --git a/browser-tools-mcp/mcp-server.ts b/browser-tools-mcp/mcp-server.ts index f144685..6a9e2f8 100644 --- a/browser-tools-mcp/mcp-server.ts +++ b/browser-tools-mcp/mcp-server.ts @@ -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 { diff --git a/browser-tools-mcp/package.json b/browser-tools-mcp/package.json index 94c2ad6..3fa7745 100644 --- a/browser-tools-mcp/package.json +++ b/browser-tools-mcp/package.json @@ -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" } } diff --git a/browser-tools-server/browser-connector.ts b/browser-tools-server/browser-connector.ts index e8f04cc..a677d93 100644 --- a/browser-tools-server/browser-connector.ts +++ b/browser-tools-server/browser-connector.ts @@ -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 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 { + 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((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 { + // 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 { + 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 diff --git a/browser-tools-server/browser-utils.ts b/browser-tools-server/browser-utils.ts new file mode 100644 index 0000000..feb04ec --- /dev/null +++ b/browser-tools-server/browser-utils.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/browser-tools-server/lighthouse/accessibility.ts b/browser-tools-server/lighthouse/accessibility.ts new file mode 100644 index 0000000..c762357 --- /dev/null +++ b/browser-tools-server/lighthouse/accessibility.ts @@ -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 { + 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) => + ({ + selector: + ((item.node as Record)?.selector as string) || + (item.selector as string) || + "Unknown selector", + snippet: + ((item.node as Record)?.snippet as string) || + (item.snippet as string) || + "No snippet available", + explanation: + ((item.node as Record) + ?.explanation as string) || + (item.explanation as string) || + "No explanation available", + url: + (item.url as string) || + ((item.node as Record)?.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) + ?.impact as string) || ImpactLevel.MODERATE, + elements: detailed ? elements : elements.slice(0, 3), + failureSummary: + ((details?.items?.[0] as Record) + ?.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> { + 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) + }` + ); + } +} diff --git a/browser-tools-server/lighthouse/index.ts b/browser-tools-server/lighthouse/index.ts new file mode 100644 index 0000000..0839ff0 --- /dev/null +++ b/browser-tools-server/lighthouse/index.ts @@ -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 { + 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"; diff --git a/browser-tools-server/lighthouse/performance.ts b/browser-tools-server/lighthouse/performance.ts new file mode 100644 index 0000000..57660e1 --- /dev/null +++ b/browser-tools-server/lighthouse/performance.ts @@ -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 { + 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) => { + try { + return { + selector: + ((item.node as Record) + ?.selector as string) || + (item.selector as string) || + "Unknown selector", + snippet: + ((item.node as Record) + ?.snippet as string) || + (item.snippet as string) || + "No snippet available", + explanation: + ((item.node as Record) + ?.explanation as string) || + (item.explanation as string) || + "No explanation available", + url: + (item.url as string) || + ((item.node as Record)?.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) + ?.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> { + 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) + }` + ); + } +} diff --git a/browser-tools-server/lighthouse/types.ts b/browser-tools-server/lighthouse/types.ts new file mode 100644 index 0000000..c4b4019 --- /dev/null +++ b/browser-tools-server/lighthouse/types.ts @@ -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>; + 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", +} diff --git a/browser-tools-server/package.json b/browser-tools-server/package.json index 5439710..24e41dd 100644 --- a/browser-tools-server/package.json +++ b/browser-tools-server/package.json @@ -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" } } diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index 8c48d7d..fa98a44 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -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(