From ff5abc5009d9764d2d0512bef6a29f31208eb653 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Wed, 7 May 2025 15:43:29 +0400 Subject: [PATCH 01/13] ability to read HTML using a selector --- browser-tools-mcp/README.md | 11 +- browser-tools-mcp/mcp-server.ts | 55 ++++++++++ browser-tools-mcp/package-lock.json | 8 +- browser-tools-mcp/package.json | 7 +- browser-tools-server/browser-connector.ts | 118 +++++++++++++++++++++- browser-tools-server/package-lock.json | 4 +- browser-tools-server/package.json | 5 +- chrome-extension/background.js | 32 ++++++ chrome-extension/devtools.js | 55 ++++++++++ chrome-extension/manifest.json | 2 +- 10 files changed, 282 insertions(+), 15 deletions(-) diff --git a/browser-tools-mcp/README.md b/browser-tools-mcp/README.md index 059ec32..2aad203 100644 --- a/browser-tools-mcp/README.md +++ b/browser-tools-mcp/README.md @@ -5,6 +5,7 @@ A Model Context Protocol (MCP) server that provides AI-powered browser tools int ## Features - MCP protocol implementation +- Get HTML of all matches to a CSS selector (new) - Browser console log access - Network request analysis - Screenshot capture capabilities @@ -21,13 +22,13 @@ A Model Context Protocol (MCP) server that provides AI-powered browser tools int ## Installation ```bash -npx @agentdeskai/browser-tools-mcp +npx @munawwar-forks/browser-tools-mcp ``` Or install globally: ```bash -npm install -g @agentdeskai/browser-tools-mcp +npm install -g @munawwar-forks/browser-tools-mcp ``` ## Usage @@ -35,17 +36,18 @@ npm install -g @agentdeskai/browser-tools-mcp 1. First, make sure the Browser Tools Server is running: ```bash -npx @agentdeskai/browser-tools-server +npx @munawwar-forks/browser-tools-server ``` 2. Then start the MCP server: ```bash -npx @agentdeskai/browser-tools-mcp +npx @munawwar-forks/browser-tools-mcp ``` 3. The MCP server will connect to the Browser Tools Server and provide the following capabilities: +- Get HTML by selector - Console log retrieval - Network request monitoring - Screenshot capture @@ -57,6 +59,7 @@ npx @agentdeskai/browser-tools-mcp The server provides the following MCP functions: +- `mcp_getHtmlBySelector` - Retrieve HTML by CSS selector - `mcp_getConsoleLogs` - Retrieve browser console logs - `mcp_getConsoleErrors` - Get browser console errors - `mcp_getNetworkErrors` - Get network error logs diff --git a/browser-tools-mcp/mcp-server.ts b/browser-tools-mcp/mcp-server.ts index a7a1272..24b39ae 100644 --- a/browser-tools-mcp/mcp-server.ts +++ b/browser-tools-mcp/mcp-server.ts @@ -4,6 +4,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import path from "path"; import fs from "fs"; +// Using zod from the @modelcontextprotocol/sdk dependencies +// This avoids adding zod as a direct dependency to package.json +import { z } from "zod"; // Create the MCP server const server = new McpServer({ @@ -319,6 +322,58 @@ server.tool( } ); +server.tool( + "getHtmlBySelector", + "Get HTML elements matching a CSS selector", + { selector: z.string().describe("CSS selector to find elements (e.g., '.classname', '#id', 'div.container > p')") }, + async ({ selector }) => { + return await withServerConnection(async () => { + try { + // Call the browser-connector endpoint + const response = await fetch( + `http://${discoveredHost}:${discoveredPort}/get-html-by-selector`, + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ selector }) + } + ); + + const result = await response.json().catch(() => null); + if (result?.error) { + throw new Error(result.error); + } + if (!response.ok || result === null) { + throw new Error(`Server returned error: ${response.status}`); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(result.html || [], null, 2) + } + ] + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Error getting HTML by selector:", errorMessage); + return { + content: [ + { + type: "text", + text: `Failed to get HTML by selector: ${errorMessage}` + } + ], + isError: true + }; + } + }); + } +); + server.tool("wipeLogs", "Wipe all browser logs from memory", async () => { return await withServerConnection(async () => { const response = await fetch( diff --git a/browser-tools-mcp/package-lock.json b/browser-tools-mcp/package-lock.json index 0784e8f..5fc0476 100644 --- a/browser-tools-mcp/package-lock.json +++ b/browser-tools-mcp/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@agentdeskai/browser-tools-mcp", - "version": "1.1.0", + "name": "@munawwar-forks/browser-tools-mcp", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@agentdeskai/browser-tools-mcp", - "version": "1.1.0", + "name": "@munawwar-forks/browser-tools-mcp", + "version": "1.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", diff --git a/browser-tools-mcp/package.json b/browser-tools-mcp/package.json index 3d6e447..f2903e1 100644 --- a/browser-tools-mcp/package.json +++ b/browser-tools-mcp/package.json @@ -1,14 +1,17 @@ { - "name": "@agentdeskai/browser-tools-mcp", + "name": "@munawwar-forks/browser-tools-mcp", "version": "1.2.0", "description": "MCP (Model Context Protocol) server for browser tools integration", "main": "dist/mcp-server.js", "bin": { "browser-tools-mcp": "dist/mcp-server.js" }, + "publishConfig": { + "access": "public" + }, "scripts": { "inspect": "tsc && npx @modelcontextprotocol/inspector node -- dist/mcp-server.js", - "inspect-live": "npx @modelcontextprotocol/inspector npx -- @agentdeskai/browser-tools-mcp", + "inspect-live": "npx @modelcontextprotocol/inspector npx -- @munawwar-forks/browser-tools-mcp", "build": "tsc", "start": "tsc && node dist/mcp-server.js", "prepublishOnly": "npm run build", diff --git a/browser-tools-server/browser-connector.ts b/browser-tools-server/browser-connector.ts index a4cc03c..00e7086 100644 --- a/browser-tools-server/browser-connector.ts +++ b/browser-tools-server/browser-connector.ts @@ -181,6 +181,14 @@ interface ScreenshotCallback { const screenshotCallbacks = new Map(); +// Add new state for tracking selector requests +interface SelectorCallback { + resolve: (value: string[]) => void; + reject: (reason: Error) => void; +} + +const selectorCallbacks = new Map(); + // Function to get available port starting with the given port async function getAvailablePort( startPort: number, @@ -632,6 +640,18 @@ export class BrowserConnector { } ); + // Register the get-html-by-selector endpoint + this.app.post( + "/get-html-by-selector", + async (req: express.Request, res: express.Response) => { + console.log( + "Browser Connector: Received request to /get-html-by-selector endpoint" + ); + console.log("Browser Connector: Request body:", req.body); + await this.getHtmlBySelector(req, res); + } + ); + // Set up accessibility audit endpoint this.setupAccessibilityAudit(); @@ -741,7 +761,28 @@ export class BrowserConnector { ); screenshotCallbacks.clear(); // Clear all callbacks } - } else { + } + // Handle selector response + if (data.type === "html-by-selector" && data.requestId) { + console.log("Received HTML by selector response"); + const callback = selectorCallbacks.get(data.requestId); + if (callback) { + callback.resolve(data.html || []); + selectorCallbacks.delete(data.requestId); + } else { + console.log("No callback found for selector request:", data.requestId); + } + } + // Handle selector error + else if (data.type === "selector-error" && data.requestId) { + console.log("Received selector error:", data.error); + const callback = selectorCallbacks.get(data.requestId); + if (callback) { + callback.reject(new Error(data.error || "Failed to get HTML by selector")); + selectorCallbacks.delete(data.requestId); + } + } + else { console.log("Unhandled message type:", data.type); } } catch (error) { @@ -1401,6 +1442,81 @@ export class BrowserConnector { } }); } + + // Add method to handle selector requests + private async getHtmlBySelector(req: express.Request, res: express.Response) { + if (!this.activeConnection) { + return res.status(503).json({ error: "Chrome extension not connected" }); + } + + const { selector } = req.body; + if (!selector) { + return res.status(400).json({ error: "No selector provided" }); + } + + try { + const requestId = Date.now().toString(); + console.log("Browser Connector: Generated requestId for selector request:", requestId); + + // Create promise that will resolve when we get the HTML data + const selectorPromise = new Promise((resolve, reject) => { + console.log( + `Browser Connector: Setting up selector callback for requestId: ${requestId}` + ); + // Store callback in map + selectorCallbacks.set(requestId, { resolve, reject }); + + // Set timeout to clean up if we don't get a response + setTimeout(() => { + if (selectorCallbacks.has(requestId)) { + console.log( + `Browser Connector: Selector request timed out for requestId: ${requestId}` + ); + selectorCallbacks.delete(requestId); + reject(new Error("Selector request timed out - no response from Chrome extension")); + } + }, 10000); // 10 second timeout + }); + + // Send selector request to extension + const message = JSON.stringify({ + type: "get-html-by-selector", + selector, + requestId, + }); + console.log( + `Browser Connector: Sending WebSocket message to extension:`, + message + ); + this.activeConnection.send(message); + + // Wait for HTML data + console.log("Browser Connector: Waiting for selector response..."); + const html = await selectorPromise; + console.log("Browser Connector: Received HTML response"); + + // If we got an empty array, add a helpful message + if (Array.isArray(html) && html.length === 0) { + console.log(`Browser Connector: No elements found for selector: ${selector}`); + return res.json({ + html: [], + message: `No elements found matching selector: ${selector}` + }); + } + + res.json({ html }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Browser Connector: Error getting HTML by selector:", errorMessage); + if (errorMessage.includes("Invalid selector")) { + return res.status(400).json({ error: errorMessage }); + } else if (errorMessage.includes("timed out")) { + return res.status(504).json({ error: errorMessage }); + } else { + return res.status(500).json({ error: errorMessage }); + } + } + } } // Use an async IIFE to allow for async/await in the initial setup diff --git a/browser-tools-server/package-lock.json b/browser-tools-server/package-lock.json index c38a600..966f355 100644 --- a/browser-tools-server/package-lock.json +++ b/browser-tools-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentdeskai/browser-tools-server", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentdeskai/browser-tools-server", - "version": "1.1.1", + "version": "1.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", diff --git a/browser-tools-server/package.json b/browser-tools-server/package.json index f7c5442..dfb4648 100644 --- a/browser-tools-server/package.json +++ b/browser-tools-server/package.json @@ -1,5 +1,5 @@ { - "name": "@agentdeskai/browser-tools-server", + "name": "@munawwar-forks/browser-tools-server", "version": "1.2.0", "description": "A browser tools server for capturing and managing browser events, logs, and screenshots", "type": "module", @@ -7,6 +7,9 @@ "bin": { "browser-tools-server": "./dist/browser-connector.js" }, + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsc", "start": "tsc && node dist/browser-connector.js", diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 45bc4d7..7320fce 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -68,6 +68,38 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); return true; // Required to use sendResponse asynchronously } + + if (message.type === "GET_HTML_BY_SELECTOR" && message.tabId && message.selector) { + chrome.scripting.executeScript( + { + target: { tabId: message.tabId }, + func: (selector) => { + try { + return Array.from(document.querySelectorAll(selector)).map(el => el.outerHTML); + } catch (error) { + // Handle invalid selector syntax + return { error: `Invalid selector: ${error.message}` }; + } + }, + args: [message.selector], + }, + (results) => { + if (chrome.runtime.lastError) { + sendResponse({ success: false, error: chrome.runtime.lastError.message }); + } else { + const result = results[0]?.result; + // Check if the result is an error object + if (result && result.error) { + sendResponse({ success: false, error: result.error }); + } else { + // results[0].result is the array of HTML strings + sendResponse({ success: true, html: result || [] }); + } + } + } + ); + return true; // Required for async sendResponse + } }); // Validate server identity diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index 6197f2f..131ef7f 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -1002,6 +1002,61 @@ async function setupWebSocket() { ws.send(JSON.stringify(response)); }); + } else if (message.type === "get-html-by-selector") { + console.log("Chrome Extension: Getting HTML by selector:", message.selector); + + // Execute script in the inspected window to find elements by selector + chrome.devtools.inspectedWindow.eval( + `(function() { + try { + // Find all elements matching the selector + const elements = document.querySelectorAll('${message.selector.replace(/'/g, "\\'")}'); + + // Convert elements to array of HTML strings + return Array.from(elements).map(el => el.outerHTML); + } catch (error) { + // Handle invalid selector syntax + return { error: "Invalid selector: " + error.message }; + } + })()`, + (result, isException) => { + if (isException || !result) { + console.error("Chrome Extension: Error getting HTML by selector:", isException || "No result"); + ws.send( + JSON.stringify({ + type: "selector-error", + error: isException?.value || "Failed to execute selector query", + requestId: message.requestId, + }) + ); + return; + } + + // Check if result is an error object + if (result && result.error) { + console.error("Chrome Extension: Selector error:", result.error); + ws.send( + JSON.stringify({ + type: "selector-error", + error: result.error, + requestId: message.requestId, + }) + ); + return; + } + + console.log(`Chrome Extension: Found ${result.length} elements matching selector`); + + // Send back the HTML + ws.send( + JSON.stringify({ + type: "html-by-selector", + html: result, + requestId: message.requestId, + }) + ); + } + ); } else if (message.type === "get-current-url") { console.log("Chrome Extension: Received request for current URL"); diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 4b94126..97f1cc2 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,5 +1,5 @@ { - "name": "BrowserTools MCP", + "name": "BrowserTools MCP Fork by Munawwar", "version": "1.2.0", "description": "MCP tool for AI code editors to capture data from a browser such as console logs, network requests, screenshots and more", "manifest_version": 3, From 4393daa36fa7ab0e289d231672752d603ca23e72 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Wed, 7 May 2025 19:38:04 +0400 Subject: [PATCH 02/13] refactor to not just retturn HTML but also applied CSS, like how dev tools would do --- browser-tools-mcp/mcp-server.ts | 22 +- browser-tools-mcp/package-lock.json | 4 +- browser-tools-mcp/package.json | 2 +- browser-tools-server/browser-connector.ts | 98 ++++++--- browser-tools-server/package-lock.json | 9 +- browser-tools-server/package.json | 4 +- chrome-extension/background.js | 32 --- chrome-extension/devtools.js | 235 +++++++++++++++++++--- chrome-extension/manifest.json | 2 +- 9 files changed, 301 insertions(+), 107 deletions(-) diff --git a/browser-tools-mcp/mcp-server.ts b/browser-tools-mcp/mcp-server.ts index 24b39ae..dd5d6a6 100644 --- a/browser-tools-mcp/mcp-server.ts +++ b/browser-tools-mcp/mcp-server.ts @@ -323,21 +323,25 @@ server.tool( ); server.tool( - "getHtmlBySelector", - "Get HTML elements matching a CSS selector", - { selector: z.string().describe("CSS selector to find elements (e.g., '.classname', '#id', 'div.container > p')") }, - async ({ selector }) => { + "inspectElementsBySelector", + "Get HTML elements and their CSS styles matching a CSS selector", + { + selector: z.string().describe("CSS selector to find elements (e.g., '.classname', '#id', 'div.container > p')"), + resultLimit: z.number().optional().default(1).describe("Maximum number of elements to process (default: 1)"), + includeComputedStyles: z.array(z.string()).optional().default([]).describe("Array of specific CSS properties to include in the computed styles output (empty array means no computed styles)") + }, + async ({ selector, resultLimit = 1, includeComputedStyles = [] }) => { return await withServerConnection(async () => { try { // Call the browser-connector endpoint const response = await fetch( - `http://${discoveredHost}:${discoveredPort}/get-html-by-selector`, + `http://${discoveredHost}:${discoveredPort}/inspect-elements-by-selector`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ selector }) + body: JSON.stringify({ selector, resultLimit, includeComputedStyles }) } ); @@ -353,18 +357,18 @@ server.tool( content: [ { type: "text", - text: JSON.stringify(result.html || [], null, 2) + text: JSON.stringify(result.data || {}, null, 2) } ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Error getting HTML by selector:", errorMessage); + console.error("Error inspecting elements by selector:", errorMessage); return { content: [ { type: "text", - text: `Failed to get HTML by selector: ${errorMessage}` + text: `Failed to inspect elements by selector: ${errorMessage}` } ], isError: true diff --git a/browser-tools-mcp/package-lock.json b/browser-tools-mcp/package-lock.json index 5fc0476..5ac00b7 100644 --- a/browser-tools-mcp/package-lock.json +++ b/browser-tools-mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.0", + "version": "1.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", diff --git a/browser-tools-mcp/package.json b/browser-tools-mcp/package.json index f2903e1..989f2aa 100644 --- a/browser-tools-mcp/package.json +++ b/browser-tools-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.0", + "version": "1.2.1", "description": "MCP (Model Context Protocol) server for browser tools integration", "main": "dist/mcp-server.js", "bin": { diff --git a/browser-tools-server/browser-connector.ts b/browser-tools-server/browser-connector.ts index 00e7086..0c8168d 100644 --- a/browser-tools-server/browser-connector.ts +++ b/browser-tools-server/browser-connector.ts @@ -640,15 +640,15 @@ export class BrowserConnector { } ); - // Register the get-html-by-selector endpoint + // Register the inspect-elements-by-selector endpoint this.app.post( - "/get-html-by-selector", + "/inspect-elements-by-selector", async (req: express.Request, res: express.Response) => { console.log( - "Browser Connector: Received request to /get-html-by-selector endpoint" + "Browser Connector: Received request to /inspect-elements-by-selector endpoint" ); console.log("Browser Connector: Request body:", req.body); - await this.getHtmlBySelector(req, res); + await this.inspectElementsBySelector(req, res); } ); @@ -1443,45 +1443,86 @@ export class BrowserConnector { }); } - // Add method to handle selector requests - private async getHtmlBySelector(req: express.Request, res: express.Response) { + // Add method to handle elements with styles requests + private async inspectElementsBySelector(req: express.Request, res: express.Response) { if (!this.activeConnection) { return res.status(503).json({ error: "Chrome extension not connected" }); } - const { selector } = req.body; + const { selector, resultLimit = 1, includeComputedStyles = [] } = req.body; if (!selector) { return res.status(400).json({ error: "No selector provided" }); } try { const requestId = Date.now().toString(); - console.log("Browser Connector: Generated requestId for selector request:", requestId); + console.log("Browser Connector: Generated requestId for elements with styles request:", requestId); - // Create promise that will resolve when we get the HTML data - const selectorPromise = new Promise((resolve, reject) => { + // Create promise that will resolve when we get the elements and styles data + const elementsBySelectorPromise = new Promise((resolve, reject) => { console.log( - `Browser Connector: Setting up selector callback for requestId: ${requestId}` + `Browser Connector: Setting up elements with styles callback for requestId: ${requestId}` ); + + // Store callback in a map + const elementsBySelectorCallbacks = new Map void; + reject: (reason: Error) => void; + }>(); + // Store callback in map - selectorCallbacks.set(requestId, { resolve, reject }); + elementsBySelectorCallbacks.set(requestId, { resolve, reject }); + + // Add a message listener for inspect-elements-by-selector response + const messageHandler = (event: WebSocket.MessageEvent) => { + try { + const response = JSON.parse(event.data as string); + + if (response.type === "inspect-elements-response" && response.requestId === requestId) { + console.log("Browser Connector: Received inspect-elements-by-selector response"); + const callback = elementsBySelectorCallbacks.get(requestId); + if (callback) { + callback.resolve(response.data); + elementsBySelectorCallbacks.delete(requestId); + this.activeConnection?.removeEventListener("message", messageHandler); + } + } + else if (response.type === "inspect-elements-error" && response.requestId === requestId) { + console.error("Browser Connector: inspect-elements-by-selector error:", response.error); + const callback = elementsBySelectorCallbacks.get(requestId); + if (callback) { + callback.reject(new Error(response.error || "Failed to get inspect-elements-by-selector")); + elementsBySelectorCallbacks.delete(requestId); + this.activeConnection?.removeEventListener("message", messageHandler); + } + } + } catch (error) { + console.error("Error processing inspect-elements-by-selector response:", error); + } + }; + + // Add the message listener + this.activeConnection?.addEventListener("message", messageHandler); // Set timeout to clean up if we don't get a response setTimeout(() => { - if (selectorCallbacks.has(requestId)) { + if (elementsBySelectorCallbacks.has(requestId)) { console.log( - `Browser Connector: Selector request timed out for requestId: ${requestId}` + `Browser Connector: inspect-elements-by-selector request timed out for requestId: ${requestId}` ); - selectorCallbacks.delete(requestId); - reject(new Error("Selector request timed out - no response from Chrome extension")); + elementsBySelectorCallbacks.delete(requestId); + this.activeConnection?.removeEventListener("message", messageHandler); + reject(new Error("inspect-elements-by-selector request timed out - no response from Chrome extension")); } }, 10000); // 10 second timeout }); - // Send selector request to extension + // Send request to extension const message = JSON.stringify({ - type: "get-html-by-selector", + type: "inspect-elements-by-selector", selector, + resultLimit, + includeComputedStyles, requestId, }); console.log( @@ -1490,24 +1531,15 @@ export class BrowserConnector { ); this.activeConnection.send(message); - // Wait for HTML data - console.log("Browser Connector: Waiting for selector response..."); - const html = await selectorPromise; - console.log("Browser Connector: Received HTML response"); - - // If we got an empty array, add a helpful message - if (Array.isArray(html) && html.length === 0) { - console.log(`Browser Connector: No elements found for selector: ${selector}`); - return res.json({ - html: [], - message: `No elements found matching selector: ${selector}` - }); - } + // Wait for inspect-elements-by-selector data + console.log("Browser Connector: Waiting for inspect-elements-by-selector response..."); + const elementsBySelector = await elementsBySelectorPromise; + console.log(`Browser Connector: Received inspect-elements-by-selector data with ${elementsBySelector.elements.length} elements`); - res.json({ html }); + res.json({ data: elementsBySelector }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Browser Connector: Error getting HTML by selector:", errorMessage); + console.error("Browser Connector: Error inspecting elements by selector:", errorMessage); if (errorMessage.includes("Invalid selector")) { return res.status(400).json({ error: errorMessage }); } else if (errorMessage.includes("timed out")) { diff --git a/browser-tools-server/package-lock.json b/browser-tools-server/package-lock.json index 966f355..974659c 100644 --- a/browser-tools-server/package-lock.json +++ b/browser-tools-server/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@agentdeskai/browser-tools-server", - "version": "1.2.0", + "name": "@munawwar-forks/browser-tools-server", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@agentdeskai/browser-tools-server", - "version": "1.2.0", + "name": "@munawwar-forks/browser-tools-server", + "version": "1.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", @@ -277,6 +277,7 @@ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", "dev": true, + "license": "MIT", "dependencies": { "@types/connect": "*", "@types/node": "*" diff --git a/browser-tools-server/package.json b/browser-tools-server/package.json index dfb4648..094a85b 100644 --- a/browser-tools-server/package.json +++ b/browser-tools-server/package.json @@ -1,6 +1,6 @@ { "name": "@munawwar-forks/browser-tools-server", - "version": "1.2.0", + "version": "1.2.1", "description": "A browser tools server for capturing and managing browser events, logs, and screenshots", "type": "module", "main": "dist/browser-connector.js", @@ -41,13 +41,13 @@ "chrome-launcher": "^1.1.2" }, "devDependencies": { - "@types/ws": "^8.5.14", "@types/body-parser": "^1.19.5", "@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", + "@types/ws": "^8.5.14", "typescript": "^5.7.3" } } diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 7320fce..45bc4d7 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -68,38 +68,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); return true; // Required to use sendResponse asynchronously } - - if (message.type === "GET_HTML_BY_SELECTOR" && message.tabId && message.selector) { - chrome.scripting.executeScript( - { - target: { tabId: message.tabId }, - func: (selector) => { - try { - return Array.from(document.querySelectorAll(selector)).map(el => el.outerHTML); - } catch (error) { - // Handle invalid selector syntax - return { error: `Invalid selector: ${error.message}` }; - } - }, - args: [message.selector], - }, - (results) => { - if (chrome.runtime.lastError) { - sendResponse({ success: false, error: chrome.runtime.lastError.message }); - } else { - const result = results[0]?.result; - // Check if the result is an error object - if (result && result.error) { - sendResponse({ success: false, error: result.error }); - } else { - // results[0].result is the array of HTML strings - sendResponse({ success: true, html: result || [] }); - } - } - } - ); - return true; // Required for async sendResponse - } }); // Validate server identity diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index 131ef7f..be657e4 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -1002,30 +1002,219 @@ async function setupWebSocket() { ws.send(JSON.stringify(response)); }); - } else if (message.type === "get-html-by-selector") { - console.log("Chrome Extension: Getting HTML by selector:", message.selector); + } else if (message.type === "inspect-elements-by-selector") { + console.log("Chrome Extension: Received request for inspecting elements by selector:", message.selector); + const resultLimit = message.resultLimit || 1; + const includeComputedStyles = message.includeComputedStyles || []; - // Execute script in the inspected window to find elements by selector - chrome.devtools.inspectedWindow.eval( - `(function() { - try { - // Find all elements matching the selector - const elements = document.querySelectorAll('${message.selector.replace(/'/g, "\\'")}'); + // Define the inspection function separately for better readability + const inspectionFunction = function(selector, limit, includeComputedStyles) { + try { + // Find all elements matching the selector + const elements = document.querySelectorAll(selector); + + if (elements.length === 0) { + return { error: "No elements found matching selector" }; + } + + // Calculate specificity of a selector (simplified) + function calculateSpecificity(selector) { + let specificity = 0; + const idCount = (selector.match(/#[\w-]+/g) || []).length; + const classCount = (selector.match(/\.[\w-]+/g) || []).length; + const elementCount = (selector.match(/[a-z][\w-]*/ig) || []).length - + (selector.match(/:[a-z][\w-]*/ig) || []).length; + + return idCount * 100 + classCount * 10 + elementCount; + } + + // FNV-1a hash function implementation + function fnv1a(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); + } + // Convert to base36 string representation for readability and compactness + return (h >>> 0).toString(36); + } + + function getStartTag(element) { + if (!element || !element.outerHTML) { + return ""; + } + + let outerHTML = element.outerHTML; + const tagName = element.tagName.toLowerCase(); + const closingTag = ``; - // Convert elements to array of HTML strings - return Array.from(elements).map(el => el.outerHTML); - } catch (error) { - // Handle invalid selector syntax - return { error: "Invalid selector: " + error.message }; + if (outerHTML.endsWith(closingTag)) { + outerHTML = outerHTML.slice(0, outerHTML.length - closingTag.length); + } + + const startTag = outerHTML.replace(element.innerHTML, ""); + return startTag; } - })()`, + + // Storage for HTML and rule hashes to avoid duplication + const htmlStore = {}; + const ruleStore = {}; + const seenHtml = new Set(); + const seenRules = new Set(); + + // Process a single element to get its HTML and CSS details + function processElement(element, index) { + // Get the HTML and hash it + const html = element.outerHTML; + const htmlHash = fnv1a(html); + + // Store the HTML if we haven't seen it before + let seenHtmlBefore = false; + if (!seenHtml.has(htmlHash)) { + htmlStore[htmlHash] = html; + seenHtml.add(htmlHash); + } else { + seenHtmlBefore = true; + } + + // Get computed styles if requested + let computedStyles; + if (Array.isArray(includeComputedStyles) && includeComputedStyles.length > 0) { + computedStyles = {}; + const styles = window.getComputedStyle(element); + for (const prop of includeComputedStyles) { + if (prop in styles) { + computedStyles[prop] = styles.getPropertyValue(prop); + } + } + } + + // Find matching rules + const matchedRules = []; + + for (let i = 0; i < document.styleSheets.length; i++) { + try { + const sheet = document.styleSheets[i]; + let rules = []; + + try { + rules = sheet.cssRules || sheet.rules || []; + } catch (e) { + // Skip cross-origin stylesheets + continue; + } + + // Determine stylesheet origin + let originType = 'author'; + if (sheet.href && ( + sheet.href.includes('user-agent') || + sheet.href.includes('chrome://') || + !sheet.ownerNode)) { + originType = 'user-agent'; + } + + for (let j = 0; j < rules.length; j++) { + const rule = rules[j]; + + try { + if (rule.selectorText && element.matches(rule.selectorText)) { + matchedRules.push({ + cssText: rule.cssText, + styleSheet: { + href: sheet.href || 'inline', + index: i, + // Include style tag's attributes. This may be needed for CSS-in-JS solutions' + ...(!sheet.href && { tag: getStartTag(sheet.ownerNode) }) + }, + specificity: calculateSpecificity(rule.selectorText), + }); + } + } catch (e) { + // Skip non-standard rules + continue; + } + } + } catch (e) { + // Skip inaccessible stylesheets + continue; + } + } + + // Sort rules by specificity (higher values first) + matchedRules.sort((a, b) => { + if (b.specificity === a.specificity) { + return b.styleSheet.index - a.styleSheet.index; + } + return b.specificity - a.specificity + }); + + // Hash the entire matchedRules array + const rulesJson = JSON.stringify(matchedRules); + const rulesHash = fnv1a(rulesJson); + + // Store the rules if we haven't seen this exact set before + let seenRulesBefore = false; + if (!seenRules.has(rulesHash)) { + ruleStore[rulesHash] = matchedRules; + seenRules.add(rulesHash); + } else { + seenRulesBefore = true; + } + + return { + index: index, + // Only send full HTML if it's the first occurrence + ...(!seenHtmlBefore && { html }), + htmlHash: htmlHash, + // Include minimal dimensional info that isn't in the HTML + dimensions: { + offsetWidth: element.offsetWidth, + offsetHeight: element.offsetHeight, + clientWidth: element.clientWidth, + clientHeight: element.clientHeight + }, + boundingClientRect: element.getBoundingClientRect(), + styles: { + // Only include full rules if it's the first occurrence + ...(!seenRulesBefore && { matchedRules }), + matchedRulesHash: rulesHash, + computedStyles, + } + }; + } + + // Process up to resultLimit elements + const elementsToProcess = Math.min(elements.length, limit); + const results = []; + + for (let i = 0; i < elementsToProcess; i++) { + results.push(processElement(elements[i], i)); + } + + return { + elements: results, + totalCount: elements.length, + processedCount: elementsToProcess, + // selector: selector, + // Include the stores for the hashed content + // htmlStore, + // ruleStore, + }; + } catch (error) { + return { error: "Error processing elements: " + error.message }; + } + }; + + // Execute script in the inspected window with cleaner syntax + chrome.devtools.inspectedWindow.eval( + `(${inspectionFunction.toString()})('${message.selector.replace(/'/g, "\\'")}', ${resultLimit}, ${JSON.stringify(includeComputedStyles)})`, (result, isException) => { if (isException || !result) { - console.error("Chrome Extension: Error getting HTML by selector:", isException || "No result"); + console.error("Chrome Extension: Error inspecting elements by selector:", isException || "No result"); ws.send( JSON.stringify({ - type: "selector-error", - error: isException?.value || "Failed to execute selector query", + type: "inspect-elements-error", + error: isException?.value || "Failed to inspect elements by selector", requestId: message.requestId, }) ); @@ -1034,10 +1223,10 @@ async function setupWebSocket() { // Check if result is an error object if (result && result.error) { - console.error("Chrome Extension: Selector error:", result.error); + console.error("Chrome Extension: Inspect elements by selector error:", result.error); ws.send( JSON.stringify({ - type: "selector-error", + type: "inspect-elements-error", error: result.error, requestId: message.requestId, }) @@ -1045,13 +1234,13 @@ async function setupWebSocket() { return; } - console.log(`Chrome Extension: Found ${result.length} elements matching selector`); + console.log(`Chrome Extension: Found ${result.totalCount} elements, processed ${result.processedCount}`); - // Send back the HTML + // Send back the elements with styles data ws.send( JSON.stringify({ - type: "html-by-selector", - html: result, + type: "inspect-elements-response", + data: result, requestId: message.requestId, }) ); diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 97f1cc2..0363ba1 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,6 +1,6 @@ { "name": "BrowserTools MCP Fork by Munawwar", - "version": "1.2.0", + "version": "1.2.1", "description": "MCP tool for AI code editors to capture data from a browser such as console logs, network requests, screenshots and more", "manifest_version": 3, "devtools_page": "devtools.html", From 35da83bb4e9e6a5508184c325646bfad542600fb Mon Sep 17 00:00:00 2001 From: Munawwar Date: Wed, 7 May 2025 23:54:24 +0400 Subject: [PATCH 03/13] added github repo link --- browser-tools-mcp/package-lock.json | 4 ++-- browser-tools-mcp/package.json | 6 +++++- browser-tools-server/package-lock.json | 4 ++-- browser-tools-server/package.json | 6 +++++- chrome-extension/manifest.json | 3 ++- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/browser-tools-mcp/package-lock.json b/browser-tools-mcp/package-lock.json index 5ac00b7..3f157ab 100644 --- a/browser-tools-mcp/package-lock.json +++ b/browser-tools-mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", diff --git a/browser-tools-mcp/package.json b/browser-tools-mcp/package.json index 989f2aa..e1131ec 100644 --- a/browser-tools-mcp/package.json +++ b/browser-tools-mcp/package.json @@ -1,8 +1,12 @@ { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.1", + "version": "1.2.2", "description": "MCP (Model Context Protocol) server for browser tools integration", "main": "dist/mcp-server.js", + "repository": { + "type": "git", + "url": "https://github.com/Munawwar/browser-tools-mcp" + }, "bin": { "browser-tools-mcp": "dist/mcp-server.js" }, diff --git a/browser-tools-server/package-lock.json b/browser-tools-server/package-lock.json index 974659c..8c27e70 100644 --- a/browser-tools-server/package-lock.json +++ b/browser-tools-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@munawwar-forks/browser-tools-server", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@munawwar-forks/browser-tools-server", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", diff --git a/browser-tools-server/package.json b/browser-tools-server/package.json index 094a85b..7b36f54 100644 --- a/browser-tools-server/package.json +++ b/browser-tools-server/package.json @@ -1,9 +1,13 @@ { "name": "@munawwar-forks/browser-tools-server", - "version": "1.2.1", + "version": "1.2.2", "description": "A browser tools server for capturing and managing browser events, logs, and screenshots", "type": "module", "main": "dist/browser-connector.js", + "repository": { + "type": "git", + "url": "https://github.com/Munawwar/browser-tools-mcp" + }, "bin": { "browser-tools-server": "./dist/browser-connector.js" }, diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 0363ba1..ce592ff 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,8 +1,9 @@ { "name": "BrowserTools MCP Fork by Munawwar", - "version": "1.2.1", + "version": "1.2.2", "description": "MCP tool for AI code editors to capture data from a browser such as console logs, network requests, screenshots and more", "manifest_version": 3, + "homepage_url": "https://github.com/Munawwar/browser-tools-mcp", "devtools_page": "devtools.html", "permissions": [ "activeTab", From 8020cdb9411a3f1731b71cfc373d4ec5786b100c Mon Sep 17 00:00:00 2001 From: Munawwar Date: Thu, 8 May 2025 00:02:27 +0400 Subject: [PATCH 04/13] quick start instructions --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 4e3c283..45c2219 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,20 @@ This application is a powerful browser monitoring and interaction tool that enables AI-powered applications via Anthropic's Model Context Protocol (MCP) to capture and analyze browser data through a Chrome extension. +## Quick Start + +**Step 1**: Download this repo and load the `chrome-extension` directory in developer mode. + +**Step 2**: Run MCP server (configure your IDE with this): +```sh +npx -y @munawwar-forks/browser-tools-mcp@1.2.2 +``` + +**Step 3**: Run browser-connector server in a terminal +```sh +npx -y @munawwar-forks/browser-tools-server@1.2.2 +``` + Read our [docs](https://browsertools.agentdesk.ai/) for the full installation, quickstart and contribution guides. ## Roadmap From 4a7fbd39bb665a56c52b3e56ecdebf669582f834 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Thu, 8 May 2025 09:38:56 +0400 Subject: [PATCH 05/13] readme --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 45c2219..53d18ec 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Coding agents like Cursor can run these audits against the current page seamless | **NextJS Audit** | Injects a prompt used to perform a NextJS audit. | | **Audit Mode** | Runs all auditing tools in a sequence. | | **Debugger Mode** | Runs all debugging tools in a sequence. | +| **Element Inspection** | Inspect HTML elements and their CSS properties using CSS selectors. | --- @@ -181,6 +182,17 @@ Runs all debugging tools in a particular sequence > > - "Enter debugger mode." +#### Inspect Elements By Selector (`inspectElementsBySelector`) + +Finds HTML elements that match a CSS selector and returns their HTML and CSS styles. + +> **Example Queries:** +> +> - "Inspect the body element." +> - "Show me all the button elements." +> - "What CSS styles are applied to the header?" +> - "Inspect the element with class 'container' and show its font properties." + ## Architecture There are three core components all used to capture and analyze browser data: @@ -243,6 +255,7 @@ Once installed and configured, the system allows any compatible MCP client to: - Capture network traffic - Take screenshots - Analyze selected elements +- Inspect elements HTML and CSS using CSS selectors - Wipe logs stored in our MCP server - Run accessibility, performance, SEO, and best practices audits From 986888734d53645525cf2d8858d0f292b4f4946e Mon Sep 17 00:00:00 2001 From: Munawwar Date: Thu, 8 May 2025 15:13:48 +0400 Subject: [PATCH 06/13] fix bounding client rect --- chrome-extension/devtools.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index be657e4..5aa2cf1 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -1160,6 +1160,8 @@ async function setupWebSocket() { } else { seenRulesBefore = true; } + + const rect = element.getBoundingClientRect(); return { index: index, @@ -1173,7 +1175,15 @@ async function setupWebSocket() { clientWidth: element.clientWidth, clientHeight: element.clientHeight }, - boundingClientRect: element.getBoundingClientRect(), + boundingClientRect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left, + bottom: rect.bottom, + }, styles: { // Only include full rules if it's the first occurrence ...(!seenRulesBefore && { matchedRules }), From 15d2971f80981ba7563af9a3bc344c5a095fea85 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Thu, 8 May 2025 15:15:23 +0400 Subject: [PATCH 07/13] avoid specificity collision --- chrome-extension/devtools.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index 5aa2cf1..de43407 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -1025,7 +1025,7 @@ async function setupWebSocket() { const elementCount = (selector.match(/[a-z][\w-]*/ig) || []).length - (selector.match(/:[a-z][\w-]*/ig) || []).length; - return idCount * 100 + classCount * 10 + elementCount; + return idCount * 10000 + classCount * 100 + elementCount; } // FNV-1a hash function implementation From 02a2c4e2f948759332f255f77bdb85287145de1e Mon Sep 17 00:00:00 2001 From: Munawwar Date: Thu, 8 May 2025 14:01:30 +0400 Subject: [PATCH 08/13] experiment with chrome debugger API --- chrome-extension/panel.html | 10 +++ chrome-extension/panel.js | 165 ++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/chrome-extension/panel.html b/chrome-extension/panel.html index 5dd04a9..fd0a23d 100644 --- a/chrome-extension/panel.html +++ b/chrome-extension/panel.html @@ -208,6 +208,16 @@

