UNPKG

dev3000

Version:

AI-powered development tools with browser monitoring and MCP server integration

1,055 lines (1,032 loc) 59.3 kB
import { spawn } from "child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { WebSocket } from "ws"; export class CDPMonitor { browser = null; connection = null; debugPort = 9222; eventHandlers = new Map(); profileDir; screenshotDir; logger; debug = false; browserPath; isShuttingDown = false; pendingRequests = 0; networkIdleTimer = null; pluginReactScan = false; cdpUrl = null; lastScreenshotTime = 0; minScreenshotInterval = 1000; // Minimum 1 second between screenshots chromePids = new Set(); // Track all Chrome PIDs for this instance onWindowClosedCallback = null; // Callback for when window is manually closed appServerPort; // Port of the user's app server to monitor mcpServerPort; // Port of dev3000's MCP server to ignore constructor(profileDir, screenshotDir, logger, debug = false, browserPath, pluginReactScan = false, appServerPort, mcpServerPort) { this.profileDir = profileDir; this.screenshotDir = screenshotDir; this.appServerPort = appServerPort; this.mcpServerPort = mcpServerPort; this.logger = logger; this.debug = debug; this.browserPath = browserPath; this.pluginReactScan = pluginReactScan; } debugLog(message) { if (this.debug) { console.log(`[CDP DEBUG] ${message}`); } } /** * Check if a URL should be monitored (i.e., it's from the user's app server, not dev3000's MCP server or external sites) */ shouldMonitorUrl(url) { try { const urlObj = new URL(url); const hostname = urlObj.hostname; const port = urlObj.port || (urlObj.protocol === "https:" ? "443" : "80"); // Only monitor localhost/127.0.0.1 (the user's local dev server) const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0"; if (!isLocalhost) { return false; } // Skip dev3000's MCP server port if (this.mcpServerPort && port === this.mcpServerPort) { return false; } // If we have an app server port specified, only monitor that specific port if (this.appServerPort && port !== this.appServerPort) { return false; } return true; } catch { // If URL parsing fails, skip it (safer to under-monitor than over-monitor) return false; } } async start() { // Launch Chrome with CDP enabled this.debugLog("Starting Chrome launch process"); await this.launchChrome(); this.debugLog("Chrome launch completed"); // Connect to Chrome DevTools Protocol this.debugLog("Starting CDP connection"); await this.connectToCDP(); this.debugLog("CDP connection completed"); // Enable all the CDP domains we need for comprehensive monitoring this.debugLog("Starting CDP domain enablement"); await this.enableCDPDomains(); this.debugLog("CDP domain enablement completed"); // Setup event handlers for comprehensive logging this.debugLog("Setting up CDP event handlers"); this.setupEventHandlers(); this.debugLog("CDP event handlers setup completed"); } getCdpUrl() { return this.cdpUrl; } getChromePids() { return Array.from(this.chromePids); } setOnWindowClosedCallback(callback) { this.onWindowClosedCallback = callback; } async discoverChromePids() { try { const { spawn } = await import("child_process"); // Find all Chrome processes with our profile directory const profileDirEscaped = this.profileDir.replace(/'/g, "'\\''"); const pidsOutput = await new Promise((resolve) => { const proc = spawn("sh", ["-c", `pgrep -f '${profileDirEscaped}'`], { stdio: "pipe" }); let output = ""; proc.stdout?.on("data", (data) => { output += data.toString(); }); proc.on("exit", () => resolve(output.trim())); }); const pids = pidsOutput .split("\n") .filter(Boolean) .map((pid) => parseInt(pid.trim(), 10)) .filter((pid) => !Number.isNaN(pid)); // Add main browser PID if we have it if (this.browser?.pid) { pids.push(this.browser.pid); } // Store unique PIDs for (const pid of pids) { this.chromePids.add(pid); } this.debugLog(`Discovered ${this.chromePids.size} Chrome PIDs for this instance: [${Array.from(this.chromePids).join(", ")}]`); } catch (error) { this.debugLog(`Failed to discover Chrome PIDs: ${error}`); // Fallback to just the main browser PID if we have it if (this.browser?.pid) { this.chromePids.add(this.browser.pid); } } } createLoadingPage() { const loadingDir = join(tmpdir(), "dev3000-loading"); if (!existsSync(loadingDir)) { mkdirSync(loadingDir, { recursive: true }); } const loadingPath = join(loadingDir, "loading.html"); // Read the loading HTML from the source file const currentFile = fileURLToPath(import.meta.url); const currentDir = dirname(currentFile); const loadingHtmlPath = join(currentDir, "src/loading.html"); let loadingHtml; try { loadingHtml = readFileSync(loadingHtmlPath, "utf-8"); } catch (_error) { // Fallback to a simple loading page if file not found loadingHtml = `<!DOCTYPE html> <html> <head><title>dev3000 - Starting...</title></head> <body style="font-family: system-ui; background: #1e1e1e; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0;"> <div style="text-align: center;"> <h1>dev3000</h1> <p>Starting development environment...</p> </div> </body> </html>`; } writeFileSync(loadingPath, loadingHtml); return `file://${loadingPath}`; } setupRuntimeCrashMonitoring() { if (!this.browser) return; // Remove existing launch-phase handlers to avoid duplicates this.browser.removeAllListeners("exit"); this.browser.removeAllListeners("error"); // Monitor for Chrome crashes during runtime this.browser.on("exit", (code, signal) => { if (!this.isShuttingDown) { const crashMsg = `[CRASH] Chrome process exited unexpectedly - Code: ${code}, Signal: ${signal}`; // this.logger("browser", `${crashMsg} `) // [PLAYWRIGHT] tag removed this.logger("browser", `${crashMsg}`); this.debugLog(`Chrome crashed: code=${code}, signal=${signal}`); // Log context for crash correlation this.logger("browser", "[CRASH] Chrome crashed - check recent server/browser logs for correlation"); // Take screenshot if still connected (for crash context) if (this.connection && this.connection.ws.readyState === 1) { this.takeScreenshot("crash"); } } }); this.browser.on("error", (error) => { if (!this.isShuttingDown) { this.logger("browser", `[CHROME] Chrome process error: ${error.message}`); this.debugLog(`Chrome process error during runtime: ${error}`); } }); this.debugLog("Runtime crash monitoring enabled for Chrome process"); } async launchChrome() { return new Promise((resolve, reject) => { // Use custom browser path if provided, otherwise try different Chrome executables based on platform const chromeCommands = this.browserPath ? [this.browserPath] : [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "google-chrome", "chrome", "chromium", "/Applications/Arc.app/Contents/MacOS/Arc", "/Applications/Comet.app/Contents/MacOS/Comet" ]; const browserType = this.browserPath ? "custom browser" : "Chrome"; this.debugLog(`Attempting to launch ${browserType} for CDP monitoring on port ${this.debugPort}`); this.debugLog(`Profile directory: ${this.profileDir}`); if (this.browserPath) { this.debugLog(`Custom browser path: ${this.browserPath}`); } let attemptIndex = 0; const tryNextChrome = () => { if (attemptIndex >= chromeCommands.length) { reject(new Error("Failed to launch Chrome: all browser paths exhausted")); return; } const chromePath = chromeCommands[attemptIndex]; this.debugLog(`Trying Chrome path [${attemptIndex}]: ${chromePath}`); attemptIndex++; this.browser = spawn(chromePath, [ `--remote-debugging-port=${this.debugPort}`, `--user-data-dir=${this.profileDir}`, "--no-first-run", "--no-default-browser-check", "--disable-component-extensions-with-background-pages", "--disable-background-networking", "--disable-sync", "--metrics-recording-only", "--disable-default-apps", "--disable-session-crashed-bubble", "--disable-restore-session-state", this.createLoadingPage() ], { stdio: "pipe", detached: false // Keep it attached so it dies with parent }); if (!this.browser) { this.debugLog(`Failed to spawn Chrome process for path: ${chromePath}`); setTimeout(tryNextChrome, 100); return; } let processExited = false; this.browser.on("error", (error) => { this.debugLog(`Chrome launch error for ${chromePath}: ${error.message}`); if (!this.isShuttingDown && !processExited) { processExited = true; setTimeout(tryNextChrome, 100); } }); this.browser.on("exit", (code, signal) => { if (!this.isShuttingDown && !processExited && code !== 0) { this.debugLog(`Chrome exited early for ${chromePath} with code ${code}, signal ${signal}`); processExited = true; setTimeout(tryNextChrome, 100); } }); this.browser.stderr?.on("data", (data) => { this.debugLog(`Chrome stderr: ${data.toString().trim()}`); }); this.browser.stdout?.on("data", (data) => { this.debugLog(`Chrome stdout: ${data.toString().trim()}`); }); // Poll for Chrome readiness instead of fixed timeout const checkChromeReady = async (attempts = 0) => { const maxAttempts = 30; // 30 attempts = 15 seconds max if (processExited) { return; } if (attempts >= maxAttempts) { this.debugLog(`Chrome readiness check timed out after ${maxAttempts * 500}ms`); processExited = true; setTimeout(tryNextChrome, 100); return; } try { // Try to connect to CDP to verify Chrome is ready const response = await fetch(`http://localhost:${this.debugPort}/json`, { signal: AbortSignal.timeout(500) }); if (response.ok) { this.debugLog(`Chrome successfully started with path: ${chromePath} (after ${attempts * 500}ms)`); // Discover all Chrome PIDs for this instance await this.discoverChromePids(); // Set up runtime crash monitoring after successful launch this.setupRuntimeCrashMonitoring(); resolve(); return; } } catch (_error) { // Chrome not ready yet, retry } setTimeout(() => checkChromeReady(attempts + 1), 500); }; // Start checking after a small delay setTimeout(() => checkChromeReady(), 500); }; tryNextChrome(); }); } async connectToCDP() { this.debugLog(`Attempting to connect to CDP on port ${this.debugPort}`); // Retry connection with exponential backoff let retryCount = 0; const maxRetries = 5; while (retryCount < maxRetries) { try { // Get the WebSocket URL from Chrome's debug endpoint const targetsResponse = await fetch(`http://localhost:${this.debugPort}/json`); const targets = await targetsResponse.json(); // Find the first page target (tab) const pageTarget = targets.find((target) => target.type === "page"); if (!pageTarget) { throw new Error("No page target found in Chrome"); } const wsUrl = pageTarget.webSocketDebuggerUrl; this.cdpUrl = wsUrl; // Store the CDP URL this.debugLog(`Found page target: ${pageTarget.title || "Unknown"} - ${pageTarget.url}`); this.debugLog(`Got CDP WebSocket URL: ${wsUrl}`); return new Promise((resolve, reject) => { this.debugLog(`Creating WebSocket connection to: ${wsUrl}`); const ws = new WebSocket(wsUrl); // Increase max listeners to prevent warnings ws.setMaxListeners(20); ws.on("open", () => { this.debugLog("WebSocket connection opened successfully"); this.connection = { ws, sessionId: null, nextId: 1 }; resolve(); }); ws.on("error", (error) => { this.debugLog(`WebSocket connection error: ${error}`); reject(error); }); ws.on("message", (data) => { try { const message = JSON.parse(data.toString()); this.handleCDPMessage(message); } catch (error) { this.logger("browser", `[CDP] Failed to parse message: ${error}`); } }); ws.on("close", (code, reason) => { this.debugLog(`WebSocket closed with code ${code}, reason: ${reason}`); if (!this.isShuttingDown) { this.logger("browser", `[CDP] Connection lost unexpectedly (code: ${code}, reason: ${reason})`); this.logger("browser", "[CDP] CDP connection lost - check for Chrome crash or server issues"); // Log current Chrome process status if (this.browser && !this.browser.killed) { this.logger("browser", "[CDP] Chrome process still running after CDP disconnect"); } else { this.logger("browser", "[CDP] Chrome process not available after CDP disconnect"); } // If Chrome process is gone or connection loss seems permanent, trigger shutdown // Use a small delay to distinguish between temporary reconnects and permanent failures setTimeout(() => { if (!this.isShuttingDown && this.onWindowClosedCallback) { // Check if Chrome process is still alive if (!this.browser || this.browser.killed || !this.browser.pid) { this.debugLog("Chrome process is dead and CDP connection lost, triggering d3k shutdown"); this.logger("browser", "[CDP] Chrome process terminated, shutting down d3k"); this.onWindowClosedCallback(); } else { // Chrome is alive but CDP connection is lost - this could be recoverable this.debugLog("Chrome process alive but CDP connection lost - attempting recovery"); this.logger("browser", "[CDP] Attempting to recover from connection loss"); // Could add reconnection logic here in the future } } }, 2000); // Wait 2 seconds to see if it's a temporary disconnect } }); // Connection timeout setTimeout(() => { this.debugLog(`WebSocket readyState: ${ws.readyState} (CONNECTING=0, OPEN=1, CLOSING=2, CLOSED=3)`); if (ws.readyState === WebSocket.CONNECTING) { this.debugLog("WebSocket connection timed out, closing"); ws.close(); reject(new Error("CDP connection timeout")); } }, 5000); }); } catch (error) { retryCount++; this.debugLog(`CDP connection attempt ${retryCount} failed: ${error}`); if (retryCount >= maxRetries) { throw new Error(`Failed to connect to CDP after ${maxRetries} attempts: ${error}`); } // Exponential backoff const delay = Math.min(1000 * 2 ** (retryCount - 1), 5000); this.debugLog(`Retrying CDP connection in ${delay}ms`); await new Promise((resolve) => setTimeout(resolve, delay)); } } } async sendCDPCommand(method, params = {}) { if (!this.connection) { throw new Error("No CDP connection available"); } return new Promise((resolve, reject) => { const id = this.connection.nextId++; const command = { id, method, params }; const messageHandler = (data) => { try { const message = JSON.parse(data.toString()); if (message.id === id) { this.connection?.ws.removeListener("message", messageHandler); if (message.error) { reject(new Error(message.error.message)); } else { resolve(message.result); } } } catch (error) { this.connection?.ws.removeListener("message", messageHandler); reject(error); } }; this.connection?.ws.on("message", messageHandler); // Command timeout const timeout = setTimeout(() => { this.connection?.ws.removeListener("message", messageHandler); reject(new Error(`CDP command timeout: ${method}`)); }, 10000); // Clear timeout if command succeeds/fails const originalResolve = resolve; const originalReject = reject; resolve = (value) => { clearTimeout(timeout); originalResolve(value); }; reject = (reason) => { clearTimeout(timeout); originalReject(reason); }; this.connection?.ws.send(JSON.stringify(command)); }); } async enableCDPDomains() { const domains = [ "Runtime", // Console logs, exceptions "Network", // Network requests/responses "Page", // Page events, navigation "DOM", // DOM mutations "Performance", // Performance metrics "Security", // Security events "Log", // Browser console logs "Target" // Target events (window/tab creation/destruction) // Note: Input domain is for dispatching events, not monitoring them - we use JS injection instead ]; for (const domain of domains) { try { this.debugLog(`Enabling CDP domain: ${domain}`); await this.sendCDPCommand(`${domain}.enable`); this.debugLog(`Successfully enabled CDP domain: ${domain}`); if (this.debug) { this.logger("browser", `[CDP] Enabled ${domain} domain`); } } catch (error) { this.debugLog(`Failed to enable CDP domain ${domain}: ${error}`); // Only log CDP errors when debug mode is enabled if (this.debug) { this.logger("browser", `[CDP] Failed to enable ${domain}: ${error}`); } // Continue with other domains instead of throwing } } this.debugLog("Enabling runtime for console and exception capture"); await this.sendCDPCommand("Runtime.enable"); await this.sendCDPCommand("Runtime.setAsyncCallStackDepth", { maxDepth: 32 }); this.debugLog("CDP domains enabled successfully"); } setupEventHandlers() { // Console messages with full context this.onCDPEvent("Runtime.consoleAPICalled", (event) => { const params = event.params; this.debugLog(`Runtime.consoleAPICalled event received: ${params.type}`); const { type, args, stackTrace } = params; // Debug: Log all console messages to see if tracking script is working if (args && args.length > 0) { this.debugLog(`Console message value: ${args[0].value}`); this.debugLog(`Console message full arg: ${JSON.stringify(args[0])}`); } // Debug: Log all console messages to see if tracking script is even running if (args && args.length > 0 && args[0].value?.includes("CDP tracking initialized")) { if (this.debug) { this.logger("browser", `[DEBUG] Interaction tracking script loaded successfully`); } } // Log regular console messages with enhanced context // Handle console formatting: if first arg has %c, skip style string args let formatCount = 0; if (args && args.length > 0 && args[0].type === "string" && args[0].value) { formatCount = (args[0].value.match(/%c/g) || []).length; } const values = (args || []) .map((arg, index) => { // Skip style string arguments (they come after the format string) if (formatCount > 0 && index > 0 && index <= formatCount && arg.type === "string") { return null; // Skip style strings } if (arg.type === "object" && arg.preview) { return JSON.stringify(arg.preview); } // For the first string argument, strip %c formatting directives if (index === 0 && arg.type === "string" && arg.value && formatCount > 0) { return arg.value.replace(/%c/g, ""); } return arg.value || "[object]"; }) .filter((v) => v !== null) // Remove skipped style strings .join(" "); // Simplify console tags - we already have [BROWSER] prefix const typeTag = type === "error" ? "ERROR" : type === "warn" ? "WARNING" : type === "info" ? "INFO" : type === "debug" ? "DEBUG" : "LOG"; let logMsg = `[${typeTag}] ${values}`; // Add stack trace for errors if (stackTrace && (type === "error" || type === "assert")) { logMsg += `\n[STACK] ${stackTrace.callFrames .slice(0, 3) .map((frame) => `${frame.functionName || "anonymous"}@${frame.url}:${frame.lineNumber}`) .join(" -> ")}`; } this.logger("browser", logMsg); }); // Runtime exceptions with full stack traces this.onCDPEvent("Runtime.exceptionThrown", (event) => { this.debugLog("Runtime.exceptionThrown event received"); const params = event.params; const { text, lineNumber, columnNumber, url, stackTrace } = params.exceptionDetails; let errorMsg = `[ERROR] ${text}`; if (url) errorMsg += ` at ${url}:${lineNumber}:${columnNumber}`; if (stackTrace) { errorMsg += `\n[STACK] ${stackTrace.callFrames .slice(0, 5) .map((frame) => `${frame.functionName || "anonymous"}@${frame.url}:${frame.lineNumber}`) .join(" -> ")}`; } this.logger("browser", errorMsg); // Take screenshot immediately on errors (no delay needed) this.takeScreenshot("error"); }); // Browser console logs via Log domain (additional capture method) this.onCDPEvent("Log.entryAdded", (event) => { const params = event.params; const { level, text, url, lineNumber } = params.entry; let logMsg = `[CONSOLE ${(level || "log").toUpperCase()}] ${text}`; if (url && lineNumber) { logMsg += ` at ${url}:${lineNumber}`; } // Only log if it's an error/warning or if we're not already capturing it via Runtime if (level === "error" || level === "warning") { this.logger("browser", logMsg); } }); // Network requests with full details this.onCDPEvent("Network.requestWillBeSent", (event) => { const params = event.params; const { url, method, headers, postData } = params.request; const { type, initiator } = params; // Skip requests to dev3000's MCP server if (!this.shouldMonitorUrl(url)) { return; } let logMsg = `[NETWORK] ${method} ${url}`; if (type) logMsg += ` (${type})`; if (initiator?.type) logMsg += ` initiated by ${initiator.type}`; // Log important headers const importantHeaders = ["content-type", "authorization", "cookie"]; const headerInfo = importantHeaders .filter((h) => headers?.[h]) .map((h) => { const maxLength = h === "authorization" ? 10 : 50; return `${h}: ${headers?.[h]?.slice(0, maxLength) || ""}${(headers?.[h]?.length || 0) > maxLength ? "..." : ""}`; }) .join(", "); if (headerInfo) logMsg += ` [${headerInfo}]`; if (postData) logMsg += ` body: ${postData.slice(0, 100)}${postData.length > 100 ? "..." : ""}`; this.logger("browser", logMsg); }); // Network responses with full details this.onCDPEvent("Network.responseReceived", (event) => { const params = event.params; const { url, status, statusText, mimeType } = params.response; const { type } = params; // Skip responses from dev3000's MCP server if (!this.shouldMonitorUrl(url)) { return; } let logMsg = `[NETWORK] ${status} ${statusText} ${url}`; if (type) logMsg += ` (${type})`; if (mimeType) logMsg += ` [${mimeType}]`; // Add timing info if available const timing = params.response.timing; if (timing) { const totalTime = Math.round(timing.receiveHeadersEnd - timing.requestTime); if (totalTime > 0) logMsg += ` (${totalTime}ms)`; } this.logger("browser", logMsg); }); // Page navigation with full context this.onCDPEvent("Page.frameNavigated", (event) => { const params = event.params; const { frame } = params; if (frame?.parentId) return; // Only log main frame navigation const url = frame?.url || "unknown"; // Skip navigation to dev3000's MCP server if (!this.shouldMonitorUrl(url)) { return; } this.logger("browser", `[NAVIGATION] ${url}`); // Don't take a screenshot here - wait for page load }); // Page load events for better screenshot timing this.onCDPEvent("Page.loadEventFired", async (_event) => { this.logger("browser", "[DOM] Load event fired"); this.takeScreenshot("page-loaded"); // Reinject interaction tracking on page load await this.setupInteractionTracking(); }); this.onCDPEvent("Page.domContentEventFired", async (_event) => { this.logger("browser", "[DOM] DOM content loaded"); // Skip screenshot on DOM content loaded - we'll get one on page-loaded // Reinject interaction tracking on DOM content loaded await this.setupInteractionTracking(); }); // Network activity tracking for better screenshot timing this.onCDPEvent("Network.requestWillBeSent", (_event) => { this.pendingRequests++; if (this.networkIdleTimer) { clearTimeout(this.networkIdleTimer); this.networkIdleTimer = null; } }); this.onCDPEvent("Network.loadingFinished", (_event) => { this.pendingRequests--; this.scheduleNetworkIdleScreenshot(); }); this.onCDPEvent("Network.loadingFailed", (_event) => { this.pendingRequests--; this.scheduleNetworkIdleScreenshot(); }); // DOM mutations for interaction context this.onCDPEvent("DOM.documentUpdated", () => { // Document structure changed - useful for SPA routing this.logger("browser", "[DOM] Document updated"); }); // Note: Input.dispatchMouseEvent and Input.dispatchKeyEvent are for SENDING events, not capturing them // We need to rely on JavaScript injection for user input capture since CDP doesn't have // direct "user input monitoring" events - it's designed for automation, not monitoring // Performance metrics - disabled to reduce log noise // this.onCDPEvent('Performance.metrics', (event) => { // const metrics = event.params.metrics; // const importantMetrics = metrics.filter((m: any) => // ['JSHeapUsedSize', 'JSHeapTotalSize', 'Nodes', 'Documents'].includes(m.name) // ); // // if (importantMetrics.length > 0) { // const metricsStr = importantMetrics // .map((m: any) => `${m.name}:${Math.round(m.value)}`) // .join(' '); // this.logger('browser', `[PERFORMANCE] ${metricsStr}`); // } // }); // Target events - handle window/tab destruction this.onCDPEvent("Target.targetDestroyed", (event) => { const params = event.params; this.debugLog(`Target destroyed: ${params.targetId}`); this.logger("browser", `[TARGET] Window/tab closed: ${params.targetId}`); // If this is our main tab/window being closed, trigger shutdown callback if (this.onWindowClosedCallback && !this.isShuttingDown) { this.debugLog("Chrome window was manually closed, triggering d3k shutdown"); this.logger("browser", "[TARGET] Chrome window manually closed, shutting down d3k"); this.onWindowClosedCallback(); } }); } onCDPEvent(method, handler) { this.eventHandlers.set(method, handler); } handleCDPMessage(message) { if (message.method) { const handler = this.eventHandlers.get(message.method); if (handler) { const event = { method: message.method, params: message.params || {}, timestamp: Date.now(), sessionId: message.sessionId }; handler(event); } } } async navigateToApp(port) { if (!this.connection) { throw new Error("No CDP connection available"); } const navigationStartTime = Date.now(); this.debugLog(`Navigating to http://localhost:${port}`); // Navigate to the app try { const result = await this.sendCDPCommand("Page.navigate", { url: `http://localhost:${port}` }); const navigationTime = Date.now() - navigationStartTime; this.debugLog(`Navigation command sent successfully (${navigationTime}ms)`); this.debugLog(`Navigation result: ${JSON.stringify(result)}`); // Check if navigation was successful if (result.errorText) { this.debugLog(`Navigation error: ${result.errorText}`); this.logger("browser", `[CDP] Navigation failed: ${result.errorText}`); } // Wait for navigation to complete if (result.frameId) { this.debugLog(`Waiting for frame ${result.frameId} to finish loading...`); try { // Enable Page events if not already enabled await this.sendCDPCommand("Page.enable"); // Wait for frameStoppedLoading event await new Promise((resolve) => { const timeout = setTimeout(() => { this.debugLog("Navigation wait timed out after 10s"); resolve(); }, 10000); const handler = (data) => { const message = JSON.parse(data.toString()); if (message.method === "Page.frameStoppedLoading" && message.params.frameId === result.frameId) { clearTimeout(timeout); this.connection?.ws.removeListener("message", handler); this.debugLog(`Frame ${result.frameId} finished loading`); resolve(); } }; this.connection?.ws.on("message", handler); }); } catch (waitError) { this.debugLog(`Error waiting for navigation: ${waitError}`); } } } catch (error) { this.debugLog(`Navigation failed: ${error}`); this.logger("browser", `[CDP] Navigation failed: ${error}`); throw error; } // Take a delayed screenshot to catch dynamic content setTimeout(() => { this.takeScreenshot("navigation-delayed"); }, 2000); // Set up interaction tracking - but be more efficient about it const trackingStartTime = Date.now(); this.debugLog("Setting up interaction tracking"); // Initial setup - this should be enough for most cases await this.setupInteractionTracking(); // Only add one backup setup with a shorter delay (removing redundant 2s delay) setTimeout(async () => { this.debugLog("Running backup interaction tracking setup"); await this.setupInteractionTracking(); }, 500); // Reduced from 1000ms const trackingTime = Date.now() - trackingStartTime; this.debugLog(`Interaction tracking setup completed (${trackingTime}ms)`); // Start polling for interactions from the injected script this.startInteractionPolling(); // Multiple screenshot triggers will ensure we catch the initial page load } async setupInteractionTracking() { try { // First check if tracking is already set up to avoid redundant injections this.debugLog("About to check if tracking is already set up..."); const checkResult = (await this.sendCDPCommand("Runtime.evaluate", { expression: "!!window.__dev3000_cdp_tracking", returnByValue: true })); if (checkResult.result?.value === true) { this.debugLog("Interaction tracking already set up, skipping"); return; } this.debugLog("About to inject tracking script..."); // Full interaction tracking script with element details for replay const trackingScript = ` try { if (!window.__dev3000_cdp_tracking) { window.__dev3000_cdp_tracking = true; ${this.pluginReactScan ? ` // Inject react-scan for React performance monitoring if (!window.__REACT_SCAN_INJECTED__) { const script = document.createElement('script'); script.src = 'https://unpkg.com/react-scan@latest/dist/auto.global.js'; script.onload = () => { console.debug('[DEV3000] react-scan loaded successfully'); window.__REACT_SCAN_INJECTED__ = true; // Optional: Configure react-scan if (window.ReactScan && window.ReactScan.configure) { window.ReactScan.configure({ // Add any configuration options here playSound: false, showToolbar: true }); } }; script.onerror = (err) => { console.debug('[DEV3000] Failed to load react-scan:', err); }; document.head.appendChild(script); } ` : ""} // Helper function to generate CSS selector for element function getElementSelector(el) { if (!el || el === document) return 'document'; // Try ID first (most reliable) if (el.id) return '#' + el.id; // Build path with tag + classes let selector = el.tagName.toLowerCase(); if (el.className && typeof el.className === 'string') { let classes = el.className.trim().split(/\\\\s+/).filter(c => c.length > 0); if (classes.length > 0) selector += '.' + classes.join('.'); } // Add nth-child if needed to make unique if (el.parentNode) { let siblings = Array.from(el.parentNode.children).filter(child => child.tagName === el.tagName && child.className === el.className ); if (siblings.length > 1) { let index = siblings.indexOf(el) + 1; selector += ':nth-child(' + index + ')'; } } return selector; } // Helper to get element details for replay function getElementDetails(el) { let details = { selector: getElementSelector(el), tag: el.tagName.toLowerCase(), text: el.textContent ? el.textContent.trim().substring(0, 50) : '', id: el.id || '', className: el.className || '', name: el.name || '', type: el.type || '', value: el.value || '' }; return JSON.stringify(details); } // Scroll coalescing variables let scrollTimeout = null; let lastScrollX = 0; let lastScrollY = 0; let scrollStartX = 0; let scrollStartY = 0; let scrollTarget = 'document'; // Add click tracking with element details document.addEventListener('click', function(e) { let details = getElementDetails(e.target); // Send interaction data via custom event instead of console.log to avoid user visibility window.dispatchEvent(new CustomEvent('dev3000-interaction', { detail: { type: 'CLICK', x: e.clientX, y: e.clientY, element: details } })); }); // Add key tracking with element details document.addEventListener('keydown', function(e) { let details = getElementDetails(e.target); // Send interaction data via custom event instead of console.log to avoid user visibility window.dispatchEvent(new CustomEvent('dev3000-interaction', { detail: { type: 'KEY', key: e.key, element: details } })); }); // Add coalesced scroll tracking with capture to catch all scroll events document.addEventListener('scroll', function(e) { let target = e.target === document ? 'document' : getElementSelector(e.target); let currentScrollX, currentScrollY; // Get scroll position from the actual scrolling element if (e.target === document) { currentScrollX = window.scrollX; currentScrollY = window.scrollY; } else { currentScrollX = e.target.scrollLeft; currentScrollY = e.target.scrollTop; } // If this is the first scroll event or different target, reset if (scrollTimeout === null || scrollTarget !== target) { scrollStartX = currentScrollX; scrollStartY = currentScrollY; scrollTarget = target; } else { clearTimeout(scrollTimeout); } // Update current position lastScrollX = currentScrollX; lastScrollY = currentScrollY; // Set timeout to log scroll after 300ms of no scrolling (scroll settled) scrollTimeout = setTimeout(function() { // Only log if there was actual movement (threshold of 5 pixels) let deltaX = Math.abs(lastScrollX - scrollStartX); let deltaY = Math.abs(lastScrollY - scrollStartY); if (deltaX > 5 || deltaY > 5) { // Send interaction data via custom event instead of console.log to avoid user visibility window.dispatchEvent(new CustomEvent('dev3000-interaction', { detail: { type: 'SCROLL', from: { x: scrollStartX, y: scrollStartY }, to: { x: lastScrollX, y: lastScrollY }, target: target } })); window.dispatchEvent(new CustomEvent('dev3000-interaction', { detail: { type: 'SCROLL_SETTLED', x: lastScrollX, y: lastScrollY } })); } scrollTimeout = null; }, 300); }, true); // Use capture: true to catch scroll events on all elements // Listen for our custom interaction events and store them for CDP polling window.__dev3000_interactions = []; window.addEventListener('dev3000-interaction', function(e) { const detail = e.detail; let message = ''; switch(detail.type) { case 'CLICK': message = 'CLICK at ' + detail.x + ',' + detail.y + ' on ' + detail.element; break; case 'KEY': message = 'KEY ' + detail.key + ' in ' + detail.element; break; case 'SCROLL': message = 'SCROLL from ' + detail.from.x + ',' + detail.from.y + ' to ' + detail.to.x + ',' + detail.to.y + ' in ' + detail.target; break; case 'SCROLL_SETTLED': message = 'SCROLL_SETTLED at ' + detail.x + ',' + detail.y; break; } if (message) { // Store interaction in array for CDP to poll, don't log to console window.__dev3000_interactions.push({ timestamp: Date.now(), message: message }); // Keep only last 100 interactions to avoid memory issues if (window.__dev3000_interactions.length > 100) { window.__dev3000_interactions = window.__dev3000_interactions.slice(-100); } } }); console.debug('CDP tracking initialized'); } } catch (err) { console.debug('[DEV3000_INTERACTION] ERROR: ' + err.message); } `; this.debugLog("About to inject tracking script..."); // Validate JavaScript syntax before injection try { new Function(trackingScript); this.debugLog("JavaScript syntax validation passed"); } catch (syntaxError) { const errorMessage = syntaxError instanceof Error ? syntaxError.message : String(syntaxError); this.debugLog(`JavaScript syntax error detected: ${errorMessage}`); this.logger("browser", `[CDP] Tracking script syntax error: ${errorMessage}`); throw new Error(`Invalid tracking script syntax: ${errorMessage}`); } const result = await this.sendCDPCommand("Runtime.evaluate", { expression: trackingScript, includeCommandLineAPI: false }); this.debugLog(`Interaction tracking script injected. Result: ${JSON.stringify(result)}`); // Log any errors from the script injection const resultWithDetails = result; if (resultWithDetails.exceptionDetails) { this.debugLog(`Script injection exception: ${JSON.stringify(resultWithDetails.exceptionDetails)}`); this.logger("browser", `[DEBUG] Script injection exception: ${resultWithDetails.exceptionDetails.exception?.description || "Unknown error"}`); } } catch (error) { this.debugLog(`Failed to inject interaction tracking: ${error}`); this.logger("browser", `[CDP] Interaction tracking failed: ${error}`); } } startInteractionPolling() { // Poll for interactions every 500ms to avoid console.log spam const pollInteractions = async () => { if (this.isShuttingDown) return; try { const result = (await this.sendCDPCommand("Runtime.evaluate", { expression: ` (() => { if (window.__dev3000_interactions && window.__dev3000_interactions.length > 0) { const interactions = [...window.__dev3000_interactions]; window.__dev3000_interactions = []; // Clear the array return interactions; } return []; })() `, returnByValue: true })); const interactions = result.result?.valu