UNPKG

ai-debug-local-mcp

Version:

🎯 ENHANCED AI GUIDANCE v4.1.2: Dramatically improved tool descriptions help AI users choose the right tools instead of 'close enough' options. Ultra-fast keyboard automation (10x speed), universal recording, multi-ecosystem debugging support, and compreh

937 lines (789 loc) • 28.4 kB
import { Page, Browser } from 'playwright'; import { DebugState, DOMSnapshot, NetworkRequest } from '../types'; import { LogTruncator } from '../utils/log-truncator'; import { FlutterDebugEngine } from './flutter-debug-engine'; import { ProblemDebugEngine } from './problem-debug-engine'; import { NextJSDebugEngine } from './nextjs-debug-engine'; import { ServerFrameworkDebugEngine } from './server-framework-debug-engine'; import { MetaFrameworkDebugEngine } from './meta-framework-debug-engine'; import { tidewave, TidewaveCapabilities } from '../utils/tidewave-integration'; export class LocalDebugEngine { private browser?: Browser; private page?: Page; private consoleMessages: Array<{ type: string; text: string; timestamp: Date }> = []; private networkRequests: NetworkRequest[] = []; private mockedResponses: Map<string, { status: number; body: any; headers?: Record<string, string>; delay?: number; }> = new Map(); private flutterEngine?: FlutterDebugEngine; private problemEngine: ProblemDebugEngine; private nextjsEngine?: NextJSDebugEngine; private serverFrameworkEngine?: ServerFrameworkDebugEngine; private metaFrameworkEngine?: MetaFrameworkDebugEngine; private tidewaveCapabilities?: TidewaveCapabilities; constructor() { this.problemEngine = new ProblemDebugEngine(); } async attachToPage(page: Page): Promise<void> { this.page = page; // Set up console monitoring page.on('console', (msg) => { this.consoleMessages.push({ type: msg.type(), text: msg.text(), timestamp: new Date() }); }); // Set up network monitoring and mocking page.on('request', (request) => { this.networkRequests.push({ url: request.url(), method: request.method(), headers: request.headers(), timestamp: new Date(), status: 'pending' }); }); page.on('response', (response) => { const index = this.networkRequests.findIndex( req => req.url === response.url() && req.status === 'pending' ); if (index !== -1) { this.networkRequests[index] = { ...this.networkRequests[index], status: 'complete', responseStatus: response.status(), responseHeaders: response.headers() }; } }); // Set up route interception for mocking await this.setupNetworkMocking(); // Attach problem detection engine await this.problemEngine.attachToPage(page); // Detect and attach framework-specific engines await this.attachFrameworkEngines(page); } private async setupNetworkMocking(): Promise<void> { if (!this.page) return; await this.page.route('**/*', async (route) => { const url = route.request().url(); // Check if we have a mock for this URL for (const [pattern, mockData] of this.mockedResponses) { if (this.matchesPattern(url, pattern)) { // Apply delay if specified if (mockData.delay) { await new Promise(resolve => setTimeout(resolve, mockData.delay)); } // Return mocked response await route.fulfill({ status: mockData.status, headers: mockData.headers || {}, body: typeof mockData.body === 'string' ? mockData.body : JSON.stringify(mockData.body), contentType: mockData.headers?.['content-type'] || 'application/json' }); console.log(`Mocked response for ${url}`); return; } } // Continue with normal request await route.continue(); }); } private matchesPattern(url: string, pattern: string): boolean { // Support wildcards and simple patterns const regexPattern = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars .replace(/\*/g, '.*'); // Convert * to .* return new RegExp(regexPattern).test(url); } async captureState(): Promise<DebugState> { if (!this.page) { throw new Error('No page attached'); } const [localStorage, sessionStorage, cookies, url] = await Promise.all([ this.captureStorage('localStorage'), this.captureStorage('sessionStorage'), this.page.context().cookies(), this.page.url() ]); return { url, localStorage, sessionStorage, cookies, timestamp: new Date() }; } private async captureStorage(type: 'localStorage' | 'sessionStorage'): Promise<Record<string, any>> { if (!this.page) return {}; try { return await this.page.evaluate((storageType) => { const storage = (window as any)[storageType]; const items: Record<string, any> = {}; for (let i = 0; i < storage.length; i++) { const key = storage.key(i); if (key) { try { items[key] = JSON.parse(storage.getItem(key) || ''); } catch { items[key] = storage.getItem(key); } } } return items; }, type); } catch (error) { return {}; } } async captureDOMSnapshot(): Promise<DOMSnapshot> { if (!this.page) { throw new Error('No page attached'); } const snapshot = await this.page.evaluate(() => { const serializeNode = (node: Element): any => { const attributes: Record<string, string> = {}; for (const attr of Array.from(node.attributes) as Attr[]) { attributes[attr.name] = attr.value; } return { tagName: node.tagName.toLowerCase(), attributes, children: Array.from(node.children).map(serializeNode), textContent: node.textContent?.trim() || '' }; }; return { html: document.documentElement.outerHTML, structure: serializeNode(document.documentElement), timestamp: new Date().toISOString() }; }); return { ...snapshot, timestamp: new Date(snapshot.timestamp) }; } async injectDebuggingCode(code: string): Promise<any> { if (!this.page) { throw new Error('No page attached'); } return await this.page.evaluate(code); } async inspectElement(selector: string): Promise<{ exists: boolean; visible?: boolean; properties?: Record<string, any>; computedStyles?: Record<string, string>; }> { if (!this.page) { throw new Error('No page attached'); } return await this.page.evaluate((sel) => { const element = document.querySelector(sel); if (!element) { return { exists: false }; } const rect = element.getBoundingClientRect(); const styles = (window as any).getComputedStyle(element); const computedStyles: Record<string, string> = {}; // Get key computed styles ['display', 'visibility', 'opacity', 'position', 'top', 'left', 'width', 'height'].forEach(prop => { computedStyles[prop] = styles.getPropertyValue(prop); }); return { exists: true, visible: rect.width > 0 && rect.height > 0 && styles.display !== 'none' && styles.visibility !== 'hidden', properties: { tagName: element.tagName, id: element.id, className: element.className, textContent: element.textContent?.trim(), innerHTML: element.innerHTML.substring(0, 200), rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height } }, computedStyles }; }, selector); } async simulateUserAction(action: string, selector?: string, value?: any): Promise<void> { if (!this.page) { throw new Error('No page attached'); } switch (action) { case 'click': if (selector) { await this.page.click(selector); } break; case 'type': if (selector && value) { await this.page.fill(selector, value); } break; case 'hover': if (selector) { await this.page.hover(selector); } break; case 'screenshot': await this.page.screenshot({ path: value || 'debug-screenshot.png', fullPage: true }); break; case 'reload': await this.page.reload(); break; default: throw new Error(`Unknown action: ${action}`); } } getConsoleMessages( filter?: { type?: string; since?: Date; maxTokens?: number } ): Array<{ type: string; text: string; timestamp: Date }> { let messages = [...this.consoleMessages]; if (filter?.type) { messages = messages.filter(msg => msg.type === filter.type); } if (filter?.since) { messages = messages.filter(msg => msg.timestamp > filter.since!); } // Apply intelligent truncation if needed if (filter?.maxTokens) { // First try grouping similar messages messages = LogTruncator.groupSimilarLogs(messages); // Then truncate to fit token limit messages = LogTruncator.truncateLogs(messages, { maxTokens: filter.maxTokens, preserveLatest: true, prioritizeErrors: true }); } return messages; } getNetworkRequests(filter?: { status?: string; pattern?: string }): NetworkRequest[] { let requests = [...this.networkRequests]; if (filter?.status) { requests = requests.filter(req => req.status === filter.status); } if (filter?.pattern) { const regex = new RegExp(filter.pattern); requests = requests.filter(req => regex.test(req.url)); } return requests; } async findReactComponents(): Promise<Array<{ name: string; props: any; state: any }>> { if (!this.page) { throw new Error('No page attached'); } return await this.page.evaluate(() => { const components: Array<{ name: string; props: any; state: any }> = []; // Find React fiber nodes const findReactFiber = (element: Element): any => { const keys = Object.keys(element); const fiberKey = keys.find(key => key.startsWith('__reactFiber')); return fiberKey ? (element as any)[fiberKey] : null; }; // Walk the DOM and find React components const walkDOM = (element: Element) => { const fiber = findReactFiber(element); if (fiber && fiber.elementType && typeof fiber.elementType === 'function') { components.push({ name: fiber.elementType.name || 'Anonymous', props: fiber.memoizedProps || {}, state: fiber.memoizedState || {} }); } Array.from(element.children).forEach(walkDOM); }; walkDOM(document.body); return components; }).catch(() => []); } async findPhoenixLiveViewState(): Promise<any> { if (!this.page) { throw new Error('No page attached'); } return await this.page.evaluate(() => { // Look for Phoenix LiveView hooks const liveSocket = (window as any).liveSocket; if (!liveSocket) return null; const views: any[] = []; // Find all LiveView elements document.querySelectorAll('[data-phx-view]').forEach((element: any) => { const phxView = element.getAttribute('data-phx-view'); const phxSession = element.getAttribute('data-phx-session'); views.push({ id: phxView, session: phxSession, element: { id: element.id, className: element.className, tagName: element.tagName } }); }); return { connected: liveSocket.isConnected(), views, hooks: Object.keys((window as any).Hooks || {}) }; }).catch(() => null); } async detectFramework(page: Page): Promise<string> { this.page = page; // Check for meta-frameworks first (they might also be React/Vue/etc) this.metaFrameworkEngine = new MetaFrameworkDebugEngine(); await this.metaFrameworkEngine.attachToPage(page); const metaFrameworkInfo = this.metaFrameworkEngine.getFrameworkInfo(); if (metaFrameworkInfo?.framework) { return metaFrameworkInfo.framework; } // Check for Next.js (if not detected by meta-framework engine) this.nextjsEngine = new NextJSDebugEngine(); const isNextJS = await this.nextjsEngine.detectNextJS(page); if (isNextJS) { await this.nextjsEngine.attachToPage(page); return 'nextjs'; } // Check for server frameworks this.serverFrameworkEngine = new ServerFrameworkDebugEngine(); const serverFramework = await this.serverFrameworkEngine.detectServerFramework(page); if (serverFramework) { await this.serverFrameworkEngine.attachToPage(page); return serverFramework; } // Check for Flutter Web this.flutterEngine = new FlutterDebugEngine(); const isFlutter = await this.flutterEngine.detectFlutterWeb(page); if (isFlutter) { // Try to connect to Flutter DevTools const port = await this.flutterEngine.findDebugPort(page); if (port) { await this.flutterEngine.connect(port); } return 'flutter-web'; } // Check for Phoenix LiveView const phoenixState = await this.findPhoenixLiveViewState(); if (phoenixState) return 'phoenix'; // Check for React const reactComponents = await this.findReactComponents(); if (reactComponents.length > 0) return 'react'; // Check for Vue const hasVue = await page.evaluate(() => { return !!(window as any).Vue || document.querySelector('[data-v-]'); }); if (hasVue) return 'vue'; // Check for Angular const hasAngular = await page.evaluate(() => { return !!(window as any).ng || document.querySelector('[ng-version]'); }); if (hasAngular) return 'angular'; // Check for Svelte const hasSvelte = await page.evaluate(() => { return document.querySelector('[data-svelte]') !== null; }); if (hasSvelte) return 'svelte'; return 'unknown'; } async injectDebugging(page: Page, framework: string): Promise<void> { this.page = page; // Inject framework-agnostic debugging await page.addInitScript(() => { (window as any).__AI_DEBUG__ = { events: [], recordEvent: function(type: string, data: any) { this.events.push({ type, data, timestamp: new Date().toISOString() }); } }; // Monitor console const originalLog = console.log; const originalError = console.error; const originalWarn = console.warn; console.log = function(...args) { (window as any).__AI_DEBUG__.recordEvent('console.log', { args }); return originalLog.apply(console, args); }; console.error = function(...args) { (window as any).__AI_DEBUG__.recordEvent('console.error', { args }); return originalError.apply(console, args); }; console.warn = function(...args) { (window as any).__AI_DEBUG__.recordEvent('console.warn', { args }); return originalWarn.apply(console, args); }; // Monitor clicks document.addEventListener('click', (e: any) => { const target = e.target as HTMLElement; (window as any).__AI_DEBUG__.recordEvent('click', { selector: target.tagName + (target.id ? '#' + target.id : '') + (target.className ? '.' + target.className.split(' ').join('.') : ''), text: target.textContent?.substring(0, 50) }); }, true); }); // Framework-specific instrumentation if (framework === 'phoenix') { await page.addInitScript(() => { const checkLiveSocket = setInterval(() => { const liveSocket = (window as any).liveSocket; if (liveSocket) { clearInterval(checkLiveSocket); // Monitor LiveView events const originalPush = liveSocket.push; liveSocket.push = function(event: any) { (window as any).__AI_DEBUG__.recordEvent('phoenix.push', { event }); return originalPush.apply(this, arguments); }; } }, 100); }); } } async monitorEvents(page: Page, duration: number): Promise<any[]> { const startTime = Date.now(); const events: any[] = []; // Collect events for the specified duration while (Date.now() - startTime < duration * 1000) { const pageEvents = await page.evaluate(() => { const aiDebug = (window as any).__AI_DEBUG__; if (!aiDebug) return []; const currentEvents = [...aiDebug.events]; aiDebug.events = []; // Clear collected events return currentEvents; }); events.push(...pageEvents); await page.waitForTimeout(1000); // Check every second } return events; } basicAnalysis(events: any[]): any { const analysis: any = { mostActiveElement: null, performanceScore: 100, errorRate: 0 }; // Find most clicked element const clickCounts: Record<string, number> = {}; events.filter(e => e.type === 'click').forEach(e => { const selector = e.data.selector; clickCounts[selector] = (clickCounts[selector] || 0) + 1; }); if (Object.keys(clickCounts).length > 0) { analysis.mostActiveElement = Object.entries(clickCounts) .sort(([,a], [,b]) => b - a)[0][0]; } // Calculate error rate const errorCount = events.filter(e => e.type === 'console.error' || e.type === 'error').length; analysis.errorRate = events.length > 0 ? Math.round((errorCount / events.length) * 100) : 0; // Adjust performance score based on errors analysis.performanceScore = Math.max(0, 100 - (analysis.errorRate * 2)); return analysis; } async simulateAction(page: Page, action: string, selector: string, value?: string): Promise<any> { const startTime = Date.now(); let success = true; let error = null; let description = ''; try { switch (action) { case 'click': await page.click(selector); description = `Clicked element ${selector}`; break; case 'type': await page.fill(selector, value || ''); description = `Typed "${value}" into ${selector}`; break; case 'submit': await page.press(selector, 'Enter'); description = `Submitted form at ${selector}`; break; case 'scroll': await page.hover(selector); await page.mouse.wheel(0, parseInt(value || '100')); description = `Scrolled ${value || 100}px at ${selector}`; break; case 'hover': await page.hover(selector); description = `Hovered over ${selector}`; break; } } catch (e) { success = false; error = e instanceof Error ? e.message : String(e); description = `Failed to ${action} ${selector}`; } return { success, error, description, duration: Date.now() - startTime }; } async captureRecentEvents(page: Page, duration: number): Promise<any[]> { await page.waitForTimeout(duration); return await page.evaluate(() => { const aiDebug = (window as any).__AI_DEBUG__; if (!aiDebug) return []; return [...aiDebug.events]; }); } async extractPageState(page: Page): Promise<any> { const state = await this.captureState(); const dom = await this.captureDOMSnapshot(); const framework = await this.detectFramework(page); let frameworkState = null; if (framework === 'phoenix') { frameworkState = await this.findPhoenixLiveViewState(); } else if (framework === 'react') { frameworkState = await this.findReactComponents(); } return { ...state, dom, framework, frameworkState }; } generateBasicReport(session: any): any { const errorEvents = session.events.filter((e: any) => e.type === 'error' || e.type === 'console.error'); const clickEvents = session.events.filter((e: any) => e.type === 'click'); const networkEvents = session.events.filter((e: any) => e.type === 'networkRequest'); const findings = []; if (errorEvents.length > 0) { findings.push(`Found ${errorEvents.length} errors during session`); } if (clickEvents.length > 10) { findings.push(`High user interaction detected (${clickEvents.length} clicks)`); } if (networkEvents.length > 50) { findings.push(`Heavy network activity (${networkEvents.length} requests)`); } if (findings.length === 0) { findings.push('Session completed without significant issues'); } return { findings }; } async addNetworkMock( urlPattern: string, response: { status?: number; body?: any; headers?: Record<string, string>; delay?: number; } ): Promise<void> { this.mockedResponses.set(urlPattern, { status: response.status || 200, body: response.body || {}, headers: response.headers, delay: response.delay }); // Re-setup mocking if page is already attached if (this.page) { await this.setupNetworkMocking(); } } removeNetworkMock(urlPattern: string): boolean { return this.mockedResponses.delete(urlPattern); } clearAllMocks(): void { this.mockedResponses.clear(); } getMockedUrls(): string[] { return Array.from(this.mockedResponses.keys()); } // Flutter-specific methods async getFlutterWidgetTree(): Promise<any> { if (!this.flutterEngine) { throw new Error('Flutter engine not initialized'); } return await this.flutterEngine.getWidgetTree(); } async getFlutterPerformance(): Promise<any> { if (!this.flutterEngine) { throw new Error('Flutter engine not initialized'); } return await this.flutterEngine.getPerformanceInfo(); } async inspectFlutterWidget(widgetId: string): Promise<any> { if (!this.flutterEngine) { throw new Error('Flutter engine not initialized'); } return await this.flutterEngine.inspectWidget(widgetId); } async highlightFlutterWidget(widgetId: string): Promise<void> { if (!this.flutterEngine) { throw new Error('Flutter engine not initialized'); } await this.flutterEngine.highlightWidget(widgetId); } async getFlutterMemoryUsage(): Promise<any> { if (!this.flutterEngine) { throw new Error('Flutter engine not initialized'); } return await this.flutterEngine.getMemoryUsage(); } isFlutterConnected(): boolean { return this.flutterEngine !== undefined; } // Problem-focused debugging methods async getHydrationIssues(): Promise<any> { return await this.problemEngine.getHydrationIssues(); } async getBundleAnalysis(): Promise<any> { return await this.problemEngine.getBundleAnalysis(); } async getRouteChanges(): Promise<any> { return await this.problemEngine.getRouteChanges(); } async detectCommonProblems(): Promise<any> { return await this.problemEngine.detectCommonProblems(); } private async attachFrameworkEngines(page: Page): Promise<void> { // This is called after framework detection in detectFramework // Engines are already attached there } // Next.js specific methods async getNextJSPageInfo(): Promise<any> { if (!this.nextjsEngine) { throw new Error('Next.js engine not initialized'); } return await this.nextjsEngine.getPageInfo(); } async getNextJSBuildInfo(): Promise<any> { if (!this.nextjsEngine) { throw new Error('Next.js engine not initialized'); } return await this.nextjsEngine.getBuildInfo(); } async getNextJSHydrationErrors(): Promise<any> { if (!this.nextjsEngine) { throw new Error('Next.js engine not initialized'); } return await this.nextjsEngine.getHydrationErrors(); } async getNextJSImageIssues(): Promise<any> { if (!this.nextjsEngine) { throw new Error('Next.js engine not initialized'); } return await this.nextjsEngine.getImageOptimizationIssues(); } async detectNextJSProblems(): Promise<any> { if (!this.nextjsEngine) { throw new Error('Next.js engine not initialized'); } return await this.nextjsEngine.detectNextJSProblems(); } // Server framework specific methods async getTurboEvents(): Promise<any> { if (!this.serverFrameworkEngine) { throw new Error('Server framework engine not initialized'); } return await this.serverFrameworkEngine.getTurboEvents(); } async getStimulusControllers(): Promise<any> { if (!this.serverFrameworkEngine) { throw new Error('Server framework engine not initialized'); } return await this.serverFrameworkEngine.getStimulusControllers(); } async getCSRFIssues(): Promise<any> { if (!this.serverFrameworkEngine) { throw new Error('Server framework engine not initialized'); } return await this.serverFrameworkEngine.getCSRFIssues(); } async detectServerFrameworkProblems(): Promise<any> { if (!this.serverFrameworkEngine) { throw new Error('Server framework engine not initialized'); } return await this.serverFrameworkEngine.detectServerFrameworkProblems(); } async getFormSubmissions(): Promise<any> { if (!this.serverFrameworkEngine) { throw new Error('Server framework engine not initialized'); } return await this.serverFrameworkEngine.getFormSubmissions(); } isNextJSConnected(): boolean { return this.nextjsEngine !== undefined; } isServerFrameworkConnected(): boolean { return this.serverFrameworkEngine !== undefined; } // Meta-framework specific methods async getMetaFrameworkInfo(): Promise<any> { return this.metaFrameworkEngine?.getFrameworkInfo(); } async getRemixLoaderData(): Promise<any> { if (!this.metaFrameworkEngine) { throw new Error('Meta-framework engine not initialized'); } return await this.metaFrameworkEngine.getRemixLoaderData(); } async getRemixRouteModules(): Promise<any> { if (!this.metaFrameworkEngine) { throw new Error('Meta-framework engine not initialized'); } return await this.metaFrameworkEngine.getRemixRouteModules(); } async getAstroIslands(): Promise<any> { if (!this.metaFrameworkEngine) { throw new Error('Meta-framework engine not initialized'); } return await this.metaFrameworkEngine.getAstroIslands(); } async getNuxtPayload(): Promise<any> { if (!this.metaFrameworkEngine) { throw new Error('Meta-framework engine not initialized'); } return await this.metaFrameworkEngine.getNuxtPayload(); } async getQwikResumability(): Promise<any> { if (!this.metaFrameworkEngine) { throw new Error('Meta-framework engine not initialized'); } return await this.metaFrameworkEngine.getQwikResumability(); } async getViteHMREvents(): Promise<any> { if (!this.metaFrameworkEngine) { throw new Error('Meta-framework engine not initialized'); } return await this.metaFrameworkEngine.getViteHMREvents(); } async detectMetaFrameworkProblems(): Promise<any> { if (!this.metaFrameworkEngine) { throw new Error('Meta-framework engine not initialized'); } return await this.metaFrameworkEngine.detectMetaFrameworkProblems(); } isMetaFrameworkConnected(): boolean { return this.metaFrameworkEngine !== undefined; } async cleanup(): Promise<void> { if (this.page) { await this.page.close(); } if (this.browser) { await this.browser.close(); } } }