Advanced Settings

Include Response Headers + +
+

Developer Tools

+
+ +
+ +
diff --git a/chrome-extension/panel.js b/chrome-extension/panel.js index 528df11..d13a812 100644 --- a/chrome-extension/panel.js +++ b/chrome-extension/panel.js @@ -979,3 +979,168 @@ wipeLogsButton.addEventListener("click", () => { }, 2000); }); }); + +// Add direct CSS command test +document.getElementById('test-css-direct').addEventListener('click', async function() { + const resultsDiv = document.getElementById('css-test-results'); + resultsDiv.style.display = 'block'; + resultsDiv.textContent = 'Testing CSS.getMatchedStylesForNode...\n'; + + const tabId = chrome.devtools.inspectedWindow.tabId; + + // Helper function to send debugger commands with Promise + async function sendCommand(method, params = {}) { + return new Promise((resolve, reject) => { + chrome.debugger.sendCommand({ tabId }, method, params, (result) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(result); + } + }); + }); + } + + try { + // Get the currently selected element + const selectedElement = await new Promise((resolve, reject) => { + chrome.devtools.inspectedWindow.eval( + `(function() { + if (!$0) return { error: "No element selected in Elements panel" }; + + return { + tagName: $0.tagName.toLowerCase(), + id: $0.id, + className: $0.className + }; + })()`, + (result, isException) => { + if (isException) { + reject(new Error(isException.value)); + } else if (result.error) { + reject(new Error(result.error)); + } else { + resolve(result); + } + } + ); + }); + + // Create selector for the element + let selector = '.MuiFormControl-root.MuiTextField-root'; + + resultsDiv.textContent += `Element: ${selector}\n\n`; + + // Get matched styles using the direct approach + try { + // Step 1: Get the document root + const rootResult = await sendCommand("DOM.getDocument", { depth: 1 }); + + // Step 2: Enable domains (these may fail if already enabled, that's OK) + try { await sendCommand("DOM.enable"); } catch(e) {} + try { await sendCommand("CSS.enable"); } catch(e) {} + + // Step 3: Get the node ID using querySelector + const nodeResult = await sendCommand("DOM.querySelector", { + nodeId: rootResult.root.nodeId, + selector: selector + }); + + if (!nodeResult || !nodeResult.nodeId) { + throw new Error("Could not find node ID for the selected element"); + } + + // Step 4: Get matched styles + const styles = await sendCommand("CSS.getMatchedStylesForNode", { + nodeId: nodeResult.nodeId + }); + + // Display the full JSON result + resultsDiv.textContent += "Full JSON Response:\n\n"; + resultsDiv.textContent += JSON.stringify(styles, null, 2); + + // Add the helper function at the bottom + resultsDiv.textContent += "\n\n// Helper function for your codebase:\n"; + resultsDiv.textContent += getHelperFunctionCode(); + + } catch (error) { + if (error.message.includes("CSS agent was not enabled")) { + // Try with a different sequence + resultsDiv.textContent += "Retrying with different order...\n"; + + // Try enabling CSS first, then DOM + try { await sendCommand("CSS.enable"); } catch(e) {} + try { await sendCommand("DOM.enable"); } catch(e) {} + + // Get document root again + const rootResult = await sendCommand("DOM.getDocument", { depth: 1 }); + + // Get node ID + const nodeResult = await sendCommand("DOM.querySelector", { + nodeId: rootResult.root.nodeId, + selector: selector + }); + + // Try again to get matched styles + const styles = await sendCommand("CSS.getMatchedStylesForNode", { + nodeId: nodeResult.nodeId + }); + + // Display the full JSON result + resultsDiv.textContent += "Full JSON Response:\n\n"; + resultsDiv.textContent += JSON.stringify(styles, null, 2); + + // Add the helper function at the bottom + resultsDiv.textContent += "\n\n// Helper function for your codebase:\n"; + resultsDiv.textContent += getHelperFunctionCode(); + } else { + resultsDiv.textContent += `Error: ${error.message}\n`; + } + } + + } catch (error) { + resultsDiv.textContent += `${error.message}\nPlease select an element in the Elements panel first.\n`; + } + + function getHelperFunctionCode() { + return ` +async function getMatchedStylesForNode(selector) { + const tabId = chrome.devtools.inspectedWindow.tabId; + + // Helper function to send debugger commands with Promise + const sendCommand = async (method, params = {}) => { + return new Promise((resolve, reject) => { + chrome.debugger.sendCommand({ tabId }, method, params, (result) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(result); + } + }); + }); + }; + + // Step 1: Get the document root + const root = await sendCommand("DOM.getDocument", { depth: 1 }); + + // Step 2: Enable domains (these may fail if already enabled, that's OK) + try { await sendCommand("DOM.enable"); } catch(e) {} + try { await sendCommand("CSS.enable"); } catch(e) {} + + // Step 3: Get the node + const node = await sendCommand("DOM.querySelector", { + nodeId: root.root.nodeId, + selector + }); + + if (!node || !node.nodeId) { + throw new Error("Element not found: " + selector); + } + + // Step 4: Get the matched styles + return await sendCommand("CSS.getMatchedStylesForNode", { + nodeId: node.nodeId + }); +}`; + } +}); From 8298a5b115a0b39c016a86343230b1dba8d17c60 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Sat, 10 May 2025 13:35:42 +0400 Subject: [PATCH 09/13] convert inspectElementsBySelector to use chrome debugging API --- chrome-extension/devtools.js | 675 ++++++++++++++++++++++------------- chrome-extension/panel.html | 10 - chrome-extension/panel.js | 165 --------- 3 files changed, 431 insertions(+), 419 deletions(-) diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index de43407..6186f86 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -520,9 +520,50 @@ async function attachDebugger() { }); } +// Helper function to send debugger commands with Promise +async function sendCommand(method, params = {}) { + return new Promise((resolve, reject) => { + chrome.debugger.sendCommand( + { tabId: currentTabId }, + method, + params, + (result) => { + if (chrome.runtime.lastError) { + const err = new Error(chrome.runtime.lastError.message); + err.originalError = chrome.runtime.lastError; + reject(err); + } else { + resolve(result); + } + } + ); + }); +}; + +// Listen for styleSheetAdded events +let styleSheets = []; + +// Create a stylesheet event listener +const styleSheetEventListener = (source, method, params) => { + // Only process events for our tab + if (source.tabId !== chrome.devtools.inspectedWindow.tabId) { + return; + } + + if (method === "CSS.styleSheetAdded") { + console.log("Chrome Extension: Style sheet added"); + styleSheets.push(params.header); + } else if (method === "CSS.styleSheetRemoved") { + console.log("Chrome Extension: Style sheet removed"); + styleSheets = styleSheets.filter( + (sheet) => sheet.styleSheetId !== params.styleSheetId + ); + } +}; + function performAttach() { console.log("Performing debugger attachment to tab:", currentTabId); - chrome.debugger.attach({ tabId: currentTabId }, "1.3", () => { + chrome.debugger.attach({ tabId: currentTabId }, "1.3", async () => { if (chrome.runtime.lastError) { console.error("Failed to attach debugger:", chrome.runtime.lastError); isDebuggerAttached = false; @@ -532,21 +573,24 @@ function performAttach() { isDebuggerAttached = true; console.log("Debugger successfully attached"); + // Step 2: Enable domains (these may fail if already enabled, that's OK) + try { + await sendCommand("DOM.enable"); + } catch (e) {} + try { + await sendCommand("CSS.enable"); + } catch (e) {} + // Add the event listener when attaching chrome.debugger.onEvent.addListener(consoleMessageListener); + chrome.debugger.onEvent.addListener(styleSheetEventListener); - chrome.debugger.sendCommand( - { tabId: currentTabId }, - "Runtime.enable", - {}, - () => { - if (chrome.runtime.lastError) { - console.error("Failed to enable runtime:", chrome.runtime.lastError); - return; - } - console.log("Runtime API successfully enabled"); - } - ); + try { + await sendCommand("Runtime.enable", {}); + console.log("Runtime API successfully enabled"); + } catch (e) { + console.error("Failed to enable runtime:", e?.originalError); + } }); } @@ -686,6 +730,31 @@ window.addEventListener("unload", () => { } }); +/** + * + * @param {Function|string} func + * @param {(string | number)[]} args + */ +async function windowEval(func, args) { + const stringifiedArgs = args.map((arg) => { + if (typeof arg === "string") { + return JSON.stringify(arg); + } + if (Array.isArray(arg)) { + return `JSON.parse(${JSON.stringify(JSON.stringify(arg))})`; + } + return arg; + }).join(", "); + const funcString = typeof func === "string" ? func : func.toString(); + const evalString = `(${funcString})(${stringifiedArgs})`; + console.log("Evaluating:", evalString); + return new Promise((resolve) => { + chrome.devtools.inspectedWindow.eval(evalString, (resultInner, exceptionInner) => { + resolve([resultInner, exceptionInner]); + }); + }); +} + // Function to capture and send element data function captureAndSendElement() { chrome.devtools.inspectedWindow.eval( @@ -1003,259 +1072,377 @@ async function setupWebSocket() { ws.send(JSON.stringify(response)); }); } else if (message.type === "inspect-elements-by-selector") { - console.log("Chrome Extension: Received request for inspecting elements by selector:", message.selector); + console.log( + "Chrome Extension: Received request for inspecting elements by selector:", + message.selector + ); const resultLimit = message.resultLimit || 1; const includeComputedStyles = message.includeComputedStyles || []; - - // Define the inspection function separately for better readability - const inspectionFunction = function(selector, limit, includeComputedStyles) { - try { - // Find all elements matching the selector + + // Now start the inspection process + try { + // First check if elements exist and get basic info about them using eval + const [elementsInfo, elementsInfoException] = await windowEval(function (selector, resultLimit) { + // DO NOT have any closures in this entire function, because the function is stringified. const elements = document.querySelectorAll(selector); - if (elements.length === 0) { return { error: "No elements found matching selector" }; } - - // Calculate specificity of a selector (simplified) - function calculateSpecificity(selector) { - let specificity = 0; - const idCount = (selector.match(/#[\w-]+/g) || []).length; - const classCount = (selector.match(/\.[\w-]+/g) || []).length; - const elementCount = (selector.match(/[a-z][\w-]*/ig) || []).length - - (selector.match(/:[a-z][\w-]*/ig) || []).length; - - return idCount * 10000 + classCount * 100 + elementCount; - } + + // Return basic info about the elements + return { + count: elements.length, + elements: Array.from(elements).slice(0, resultLimit).map((el, i) => { + const rect = el.getBoundingClientRect(); + return { + index: i, + html: el.outerHTML, + dimensions: { + offsetWidth: el.offsetWidth, + offsetHeight: el.offsetHeight, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight + }, + // Extract individual properties from rect as it is not directly JSON serializable + boundingClientRect: { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + x: rect.x, + y: rect.y + }, + // We'll add unique ID to each element to target it later + uniqueSelector: '[data-temp-id="' + i + '"]' + }; + }) + }; + }.toString(), [message.selector, resultLimit]); - // FNV-1a hash function implementation - function fnv1a(str) { - let h = 0x811c9dc5; - for (let i = 0; i < str.length; i++) { - h ^= str.charCodeAt(i); - h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); - } - // Convert to base36 string representation for readability and compactness - return (h >>> 0).toString(36); - } + if (elementsInfoException || !elementsInfo) { + console.error( + "Chrome Extension: Error finding elements:", + elementsInfoException || "No result" + ); + ws.send( + JSON.stringify({ + type: "inspect-elements-error", + error: + elementsInfoException?.value || + "Failed to find elements by selector", + requestId: message.requestId, + }) + ); + return; + } - function getStartTag(element) { - if (!element || !element.outerHTML) { - return ""; - } + if (elementsInfo.error) { + console.error( + "Chrome Extension: Element selection error:", + elementsInfo.error + ); + ws.send( + JSON.stringify({ + type: "inspect-elements-error", + error: elementsInfo.error, + requestId: message.requestId, + }) + ); + return; + } - let outerHTML = element.outerHTML; - const tagName = element.tagName.toLowerCase(); - const closingTag = ``; - - if (outerHTML.endsWith(closingTag)) { - outerHTML = outerHTML.slice(0, outerHTML.length - closingTag.length); + const results = []; + const ruleStore = {}; + const seenRules = new Set(); + + // Process each element's style rules using CDP + for (const element of elementsInfo.elements) { + try { + // Add temporary attribute to target this specific element + const uniqueAttrName = "data-temp-id"; + const uniqueAttrValue = element.index.toString(); + + // Add the attribute to the element + const [, setAttributeException] = await windowEval(function (selector, index, uniqueAttrName, uniqueAttrValue) { + document.querySelectorAll(selector)[index].setAttribute(uniqueAttrName, uniqueAttrValue) + }, [message.selector, element.index, uniqueAttrName, uniqueAttrValue]); + if (setAttributeException) { + console.error("Error setting temp attribute:", setAttributeException); } - const startTag = outerHTML.replace(element.innerHTML, ""); - return startTag; - } - - // Storage for HTML and rule hashes to avoid duplication - const htmlStore = {}; - const ruleStore = {}; - const seenHtml = new Set(); - const seenRules = new Set(); - - // Process a single element to get its HTML and CSS details - function processElement(element, index) { - // Get the HTML and hash it - const html = element.outerHTML; - const htmlHash = fnv1a(html); - - // Store the HTML if we haven't seen it before - let seenHtmlBefore = false; - if (!seenHtml.has(htmlHash)) { - htmlStore[htmlHash] = html; - seenHtml.add(htmlHash); - } else { - seenHtmlBefore = true; - } - - // Get computed styles if requested - let computedStyles; - if (Array.isArray(includeComputedStyles) && includeComputedStyles.length > 0) { - computedStyles = {}; - const styles = window.getComputedStyle(element); - for (const prop of includeComputedStyles) { - if (prop in styles) { - computedStyles[prop] = styles.getPropertyValue(prop); - } + // Give a small delay for the attribute to be set + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Get matched styles using CDP + try { + // Step 1: Get the document root + const root = await sendCommand("DOM.getDocument", { + depth: 1, + }); + + // Step 2: Get the node using the unique attribute + const node = await sendCommand("DOM.querySelector", { + nodeId: root.root.nodeId, + selector: `[${uniqueAttrName}="${uniqueAttrValue}"]`, + }); + + if (!node || !node.nodeId) { + throw new Error( + "Element not found with temp attribute" + ); } - } - - // Find matching rules - const matchedRules = []; - - for (let i = 0; i < document.styleSheets.length; i++) { - try { - const sheet = document.styleSheets[i]; - let rules = []; - - try { - rules = sheet.cssRules || sheet.rules || []; - } catch (e) { - // Skip cross-origin stylesheets - continue; + + // Step 4: Get the matched styles + const matchedStyles = await sendCommand( + "CSS.getMatchedStylesForNode", + { + nodeId: node.nodeId, } - - // Determine stylesheet origin - let originType = 'author'; - if (sheet.href && ( - sheet.href.includes('user-agent') || - sheet.href.includes('chrome://') || - !sheet.ownerNode)) { - originType = 'user-agent'; + ); + + // Process the matched styles + const matchedRules = []; + + if (matchedStyles && matchedStyles.matchedCSSRules) { + // Process each matched rule + matchedStyles.matchedCSSRules.forEach((match) => { + const rule = match.rule; + + // Get the actual matched selector from the rule's selectorList + const selectorIndex = match.matchingSelectors[0]; + const selectorInfo = + rule.selectorList.selectors[selectorIndex]; + + // Process arrays to only include specified properties + const processedMedia = + rule.media?.map((item) => ({ + text: item.text, + source: item.source, + sourceURL: item.sourceURL, + })) || []; + + const processedLayers = + rule.layers?.map((item) => ({ + text: item.text, + })) || []; + + const processedSupports = + rule.supports + ?.filter((item) => item.active) + .map((item) => ({ text: item.text })) || []; + + const processedScopes = + rule.scopes?.map((item) => ({ + text: item.text, + })) || []; + + const processedContainerQueries = + rule.containerQueries?.map((item) => ({ + text: item.text, + name: item.name, + physicalAxes: item.physicalAxes, + logicalAxes: item.logicalAxes, + queriesScrollState: item.queriesScrollState, + })) || []; + + matchedRules.push({ + origin: rule.origin, // 'user-agent' or 'regular' + cssText: rule.style.cssText, + styleSheet: { + href: rule.styleSheetId || "inline", + // Use the stylesheet list if available, otherwise fallback to a safe default + index: + styleSheets.findIndex( + (sheet) => + sheet.styleSheetId === rule.styleSheetId + ) || 0, + }, + specificity: selectorInfo.specificity, + // additional info + media: + processedMedia.length > 0 + ? processedMedia + : undefined, + layers: + processedLayers.length > 0 + ? processedLayers + : undefined, + supports: + processedSupports.length > 0 + ? processedSupports + : undefined, + scopes: + processedScopes.length > 0 + ? processedScopes + : undefined, + containerQueries: + processedContainerQueries.length > 0 + ? processedContainerQueries + : undefined, + // rulesTypes is an array of enumerations / strings + ruleTypes: + rule.ruleTypes && rule.ruleTypes.length > 0 + ? rule.ruleTypes + : undefined, + }); + }); + } + + // Sort rules by specificity + matchedRules.sort((a, b) => { + // Helper function to compare specificity objects + function compareSpecificity(specA, specB) { + // Sort first by specificity (a, b, c values) + if (specA.a !== specB.a) { + return specB.a - specA.a; + } + if (specA.b !== specB.b) { + return specB.b - specA.b; + } + if (specA.c !== specB.c) { + return specB.c - specA.c; + } + + // Equal specificity + return 0; } - - for (let j = 0; j < rules.length; j++) { - const rule = rules[j]; + + // First compare by specificity + const specCompare = compareSpecificity( + a.specificity, + b.specificity + ); + + // If specificities are equal, sort by stylesheet order + return specCompare !== 0 + ? specCompare + : b.styleSheet.index - a.styleSheet.index; + }); + + // Get computed styles if requested + let computedStyles; + if ( + Array.isArray(includeComputedStyles) && + includeComputedStyles.length > 0 + ) { + const [computedStylesResult, computedStylesException] = await windowEval(function (uniqueAttrName, uniqueAttrValue, includeComputedStyles) { + const el = document.querySelector(`[${uniqueAttrName}="${uniqueAttrValue}"]`); + if (!el) return {}; - try { - if (rule.selectorText && element.matches(rule.selectorText)) { - matchedRules.push({ - cssText: rule.cssText, - styleSheet: { - href: sheet.href || 'inline', - index: i, - // Include style tag's attributes. This may be needed for CSS-in-JS solutions' - ...(!sheet.href && { tag: getStartTag(sheet.ownerNode) }) - }, - specificity: calculateSpecificity(rule.selectorText), - }); - } - } catch (e) { - // Skip non-standard rules - continue; - } + const styles = window.getComputedStyle(el); + return includeComputedStyles.reduce((result, prop) => { + result[prop] = styles.getPropertyValue(prop); + return result; + }, {}); + }, [uniqueAttrName, uniqueAttrValue, includeComputedStyles]); + if (computedStylesException) { + console.error("Error getting computed styles:", computedStylesException); + } else if (Object.keys(computedStylesResult).length > 0) { + computedStyles = computedStylesResult; } - } catch (e) { - // Skip inaccessible stylesheets - continue; } - } - - // Sort rules by specificity (higher values first) - matchedRules.sort((a, b) => { - if (b.specificity === a.specificity) { - return b.styleSheet.index - a.styleSheet.index; + + // Create hash for rules + const fnv1a = (str) => { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h += + (h << 1) + + (h << 4) + + (h << 7) + + (h << 8) + + (h << 24); + } + return (h >>> 0).toString(36); + }; + + // Hash the rules + const rulesJson = JSON.stringify(matchedRules); + const rulesHash = fnv1a(rulesJson); + + // Check if we've seen these rules before + let seenRulesBefore = false; + if (!seenRules.has(rulesHash)) { + ruleStore[rulesHash] = matchedRules; + seenRules.add(rulesHash); + } else { + seenRulesBefore = true; } - return b.specificity - a.specificity - }); - - // Hash the entire matchedRules array - const rulesJson = JSON.stringify(matchedRules); - const rulesHash = fnv1a(rulesJson); - - // Store the rules if we haven't seen this exact set before - let seenRulesBefore = false; - if (!seenRules.has(rulesHash)) { - ruleStore[rulesHash] = matchedRules; - seenRules.add(rulesHash); - } else { - seenRulesBefore = true; - } - const rect = element.getBoundingClientRect(); - - return { - index: index, - // Only send full HTML if it's the first occurrence - ...(!seenHtmlBefore && { html }), - htmlHash: htmlHash, - // Include minimal dimensional info that isn't in the HTML - dimensions: { - offsetWidth: element.offsetWidth, - offsetHeight: element.offsetHeight, - clientWidth: element.clientWidth, - clientHeight: element.clientHeight - }, - boundingClientRect: { - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - top: rect.top, - left: rect.left, - bottom: rect.bottom, - }, - styles: { - // Only include full rules if it's the first occurrence - ...(!seenRulesBefore && { matchedRules }), - matchedRulesHash: rulesHash, - computedStyles, + // Store the element with style info + results.push({ + ...element, + styles: { + // Only include full rules if it's the first occurrence + ...(!seenRulesBefore && { matchedRules }), + matchedRulesHash: rulesHash, + computedStyles, + }, + }); + } catch (error) { + console.error("Error processing element styles:", error); + // Still add the element but with error info + results.push({ + ...element, + styles: { + error: error.message, + }, + }); + } finally { + // Remove the temporary attribute + const [, removeAttributeException] = await windowEval(function (uniqueAttrName, uniqueAttrValue) { + const el = document.querySelector(`[${uniqueAttrName}="${uniqueAttrValue}"]`); + if (el) el.removeAttribute(uniqueAttrName); + }, [uniqueAttrName, uniqueAttrValue]); + if (removeAttributeException) { + console.warn("Error removing temporary attribute:", removeAttributeException); } - }; - } - - // Process up to resultLimit elements - const elementsToProcess = Math.min(elements.length, limit); - const results = []; - - for (let i = 0; i < elementsToProcess; i++) { - results.push(processElement(elements[i], i)); - } - - return { - elements: results, - totalCount: elements.length, - processedCount: elementsToProcess, - // selector: selector, - // Include the stores for the hashed content - // htmlStore, - // ruleStore, - }; - } catch (error) { - return { error: "Error processing elements: " + error.message }; - } - }; - - // Execute script in the inspected window with cleaner syntax - chrome.devtools.inspectedWindow.eval( - `(${inspectionFunction.toString()})('${message.selector.replace(/'/g, "\\'")}', ${resultLimit}, ${JSON.stringify(includeComputedStyles)})`, - (result, isException) => { - if (isException || !result) { - console.error("Chrome Extension: Error inspecting elements by selector:", isException || "No result"); - ws.send( - JSON.stringify({ - type: "inspect-elements-error", - error: isException?.value || "Failed to inspect elements by selector", - requestId: message.requestId, - }) - ); - return; - } - - // Check if result is an error object - if (result && result.error) { - console.error("Chrome Extension: Inspect elements by selector error:", result.error); - ws.send( - JSON.stringify({ - type: "inspect-elements-error", - error: result.error, - requestId: message.requestId, - }) - ); - return; + } + } catch (error) { + console.error("Error in element processing:", error); } - - console.log(`Chrome Extension: Found ${result.totalCount} elements, processed ${result.processedCount}`); - - // Send back the elements with styles data - ws.send( - JSON.stringify({ - type: "inspect-elements-response", - data: result, - requestId: message.requestId, - }) - ); } - ); + + // Final processing - sort by original index + results.sort((a, b) => a.index - b.index); + + // Prepare and send the final result + const finalResult = { + elements: results, + totalCount: elementsInfo.count, + processedCount: results.length, + // Send this only if we need in future. Keep response payload small. + // ruleStore, + }; + + console.log( + `Chrome Extension: Found ${finalResult.totalCount} elements, processed ${finalResult.processedCount}` + ); + + // Send back the elements with styles data + ws.send( + JSON.stringify({ + type: "inspect-elements-response", + data: finalResult, + requestId: message.requestId, + }) + ); + } catch (error) { + console.error( + "Chrome Extension: Error in element inspection process:", + error + ); + ws.send( + JSON.stringify({ + type: "inspect-elements-error", + error: error.message || "Unknown error in inspection process", + requestId: message.requestId, + }) + ); + } } else if (message.type === "get-current-url") { console.log("Chrome Extension: Received request for current URL"); diff --git a/chrome-extension/panel.html b/chrome-extension/panel.html index fd0a23d..5dd04a9 100644 --- a/chrome-extension/panel.html +++ b/chrome-extension/panel.html @@ -208,16 +208,6 @@

