mirror of
https://github.com/AgentDeskAI/browser-tools-mcp.git
synced 2025-06-27 00:41:26 +00:00
Add Lighthouse Audits via Puppeteer for Accessibility & Performance
This commit is contained in:
parent
3a1d35a256
commit
37269d1e2d
@ -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 {
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
414
browser-tools-server/browser-utils.ts
Normal file
414
browser-tools-server/browser-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
171
browser-tools-server/lighthouse/accessibility.ts
Normal file
171
browser-tools-server/lighthouse/accessibility.ts
Normal 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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
125
browser-tools-server/lighthouse/index.ts
Normal file
125
browser-tools-server/lighthouse/index.ts
Normal 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";
|
228
browser-tools-server/lighthouse/performance.ts
Normal file
228
browser-tools-server/lighthouse/performance.ts
Normal 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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
119
browser-tools-server/lighthouse/types.ts
Normal file
119
browser-tools-server/lighthouse/types.ts
Normal 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",
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user