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

644 lines (625 loc) • 26.2 kB
import { BaseToolHandler } from './base-handler.js'; import { TidewaveIntegration } from '../tidewave-integration.js'; /** * Handler for advanced LiveView debugging tools */ export class LiveViewAdvancedHandler extends BaseToolHandler { tidewave; constructor() { super(); this.tidewave = new TidewaveIntegration(); } tools = [ { name: 'liveview_inspect_mount_state', description: 'Inspect LiveView mount/3 callback state and assigns', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, liveViewModule: { type: 'string', description: 'LiveView module name' }, params: { type: 'object', description: 'Mount parameters to test with' } }, required: ['sessionId', 'liveViewModule'] } }, { name: 'liveview_trace_handle_event', description: 'Trace LiveView event handlers with payloads and state changes', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, liveViewModule: { type: 'string', description: 'LiveView module name' }, event: { type: 'string', description: 'Event name to trace' }, duration: { type: 'number', description: 'Trace duration in seconds', default: 30 } }, required: ['sessionId', 'liveViewModule', 'event'] } }, { name: 'liveview_analyze_diff_size', description: 'Analyze DOM diff sizes and optimize large payloads', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, threshold: { type: 'number', description: 'Diff size threshold in KB', default: 10 } }, required: ['sessionId'] } }, { name: 'liveview_inspect_uploads', description: 'Debug LiveView file upload state and configuration', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, uploadName: { type: 'string', description: 'Upload configuration name' } }, required: ['sessionId'] } }, { name: 'liveview_profile_components', description: 'Profile LiveComponent render times and frequency', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, duration: { type: 'number', description: 'Profile duration in seconds', default: 30 } }, required: ['sessionId'] } }, { name: 'liveview_inspect_socket', description: 'Deep inspect LiveView socket assigns and metadata', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, socketId: { type: 'string', description: 'Socket ID to inspect' } }, required: ['sessionId'] } }, { name: 'liveview_stream_analysis', description: 'Analyze LiveView streams performance and memory usage', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, streamName: { type: 'string', description: 'Stream name to analyze' } }, required: ['sessionId'] } }, { name: 'liveview_form_state_debug', description: 'Debug form state, changesets, and validation in LiveView', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, formName: { type: 'string', description: 'Form name to debug' } }, required: ['sessionId'] } }, { name: 'liveview_js_commands_trace', description: 'Trace JS commands execution and client-side effects', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' } }, required: ['sessionId'] } }, { name: 'liveview_memory_usage', description: 'Analyze LiveView process memory usage and detect leaks', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Debug session ID' }, includeComponents: { type: 'boolean', description: 'Include component analysis', default: true } }, required: ['sessionId'] } } ]; async handle(toolName, args, sessions) { const methodName = this.convertToolNameToMethod(toolName); const method = this[methodName]; if (typeof method === 'function') { return method.call(this, args, sessions); } throw new Error(`Unknown LiveView advanced tool: ${toolName}`); } convertToolNameToMethod(toolName) { return toolName.replace(/_(.)/g, (_, char) => char.toUpperCase()); } // Remove duplicate getSession - it's already in BaseToolHandler async liveviewInspectMountState(args, sessions) { const session = this.getSession(args.sessionId, sessions); const params = args.params ? JSON.stringify(args.params) : '{}'; const code = ` module = ${args.liveViewModule} params = Jason.decode!(${JSON.stringify(params)}) session = %{} # Create a test socket socket = %Phoenix.LiveView.Socket{ assigns: %{}, endpoint: MyAppWeb.Endpoint, view: module } # Call mount case module.mount(params, session, socket) do {:ok, socket} -> %{ success: true, assigns: Map.from_struct(socket.assigns), connected?: socket.connected?, root_pid: inspect(socket.root_pid) } {:ok, socket, opts} -> %{ success: true, assigns: Map.from_struct(socket.assigns), options: opts, connected?: socket.connected? } error -> %{success: false, error: inspect(error)} end `; const result = await this.tidewave.evaluateCode(session.url, code); if (!result.success) { throw new Error(`Failed to inspect mount state: ${result.error}`); } return this.createTextResponse(this.formatMountState(result.data)); } async liveviewTraceHandleEvent(args, sessions) { const session = this.getSession(args.sessionId, sessions); const code = ` # Set up tracing for handle_event :dbg.start() :dbg.tracer() :dbg.tp(${args.liveViewModule}, :handle_event, [{:_, [], [{:return_trace}]}]) :dbg.p(:all, :c) # Trace for specified duration Process.sleep(${args.duration} * 1000) :dbg.stop_clear() "Event tracing started for ${args.event} in ${args.liveViewModule}" `; const result = await this.tidewave.evaluateCode(session.url, code); if (!result.success) { throw new Error(`Failed to trace events: ${result.error}`); } return this.createTextResponse(`šŸ” **LiveView Event Tracing**\n\n` + `Module: \`${args.liveViewModule}\`\n` + `Event: \`${args.event}\`\n` + `Duration: ${args.duration} seconds\n\n` + `āœ… Tracing active. Event handler calls will appear in your logs.`); } async liveviewAnalyzeDiffSize(args, sessions) { const session = this.getSession(args.sessionId, sessions); // Inject client-side monitoring const monitoringCode = ` (() => { if (!window.liveSocket) return { error: 'LiveSocket not found' }; const diffs = []; const originalPush = window.liveSocket.push; window.liveSocket.push = function(data) { const size = JSON.stringify(data).length / 1024; // KB if (size > ${args.threshold}) { diffs.push({ timestamp: new Date().toISOString(), size: size.toFixed(2), type: data.type, event: data.event }); } return originalPush.call(this, data); }; // Collect for 10 seconds setTimeout(() => { window.liveSocket.push = originalPush; }, 10000); // Return initial status return { monitoring: true, threshold: ${args.threshold}, message: 'Monitoring DOM diffs for 10 seconds...' }; })(); `; const result = await session.page.evaluate(monitoringCode); if (result.error) { throw new Error(result.error); } // Wait and collect results await new Promise(resolve => setTimeout(resolve, 11000)); const diffs = await session.page.evaluate(() => { return window.liveDiffs || []; }); return this.createTextResponse(this.formatDiffAnalysis(diffs, args.threshold)); } async liveviewInspectUploads(args, sessions) { const session = this.getSession(args.sessionId, sessions); const uploadInfo = await session.page.evaluate((uploadName) => { const liveSocket = window.liveSocket; if (!liveSocket) return { error: 'LiveSocket not found' }; // Get upload configuration from the page const uploads = {}; const uploadInputs = document.querySelectorAll('input[type="file"][data-phx-upload-ref]'); uploadInputs.forEach((input) => { const ref = input.getAttribute('data-phx-upload-ref'); const name = input.getAttribute('name') || ref; uploads[name] = { ref, multiple: input.multiple, accept: input.accept, maxFiles: input.getAttribute('data-phx-max-files'), maxSize: input.getAttribute('data-phx-max-size') }; }); return { uploads, activeUploads: Object.keys(uploads).length, targetUpload: uploads[uploadName] || null }; }, args.uploadName); if (uploadInfo.error) { throw new Error(uploadInfo.error); } return this.createTextResponse(this.formatUploadInfo(uploadInfo, args.uploadName)); } async liveviewProfileComponents(args, sessions) { const session = this.getSession(args.sessionId, sessions); const code = ` # Profile LiveComponent rendering component_timings = %{} :telemetry.attach( "liveview-component-profiler", [:phoenix, :live_component, :render], fn _event, %{duration: duration}, %{component: component}, _config -> Agent.update(:component_profiler, fn state -> Map.update(state, component, [duration], &[duration | &1]) end) end, nil ) Agent.start_link(fn -> %{} end, name: :component_profiler) # Profile for duration Process.sleep(${args.duration} * 1000) # Get results results = Agent.get(:component_profiler, & &1) |> Enum.map(fn {component, timings} -> %{ component: inspect(component), render_count: length(timings), avg_time: Enum.sum(timings) / length(timings) / 1_000_000, # ms min_time: Enum.min(timings) / 1_000_000, max_time: Enum.max(timings) / 1_000_000 } end) |> Enum.sort_by(& &1.avg_time, :desc) :telemetry.detach("liveview-component-profiler") Agent.stop(:component_profiler) results `; const result = await this.tidewave.evaluateCode(session.url, code); if (!result.success) { throw new Error(`Failed to profile components: ${result.error}`); } return this.createTextResponse(this.formatComponentProfile(result.data)); } async liveviewInspectSocket(args, sessions) { const session = this.getSession(args.sessionId, sessions); const code = ` # Find socket process socket_pid = Process.whereis(String.to_atom("${args.socketId}")) if socket_pid && Process.alive?(socket_pid) do state = :sys.get_state(socket_pid) %{ pid: inspect(socket_pid), connected?: state.socket.connected?, assigns: Map.keys(state.socket.assigns), assign_count: map_size(state.socket.assigns), memory: Process.info(socket_pid, :memory)[:memory], message_queue: Process.info(socket_pid, :message_queue_len)[:message_queue_len] } else %{error: "Socket process not found or not alive"} end `; const result = await this.tidewave.evaluateCode(session.url, code); if (!result.success) { throw new Error(`Failed to inspect socket: ${result.error}`); } if (result.data.error) { throw new Error(result.data.error); } return this.createTextResponse(this.formatSocketInfo(result.data)); } async liveviewStreamAnalysis(args, sessions) { const session = this.getSession(args.sessionId, sessions); const code = ` # Analyze stream usage # This is a simplified version - real implementation would hook into LiveView internals %{ stream_name: "${args.streamName}", estimated_items: "Unknown - requires runtime inspection", memory_usage: "Requires process inspection", recommendations: [ "Use limit option to cap stream size", "Consider pagination for large datasets", "Use temporary assigns for non-stream data" ] } `; const result = await this.tidewave.evaluateCode(session.url, code); if (!result.success) { throw new Error(`Failed to analyze stream: ${result.error}`); } return this.createTextResponse(`šŸ“Š **Stream Analysis**\n\n` + `Stream: \`${args.streamName}\`\n\n` + `šŸ’” **Recommendations:**\n` + result.data.recommendations.map((r) => `• ${r}`).join('\n')); } async liveviewFormStateDebug(args, sessions) { const session = this.getSession(args.sessionId, sessions); // Get form state from client const formState = await session.page.evaluate((formName) => { const form = document.querySelector(`form[phx-submit="${formName}"], form[name="${formName}"]`); if (!form) return { error: 'Form not found' }; const inputs = {}; form.querySelectorAll('input, select, textarea').forEach((el) => { if (el.name) { inputs[el.name] = { value: el.value, type: el.type, required: el.required, validity: el.validity.valid }; } }); return { formName, action: form.getAttribute('action'), method: form.getAttribute('method'), phxSubmit: form.getAttribute('phx-submit'), phxChange: form.getAttribute('phx-change'), inputs }; }, args.formName); if (formState.error) { throw new Error(formState.error); } return this.createTextResponse(this.formatFormState(formState)); } async liveviewJsCommandsTrace(args, sessions) { const session = this.getSession(args.sessionId, sessions); // Inject JS command tracing const result = await session.page.evaluate(() => { if (!window.Phoenix?.LiveView?.JS) { return { error: 'Phoenix.LiveView.JS not found' }; } const JS = window.Phoenix.LiveView.JS; const commands = []; // Wrap JS methods ['push', 'dispatch', 'toggle', 'show', 'hide', 'add_class', 'remove_class'].forEach(method => { const original = JS.prototype[method]; if (original) { JS.prototype[method] = function (...args) { commands.push({ method, args, timestamp: new Date().toISOString() }); return original.apply(this, args); }; } }); return { tracing: true, message: 'JS command tracing enabled. Interact with the page to capture commands.' }; }); if (result.error) { throw new Error(result.error); } return this.createTextResponse(`šŸ” **JS Commands Tracing**\n\n` + `āœ… ${result.message}\n\n` + `Monitoring commands: push, dispatch, toggle, show, hide, add_class, remove_class\n\n` + `šŸ’” Commands will be logged to the browser console.`); } async liveviewMemoryUsage(args, sessions) { const session = this.getSession(args.sessionId, sessions); const code = ` # Get all LiveView processes liveview_processes = Process.list() |> Enum.filter(fn pid -> case Process.info(pid, :dictionary) do {:dictionary, dict} -> Keyword.has_key?(dict, :"$initial_call") && elem(Keyword.get(dict, :"$initial_call", {nil, nil, nil}), 0) == Phoenix.LiveView.Channel _ -> false end end) |> Enum.map(fn pid -> info = Process.info(pid, [:memory, :message_queue_len, :reductions]) %{ pid: inspect(pid), memory: info[:memory], message_queue: info[:message_queue_len], reductions: info[:reductions] } end) |> Enum.sort_by(& &1.memory, :desc) |> Enum.take(10) total_memory = Enum.sum(Enum.map(liveview_processes, & &1.memory)) %{ processes: liveview_processes, total_memory: total_memory, process_count: length(liveview_processes) } `; const result = await this.tidewave.evaluateCode(session.url, code); if (!result.success) { throw new Error(`Failed to analyze memory usage: ${result.error}`); } return this.createTextResponse(this.formatMemoryUsage(result.data)); } // Helper methods formatMountState(data) { if (!data.success) { return `āŒ **Mount Failed**\n\nError: ${data.error}`; } let output = `šŸŽÆ **LiveView Mount State**\n\n`; output += `Connected: ${data.connected ? 'āœ…' : 'āŒ'}\n`; if (data.root_pid) { output += `Root PID: ${data.root_pid}\n`; } output += '\n'; if (data.assigns && Object.keys(data.assigns).length > 0) { output += `**Assigns:**\n`; Object.keys(data.assigns).forEach(key => { output += `• ${key}\n`; }); } if (data.options) { output += `\n**Mount Options:**\n`; output += JSON.stringify(data.options, null, 2); } return output; } formatDiffAnalysis(diffs, threshold) { if (!diffs || diffs.length === 0) { return `āœ… **No Large Diffs Detected**\n\nNo DOM diffs exceeded ${threshold}KB during monitoring.`; } let output = `āš ļø **Large DOM Diffs Detected**\n\n`; output += `Found ${diffs.length} diffs exceeding ${threshold}KB:\n\n`; diffs.forEach((diff, i) => { output += `${i + 1}. **${diff.size}KB**\n`; output += ` Time: ${diff.timestamp}\n`; output += ` Type: ${diff.type}\n`; output += ` Event: ${diff.event}\n\n`; }); output += `šŸ’” **Optimization Tips:**\n`; output += `• Use temporary assigns for large, infrequently changing data\n`; output += `• Consider pagination or virtualization for large lists\n`; output += `• Move static content outside of changing containers`; return output; } formatUploadInfo(data, uploadName) { let output = `šŸ“¤ **Upload Configuration**\n\n`; if (uploadName && data.targetUpload) { output += `**Upload: ${uploadName}**\n`; output += `• Reference: ${data.targetUpload.ref}\n`; output += `• Multiple: ${data.targetUpload.multiple ? 'āœ…' : 'āŒ'}\n`; output += `• Accept: ${data.targetUpload.accept || 'Any'}\n`; output += `• Max Files: ${data.targetUpload.maxFiles || 'Unlimited'}\n`; output += `• Max Size: ${data.targetUpload.maxSize || 'Unlimited'}\n`; } else { output += `Active Uploads: ${data.activeUploads}\n\n`; const uploadsObj = data.uploads; if (Object.keys(uploadsObj).length > 0) { output += `**All Uploads:**\n`; Object.entries(uploadsObj).forEach(([name, config]) => { output += `\n• **${name}**\n`; output += ` Accept: ${config.accept || 'Any'}\n`; output += ` Multiple: ${config.multiple ? 'Yes' : 'No'}\n`; }); } } return output; } formatComponentProfile(components) { if (!components || components.length === 0) { return `šŸ“Š **No Components Rendered**\n\nNo LiveComponents were rendered during the profiling period.`; } let output = `⚔ **Component Performance Profile**\n\n`; components.forEach((comp, i) => { output += `${i + 1}. **${comp.component}**\n`; output += ` Renders: ${comp.render_count}\n`; output += ` Avg: ${comp.avg_time.toFixed(2)}ms\n`; output += ` Min: ${comp.min_time.toFixed(2)}ms\n`; output += ` Max: ${comp.max_time.toFixed(2)}ms\n\n`; }); const slowest = components[0]; if (slowest && slowest.avg_time > 16) { output += `āš ļø **Performance Warning**\n`; output += `${slowest.component} averages ${slowest.avg_time.toFixed(2)}ms per render.\n`; output += `This exceeds the 16ms budget for 60fps.\n`; } return output; } formatSocketInfo(data) { return `šŸ”Œ **Socket Inspection**\n\n` + `PID: ${data.pid}\n` + `Connected: ${data.connected ? 'āœ…' : 'āŒ'}\n` + `Memory: ${this.formatBytes(data.memory)}\n` + `Message Queue: ${data.message_queue} messages\n\n` + `**Assigns:** ${data.assign_count} keys\n` + data.assigns.slice(0, 10).map((a) => `• ${a}`).join('\n') + (data.assigns.length > 10 ? `\n... and ${data.assigns.length - 10} more` : ''); } formatFormState(data) { let output = `šŸ“ **Form State Debug**\n\n`; output += `Form: ${data.formName}\n`; if (data.phxSubmit) output += `Phoenix Submit: ${data.phxSubmit}\n`; if (data.phxChange) output += `Phoenix Change: ${data.phxChange}\n`; if (data.action) output += `Action: ${data.action}\n`; if (data.method) output += `Method: ${data.method}\n`; output += `\n**Fields:**\n`; Object.entries(data.inputs).forEach(([name, field]) => { const icon = field.validity ? 'āœ…' : 'āŒ'; output += `${icon} ${name}: ${field.value || '(empty)'} [${field.type}]`; if (field.required) output += ' *required'; output += '\n'; }); return output; } formatMemoryUsage(data) { let output = `šŸ’¾ **LiveView Memory Usage**\n\n`; output += `Total Memory: ${this.formatBytes(data.total_memory)}\n`; output += `Process Count: ${data.process_count}\n\n`; if (data.processes.length > 0) { output += `**Top 10 Processes by Memory:**\n`; data.processes.forEach((proc, i) => { output += `${i + 1}. PID: ${proc.pid}\n`; output += ` Memory: ${this.formatBytes(proc.memory)}\n`; output += ` Queue: ${proc.message_queue} messages\n`; output += ` Reductions: ${proc.reductions.toLocaleString()}\n\n`; }); } if (data.total_memory > 100 * 1024 * 1024) { // 100MB output += `āš ļø **Warning**: High memory usage detected!\n`; output += `Consider investigating large assigns or memory leaks.`; } return output; } formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } } //# sourceMappingURL=liveview-advanced-handler.js.map