Advanced Settings

Include Response Headers - -
-

Developer Tools

-
- -
- -
diff --git a/chrome-extension/panel.js b/chrome-extension/panel.js index d13a812..528df11 100644 --- a/chrome-extension/panel.js +++ b/chrome-extension/panel.js @@ -979,168 +979,3 @@ wipeLogsButton.addEventListener("click", () => { }, 2000); }); }); - -// Add direct CSS command test -document.getElementById('test-css-direct').addEventListener('click', async function() { - const resultsDiv = document.getElementById('css-test-results'); - resultsDiv.style.display = 'block'; - resultsDiv.textContent = 'Testing CSS.getMatchedStylesForNode...\n'; - - const tabId = chrome.devtools.inspectedWindow.tabId; - - // Helper function to send debugger commands with Promise - async function sendCommand(method, params = {}) { - return new Promise((resolve, reject) => { - chrome.debugger.sendCommand({ tabId }, method, params, (result) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else { - resolve(result); - } - }); - }); - } - - try { - // Get the currently selected element - const selectedElement = await new Promise((resolve, reject) => { - chrome.devtools.inspectedWindow.eval( - `(function() { - if (!$0) return { error: "No element selected in Elements panel" }; - - return { - tagName: $0.tagName.toLowerCase(), - id: $0.id, - className: $0.className - }; - })()`, - (result, isException) => { - if (isException) { - reject(new Error(isException.value)); - } else if (result.error) { - reject(new Error(result.error)); - } else { - resolve(result); - } - } - ); - }); - - // Create selector for the element - let selector = '.MuiFormControl-root.MuiTextField-root'; - - resultsDiv.textContent += `Element: ${selector}\n\n`; - - // Get matched styles using the direct approach - try { - // Step 1: Get the document root - const rootResult = await sendCommand("DOM.getDocument", { depth: 1 }); - - // Step 2: Enable domains (these may fail if already enabled, that's OK) - try { await sendCommand("DOM.enable"); } catch(e) {} - try { await sendCommand("CSS.enable"); } catch(e) {} - - // Step 3: Get the node ID using querySelector - const nodeResult = await sendCommand("DOM.querySelector", { - nodeId: rootResult.root.nodeId, - selector: selector - }); - - if (!nodeResult || !nodeResult.nodeId) { - throw new Error("Could not find node ID for the selected element"); - } - - // Step 4: Get matched styles - const styles = await sendCommand("CSS.getMatchedStylesForNode", { - nodeId: nodeResult.nodeId - }); - - // Display the full JSON result - resultsDiv.textContent += "Full JSON Response:\n\n"; - resultsDiv.textContent += JSON.stringify(styles, null, 2); - - // Add the helper function at the bottom - resultsDiv.textContent += "\n\n// Helper function for your codebase:\n"; - resultsDiv.textContent += getHelperFunctionCode(); - - } catch (error) { - if (error.message.includes("CSS agent was not enabled")) { - // Try with a different sequence - resultsDiv.textContent += "Retrying with different order...\n"; - - // Try enabling CSS first, then DOM - try { await sendCommand("CSS.enable"); } catch(e) {} - try { await sendCommand("DOM.enable"); } catch(e) {} - - // Get document root again - const rootResult = await sendCommand("DOM.getDocument", { depth: 1 }); - - // Get node ID - const nodeResult = await sendCommand("DOM.querySelector", { - nodeId: rootResult.root.nodeId, - selector: selector - }); - - // Try again to get matched styles - const styles = await sendCommand("CSS.getMatchedStylesForNode", { - nodeId: nodeResult.nodeId - }); - - // Display the full JSON result - resultsDiv.textContent += "Full JSON Response:\n\n"; - resultsDiv.textContent += JSON.stringify(styles, null, 2); - - // Add the helper function at the bottom - resultsDiv.textContent += "\n\n// Helper function for your codebase:\n"; - resultsDiv.textContent += getHelperFunctionCode(); - } else { - resultsDiv.textContent += `Error: ${error.message}\n`; - } - } - - } catch (error) { - resultsDiv.textContent += `${error.message}\nPlease select an element in the Elements panel first.\n`; - } - - function getHelperFunctionCode() { - return ` -async function getMatchedStylesForNode(selector) { - const tabId = chrome.devtools.inspectedWindow.tabId; - - // Helper function to send debugger commands with Promise - const sendCommand = async (method, params = {}) => { - return new Promise((resolve, reject) => { - chrome.debugger.sendCommand({ tabId }, method, params, (result) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else { - resolve(result); - } - }); - }); - }; - - // Step 1: Get the document root - const root = await sendCommand("DOM.getDocument", { depth: 1 }); - - // Step 2: Enable domains (these may fail if already enabled, that's OK) - try { await sendCommand("DOM.enable"); } catch(e) {} - try { await sendCommand("CSS.enable"); } catch(e) {} - - // Step 3: Get the node - const node = await sendCommand("DOM.querySelector", { - nodeId: root.root.nodeId, - selector - }); - - if (!node || !node.nodeId) { - throw new Error("Element not found: " + selector); - } - - // Step 4: Get the matched styles - return await sendCommand("CSS.getMatchedStylesForNode", { - nodeId: node.nodeId - }); -}`; - } -}); From 7ed2c305abaebb47049256235009b4a7a388f6e5 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Sat, 10 May 2025 18:44:39 +0400 Subject: [PATCH 10/13] improvements --- chrome-extension/devtools.js | 178 +++++++++++++++-------------------- 1 file changed, 78 insertions(+), 100 deletions(-) diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index 6186f86..19f61ab 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -453,8 +453,13 @@ function wipeLogs() { }); } +// Listen for styleSheetAdded events +let styleSheets = []; + // Listen for page refreshes chrome.devtools.network.onNavigated.addListener((url) => { + // Reset the styleSheets array on navigation/refresh + styleSheets = []; console.log("Page navigated/refreshed - wiping logs"); wipeLogs(); @@ -540,13 +545,10 @@ async function sendCommand(method, params = {}) { }); }; -// Listen for styleSheetAdded events -let styleSheets = []; - // Create a stylesheet event listener const styleSheetEventListener = (source, method, params) => { // Only process events for our tab - if (source.tabId !== chrome.devtools.inspectedWindow.tabId) { + if (source.tabId !== currentTabId) { return; } @@ -573,18 +575,19 @@ function performAttach() { isDebuggerAttached = true; console.log("Debugger successfully attached"); - // Step 2: Enable domains (these may fail if already enabled, that's OK) + // Add the event listener when attaching + chrome.debugger.onEvent.addListener(consoleMessageListener); + chrome.debugger.onEvent.addListener(styleSheetEventListener); + + // Enable domains (these may fail if already enabled, that's OK) try { await sendCommand("DOM.enable"); } catch (e) {} + // Enable CSS domain to start receiving stylesheet events try { await sendCommand("CSS.enable"); } catch (e) {} - // Add the event listener when attaching - chrome.debugger.onEvent.addListener(consoleMessageListener); - chrome.debugger.onEvent.addListener(styleSheetEventListener); - try { await sendCommand("Runtime.enable", {}); console.log("Runtime API successfully enabled"); @@ -747,7 +750,6 @@ async function windowEval(func, args) { }).join(", "); const funcString = typeof func === "string" ? func : func.toString(); const evalString = `(${funcString})(${stringifiedArgs})`; - console.log("Evaluating:", evalString); return new Promise((resolve) => { chrome.devtools.inspectedWindow.eval(evalString, (resultInner, exceptionInner) => { resolve([resultInner, exceptionInner]); @@ -1094,8 +1096,10 @@ async function setupWebSocket() { count: elements.length, elements: Array.from(elements).slice(0, resultLimit).map((el, i) => { const rect = el.getBoundingClientRect(); + const randomId = Math.random().toString(36).substring(2); + el.setAttribute(`data-${randomId}`, "1"); return { - index: i, + // index: i, html: el.outerHTML, dimensions: { offsetWidth: el.offsetWidth, @@ -1114,18 +1118,14 @@ async function setupWebSocket() { x: rect.x, y: rect.y }, - // We'll add unique ID to each element to target it later - uniqueSelector: '[data-temp-id="' + i + '"]' + uniqueId: randomId, }; }) }; }.toString(), [message.selector, resultLimit]); if (elementsInfoException || !elementsInfo) { - console.error( - "Chrome Extension: Error finding elements:", - elementsInfoException || "No result" - ); + console.error("Chrome Extension: Error finding elements:", elementsInfoException || "No result"); ws.send( JSON.stringify({ type: "inspect-elements-error", @@ -1139,10 +1139,7 @@ async function setupWebSocket() { } if (elementsInfo.error) { - console.error( - "Chrome Extension: Element selection error:", - elementsInfo.error - ); + console.error("Chrome Extension: Element selection error:", elementsInfo.error); ws.send( JSON.stringify({ type: "inspect-elements-error", @@ -1160,17 +1157,6 @@ async function setupWebSocket() { // Process each element's style rules using CDP for (const element of elementsInfo.elements) { try { - // Add temporary attribute to target this specific element - const uniqueAttrName = "data-temp-id"; - const uniqueAttrValue = element.index.toString(); - - // Add the attribute to the element - const [, setAttributeException] = await windowEval(function (selector, index, uniqueAttrName, uniqueAttrValue) { - document.querySelectorAll(selector)[index].setAttribute(uniqueAttrName, uniqueAttrValue) - }, [message.selector, element.index, uniqueAttrName, uniqueAttrValue]); - if (setAttributeException) { - console.error("Error setting temp attribute:", setAttributeException); - } // Give a small delay for the attribute to be set await new Promise((resolve) => setTimeout(resolve, 10)); @@ -1185,21 +1171,17 @@ async function setupWebSocket() { // Step 2: Get the node using the unique attribute const node = await sendCommand("DOM.querySelector", { nodeId: root.root.nodeId, - selector: `[${uniqueAttrName}="${uniqueAttrValue}"]`, + selector: `[data-${element.uniqueId}="1"]`, }); if (!node || !node.nodeId) { - throw new Error( - "Element not found with temp attribute" - ); + throw new Error("Element not found with temp attribute"); } // Step 4: Get the matched styles const matchedStyles = await sendCommand( "CSS.getMatchedStylesForNode", - { - nodeId: node.nodeId, - } + { nodeId: node.nodeId } ); // Process the matched styles @@ -1207,13 +1189,21 @@ async function setupWebSocket() { if (matchedStyles && matchedStyles.matchedCSSRules) { // Process each matched rule - matchedStyles.matchedCSSRules.forEach((match) => { + for (const match of matchedStyles.matchedCSSRules) { const rule = match.rule; // Get the actual matched selector from the rule's selectorList - const selectorIndex = match.matchingSelectors[0]; - const selectorInfo = - rule.selectorList.selectors[selectorIndex]; + const matchedSelectors = (match?.matchingSelectors || []) + .map((index) => (rule?.selectorList?.selectors[index])) + .filter((v) => v !== undefined) + .map((selector) => ({ + text: selector.text, + specificity: selector.specificity ? [ + selector.specificity.a, + selector.specificity.b, + selector.specificity.c, + ] : undefined, + })); // Process arrays to only include specified properties const processedMedia = @@ -1247,19 +1237,40 @@ async function setupWebSocket() { queriesScrollState: item.queriesScrollState, })) || []; + const styleSheetIndex = styleSheets.findIndex( + (sheet) => sheet.styleSheetId === rule.styleSheetId + ); + let styleSheet = styleSheets[styleSheetIndex]; + let styleSheetSource; + if (styleSheet?.ownerNode) { + const resolvedNode = await sendCommand( + "DOM.resolveNode", + { backendNodeId: styleSheet.ownerNode } + ); + const evaluateResponse = await sendCommand( + "Runtime.callFunctionOn", + { + objectId: resolvedNode.object.objectId, + functionDeclaration: `function () { + const startTagRegex = ${ + /(<([a-zA-Z][^\s\/>]*)(?:\s+[^\s\/>"'=]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*\s*(\/?)\s*>)/.toString() + }; + const startTag = this.outerHTML.match(startTagRegex)?.[0]; + return startTag; + }` + } + ); + styleSheetSource = evaluateResponse.result.value; + } + matchedRules.push({ - origin: rule.origin, // 'user-agent' or 'regular' - cssText: rule.style.cssText, - styleSheet: { - href: rule.styleSheetId || "inline", - // Use the stylesheet list if available, otherwise fallback to a safe default - index: - styleSheets.findIndex( - (sheet) => - sheet.styleSheetId === rule.styleSheetId - ) || 0, - }, - specificity: selectorInfo.specificity, + origin: rule.origin, // 'user-agent', 'regular', 'inspector' or 'injected' + fullSelector: rule.selectorList?.text, + body: rule.style.cssText || (rule.style.cssProperties || []) + .map((property) => `${property.name}: ${property.value}`) + .join("; "), + matchedSelectors: matchedSelectors, + source: styleSheetSource, // additional info media: processedMedia.length > 0 @@ -1287,39 +1298,11 @@ async function setupWebSocket() { ? rule.ruleTypes : undefined, }); - }); + }; } - // Sort rules by specificity - matchedRules.sort((a, b) => { - // Helper function to compare specificity objects - function compareSpecificity(specA, specB) { - // Sort first by specificity (a, b, c values) - if (specA.a !== specB.a) { - return specB.a - specA.a; - } - if (specA.b !== specB.b) { - return specB.b - specA.b; - } - if (specA.c !== specB.c) { - return specB.c - specA.c; - } - - // Equal specificity - return 0; - } - - // First compare by specificity - const specCompare = compareSpecificity( - a.specificity, - b.specificity - ); - - // If specificities are equal, sort by stylesheet order - return specCompare !== 0 - ? specCompare - : b.styleSheet.index - a.styleSheet.index; - }); + // Sort rules by most specific to least specific + matchedRules.reverse(); // Get computed styles if requested let computedStyles; @@ -1327,8 +1310,8 @@ async function setupWebSocket() { Array.isArray(includeComputedStyles) && includeComputedStyles.length > 0 ) { - const [computedStylesResult, computedStylesException] = await windowEval(function (uniqueAttrName, uniqueAttrValue, includeComputedStyles) { - const el = document.querySelector(`[${uniqueAttrName}="${uniqueAttrValue}"]`); + const [computedStylesResult, computedStylesException] = await windowEval(function (uniqueId, includeComputedStyles) { + const el = document.querySelector(`[data-${uniqueId}="1"]`); if (!el) return {}; const styles = window.getComputedStyle(el); @@ -1336,7 +1319,7 @@ async function setupWebSocket() { result[prop] = styles.getPropertyValue(prop); return result; }, {}); - }, [uniqueAttrName, uniqueAttrValue, includeComputedStyles]); + }, [element.uniqueId, includeComputedStyles]); if (computedStylesException) { console.error("Error getting computed styles:", computedStylesException); } else if (Object.keys(computedStylesResult).length > 0) { @@ -1375,6 +1358,7 @@ async function setupWebSocket() { // Store the element with style info results.push({ ...element, + uniqueId: undefined, // remove temporary attribute styles: { // Only include full rules if it's the first occurrence ...(!seenRulesBefore && { matchedRules }), @@ -1393,10 +1377,9 @@ async function setupWebSocket() { }); } finally { // Remove the temporary attribute - const [, removeAttributeException] = await windowEval(function (uniqueAttrName, uniqueAttrValue) { - const el = document.querySelector(`[${uniqueAttrName}="${uniqueAttrValue}"]`); - if (el) el.removeAttribute(uniqueAttrName); - }, [uniqueAttrName, uniqueAttrValue]); + const [, removeAttributeException] = await windowEval(function (uniqueId) { + document.querySelector(`[data-${uniqueId}="1"]`).removeAttribute(`data-${uniqueId}`) + }, element.uniqueId); if (removeAttributeException) { console.warn("Error removing temporary attribute:", removeAttributeException); } @@ -1418,9 +1401,7 @@ async function setupWebSocket() { // ruleStore, }; - console.log( - `Chrome Extension: Found ${finalResult.totalCount} elements, processed ${finalResult.processedCount}` - ); + console.log(`Chrome Extension: Found ${finalResult.totalCount} elements, processed ${finalResult.processedCount}`); // Send back the elements with styles data ws.send( @@ -1431,10 +1412,7 @@ async function setupWebSocket() { }) ); } catch (error) { - console.error( - "Chrome Extension: Error in element inspection process:", - error - ); + console.error("Chrome Extension: Error in element inspection process:", error); ws.send( JSON.stringify({ type: "inspect-elements-error", From 2cfb528e0a724d4f2120ad61d9c293e730b66b01 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Sat, 10 May 2025 19:19:21 +0400 Subject: [PATCH 11/13] support $0 as a selector --- browser-tools-mcp/mcp-server.ts | 2 +- browser-tools-mcp/package-lock.json | 4 ++-- browser-tools-mcp/package.json | 2 +- browser-tools-server/README.md | 8 +++++--- browser-tools-server/package-lock.json | 4 ++-- browser-tools-server/package.json | 2 +- chrome-extension/devtools.js | 7 ++++++- chrome-extension/manifest.json | 2 +- 8 files changed, 19 insertions(+), 12 deletions(-) diff --git a/browser-tools-mcp/mcp-server.ts b/browser-tools-mcp/mcp-server.ts index dd5d6a6..0399cf1 100644 --- a/browser-tools-mcp/mcp-server.ts +++ b/browser-tools-mcp/mcp-server.ts @@ -326,7 +326,7 @@ server.tool( "inspectElementsBySelector", "Get HTML elements and their CSS styles matching a CSS selector", { - selector: z.string().describe("CSS selector to find elements (e.g., '.classname', '#id', 'div.container > p')"), + selector: z.string().describe("CSS selector to find elements (e.g., '.classname', '#id', 'div.container > p'). Use $0 to inspect the current element."), resultLimit: z.number().optional().default(1).describe("Maximum number of elements to process (default: 1)"), includeComputedStyles: z.array(z.string()).optional().default([]).describe("Array of specific CSS properties to include in the computed styles output (empty array means no computed styles)") }, diff --git a/browser-tools-mcp/package-lock.json b/browser-tools-mcp/package-lock.json index 3f157ab..cd30c2f 100644 --- a/browser-tools-mcp/package-lock.json +++ b/browser-tools-mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.2", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.2", + "version": "1.2.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", diff --git a/browser-tools-mcp/package.json b/browser-tools-mcp/package.json index e1131ec..491b0f0 100644 --- a/browser-tools-mcp/package.json +++ b/browser-tools-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@munawwar-forks/browser-tools-mcp", - "version": "1.2.2", + "version": "1.2.3", "description": "MCP (Model Context Protocol) server for browser tools integration", "main": "dist/mcp-server.js", "repository": { diff --git a/browser-tools-server/README.md b/browser-tools-server/README.md index 47b59e5..58e69e2 100644 --- a/browser-tools-server/README.md +++ b/browser-tools-server/README.md @@ -15,13 +15,13 @@ A powerful browser tools server for capturing and managing browser events, logs, ## Installation ```bash -npx @agentdeskai/browser-tools-server +npx @munawwar-forks/browser-tools-server ``` Or install globally: ```bash -npm install -g @agentdeskai/browser-tools-server +npm install -g @munawwar-forks/browser-tools-server ``` ## Usage @@ -29,7 +29,7 @@ npm install -g @agentdeskai/browser-tools-server 1. Start the server: ```bash -npx @agentdeskai/browser-tools-server +npx @munawwar-forks/browser-tools-server ``` 2. The server will start on port 3025 by default @@ -48,6 +48,7 @@ npx @agentdeskai/browser-tools-server - `/accessibility-audit` - Run accessibility audit on current page - `/performance-audit` - Run performance audit on current page - `/seo-audit` - Run SEO audit on current page +- `/inspect-elements-by-selector` - Get HTML and CSS information for elements matching a given CSS selector ## API Documentation @@ -69,6 +70,7 @@ npx @agentdeskai/browser-tools-server - `POST /accessibility-audit` - Run a WCAG-compliant accessibility audit on the current page - `POST /performance-audit` - Run a performance audit on the current page - `POST /seo-audit` - Run a SEO audit on the current page +- `POST /inspect-elements-by-selector` - Returns HTML and CSS information for elements matching a given CSS selector # Audit Functionality diff --git a/browser-tools-server/package-lock.json b/browser-tools-server/package-lock.json index 8c27e70..bb61c2a 100644 --- a/browser-tools-server/package-lock.json +++ b/browser-tools-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@munawwar-forks/browser-tools-server", - "version": "1.2.2", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@munawwar-forks/browser-tools-server", - "version": "1.2.2", + "version": "1.2.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", diff --git a/browser-tools-server/package.json b/browser-tools-server/package.json index 7b36f54..31f09f1 100644 --- a/browser-tools-server/package.json +++ b/browser-tools-server/package.json @@ -1,6 +1,6 @@ { "name": "@munawwar-forks/browser-tools-server", - "version": "1.2.2", + "version": "1.2.3", "description": "A browser tools server for capturing and managing browser events, logs, and screenshots", "type": "module", "main": "dist/browser-connector.js", diff --git a/chrome-extension/devtools.js b/chrome-extension/devtools.js index 19f61ab..af6573e 100644 --- a/chrome-extension/devtools.js +++ b/chrome-extension/devtools.js @@ -1086,7 +1086,12 @@ async function setupWebSocket() { // First check if elements exist and get basic info about them using eval const [elementsInfo, elementsInfoException] = await windowEval(function (selector, resultLimit) { // DO NOT have any closures in this entire function, because the function is stringified. - const elements = document.querySelectorAll(selector); + let elements = []; + if (selector === "$0") { + elements = [$0]; + } else { + elements = document.querySelectorAll(selector); + } if (elements.length === 0) { return { error: "No elements found matching selector" }; } diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index ce592ff..1ce5aad 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,6 +1,6 @@ { "name": "BrowserTools MCP Fork by Munawwar", - "version": "1.2.2", + "version": "1.2.3", "description": "MCP tool for AI code editors to capture data from a browser such as console logs, network requests, screenshots and more", "manifest_version": 3, "homepage_url": "https://github.com/Munawwar/browser-tools-mcp", From 874e8d4b58275075205a46e5606a4e88fe83e489 Mon Sep 17 00:00:00 2001 From: Munawwar Date: Sat, 10 May 2025 19:26:15 +0400 Subject: [PATCH 12/13] update version --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 53d18ec..f703572 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ This application is a powerful browser monitoring and interaction tool that enab **Step 2**: Run MCP server (configure your IDE with this): ```sh -npx -y @munawwar-forks/browser-tools-mcp@1.2.2 +npx -y @munawwar-forks/browser-tools-mcp@1.2.3 ``` **Step 3**: Run browser-connector server in a terminal ```sh -npx -y @munawwar-forks/browser-tools-server@1.2.2 +npx -y @munawwar-forks/browser-tools-server@1.2.3 ``` Read our [docs](https://browsertools.agentdesk.ai/) for the full installation, quickstart and contribution guides. From 61769fc195d58ec21be7d7cb31c3c40c58a1a9ba Mon Sep 17 00:00:00 2001 From: Munawwar Firoz Date: Wed, 28 May 2025 15:05:08 +0400 Subject: [PATCH 13/13] README: Added to update list --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f703572..74e55d0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Check out our project roadmap here: [Github Roadmap / Project Board](https://git ## Updates +v1.2.3 - Added ability for agent to inspect any element's HTML, computed styling and dimensions. + v1.2.0 is out! Here's a quick breakdown of the update: - You can now enable "Allow Auto-Paste into Cursor" within the DevTools panel. Screenshots will be automatically pasted into Cursor (just make sure to focus/click into the Agent input field in Cursor, otherwise it won't work!) - Integrated a suite of SEO, performance, accessibility, and best practice analysis tools via Lighthouse