mirror of
https://github.com/AgentDeskAI/browser-tools-mcp.git
synced 2025-07-25 18:05:07 +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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { z } from "zod";
|
||||||
// import { z } from "zod";
|
// import { z } from "zod";
|
||||||
// import fs from "fs";
|
// import fs from "fs";
|
||||||
|
|
||||||
@ -12,6 +13,15 @@ const server = new McpServer({
|
|||||||
version: "1.0.9",
|
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 to get the port from the .port file
|
||||||
// function getPort(): number {
|
// function getPort(): number {
|
||||||
// try {
|
// 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
|
// Start receiving messages on stdio
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"llm-cost": "^1.0.5",
|
"llm-cost": "^1.0.5",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -40,6 +41,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.13.1",
|
"@types/node": "^22.13.1",
|
||||||
|
"@types/node-fetch": "^2.6.11",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,14 @@ import express from "express";
|
|||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
import { tokenizeAndEstimateCost } from "llm-cost";
|
import { tokenizeAndEstimateCost } from "llm-cost";
|
||||||
import WebSocket from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { IncomingMessage } from "http";
|
import { IncomingMessage } from "http";
|
||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
|
import { runPerformanceAudit } from "./lighthouse/performance.js";
|
||||||
|
import { runAccessibilityAudit } from "./lighthouse/accessibility.js";
|
||||||
|
|
||||||
// Function to get default downloads folder
|
// Function to get default downloads folder
|
||||||
function getDefaultDownloadsFolder(): string {
|
function getDefaultDownloadsFolder(): string {
|
||||||
@ -26,6 +28,9 @@ const networkErrors: any[] = [];
|
|||||||
const networkSuccess: any[] = [];
|
const networkSuccess: any[] = [];
|
||||||
const allXhr: any[] = [];
|
const allXhr: any[] = [];
|
||||||
|
|
||||||
|
// Store the current URL from the extension
|
||||||
|
let currentUrl: string = "";
|
||||||
|
|
||||||
// Add settings state
|
// Add settings state
|
||||||
let currentSettings = {
|
let currentSettings = {
|
||||||
logLimit: 50,
|
logLimit: 50,
|
||||||
@ -178,6 +183,14 @@ app.post("/extension-log", (req, res) => {
|
|||||||
console.log(`Processing ${data.type} log entry`);
|
console.log(`Processing ${data.type} log entry`);
|
||||||
|
|
||||||
switch (data.type) {
|
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":
|
case "console-log":
|
||||||
console.log("Adding console log:", {
|
console.log("Adding console log:", {
|
||||||
level: data.level,
|
level: data.level,
|
||||||
@ -324,6 +337,26 @@ app.post("/wipelogs", (req, res) => {
|
|||||||
res.json({ status: "ok", message: "All logs cleared successfully" });
|
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 {
|
interface ScreenshotMessage {
|
||||||
type: "screenshot-data" | "screenshot-error";
|
type: "screenshot-data" | "screenshot-error";
|
||||||
data?: string;
|
data?: string;
|
||||||
@ -332,17 +365,18 @@ interface ScreenshotMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BrowserConnector {
|
export class BrowserConnector {
|
||||||
private wss: WebSocket.Server;
|
private wss: WebSocketServer;
|
||||||
private activeConnection: WebSocket | null = null;
|
private activeConnection: WebSocket | null = null;
|
||||||
private app: express.Application;
|
private app: express.Application;
|
||||||
private server: any;
|
private server: any;
|
||||||
|
private urlRequestCallbacks: Map<string, (url: string) => void> = new Map();
|
||||||
|
|
||||||
constructor(app: express.Application, server: any) {
|
constructor(app: express.Application, server: any) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.server = server;
|
this.server = server;
|
||||||
|
|
||||||
// Initialize WebSocket server using the existing HTTP server
|
// Initialize WebSocket server using the existing HTTP server
|
||||||
this.wss = new WebSocket.Server({
|
this.wss = new WebSocketServer({
|
||||||
noServer: true,
|
noServer: true,
|
||||||
path: "/extension-ws",
|
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
|
// Handle upgrade requests for WebSocket
|
||||||
this.server.on(
|
this.server.on(
|
||||||
"upgrade",
|
"upgrade",
|
||||||
(request: IncomingMessage, socket: Socket, head: Buffer) => {
|
(request: IncomingMessage, socket: Socket, head: Buffer) => {
|
||||||
if (request.url === "/extension-ws") {
|
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.emit("connection", ws, request);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.wss.on("connection", (ws) => {
|
this.wss.on("connection", (ws: WebSocket) => {
|
||||||
console.log("Chrome extension connected via WebSocket");
|
console.log("Chrome extension connected via WebSocket");
|
||||||
this.activeConnection = ws;
|
this.activeConnection = ws;
|
||||||
|
|
||||||
@ -388,6 +428,28 @@ export class BrowserConnector {
|
|||||||
data: data.data ? "[base64 data]" : undefined,
|
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
|
// Handle screenshot response
|
||||||
if (data.type === "screenshot-data" && data.data) {
|
if (data.type === "screenshot-data" && data.data) {
|
||||||
console.log("Received screenshot 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
|
// Add new endpoint for programmatic screenshot capture
|
||||||
async captureScreenshot(req: express.Request, res: express.Response) {
|
async captureScreenshot(req: express.Request, res: express.Response) {
|
||||||
console.log("Browser Connector: Starting captureScreenshot method");
|
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
|
// 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",
|
"name": "@agentdeskai/browser-tools-server",
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"description": "A browser tools server for capturing and managing browser events, logs, and screenshots",
|
"description": "A browser tools server for capturing and managing browser events, logs, and screenshots",
|
||||||
|
"type": "module",
|
||||||
"main": "dist/browser-connector.js",
|
"main": "dist/browser-connector.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"browser-tools-server": "./dist/browser-connector.js"
|
"browser-tools-server": "./dist/browser-connector.js"
|
||||||
@ -27,7 +28,10 @@
|
|||||||
"body-parser": "^1.20.3",
|
"body-parser": "^1.20.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"lighthouse": "^11.6.0",
|
||||||
"llm-cost": "^1.0.5",
|
"llm-cost": "^1.0.5",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"puppeteer-core": "^22.4.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -36,6 +40,8 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.13.1",
|
"@types/node": "^22.13.1",
|
||||||
|
"@types/node-fetch": "^2.6.11",
|
||||||
|
"@types/puppeteer-core": "^7.0.4",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -313,9 +313,24 @@ function wipeLogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listen for page refreshes
|
// Listen for page refreshes
|
||||||
chrome.devtools.network.onNavigated.addListener(() => {
|
chrome.devtools.network.onNavigated.addListener((url) => {
|
||||||
console.log("Page navigated/refreshed - wiping logs");
|
console.log("Page navigated/refreshed - wiping logs");
|
||||||
wipeLogs();
|
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
|
// 1) Listen for network requests
|
||||||
@ -567,6 +582,38 @@ function setupWebSocket() {
|
|||||||
|
|
||||||
ws.send(JSON.stringify(response));
|
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) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user