UNPKG

visual-ui-debug-agent-mcp

Version:

VUDA: Visual UI Debug Agent - An autonomous MCP for visual testing and debugging of user interfaces

1,341 lines (1,176 loc) 183 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { randomUUID } from 'crypto'; import { z } from 'zod'; import { chromium, devices, request } from 'playwright'; import Jimp from 'jimp'; import fetch from 'node-fetch'; const TEMP_DIR = path.join(os.tmpdir(), 'ai-vision-debug'); const DOWNLOADS_DIR = path.join(os.homedir(), 'Downloads'); // Set up logging to a file instead of console const logDir = path.join(os.tmpdir(), 'ai-vision-debug-logs'); try { if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }); } } catch (error) { // Silently fail if we can't create the directories } const logFile = path.join(logDir, 'ai-vision-debug.log'); function logToFile(message: string): void { try { fs.appendFileSync(logFile, `${new Date().toISOString()} - ${message}\n`); } catch (error) { // Silently fail if we can't write to the log file } } // Session state to track current debugging session interface DebugSession { currentUrl: string | null; lastScreenshotPath: string | null; debugHistory: string[]; } // Initialize debug session const debugSession: DebugSession = { currentUrl: null, lastScreenshotPath: null, debugHistory: [] }; // Global browser and page state let browserInstance: import('playwright').Browser | null = null; let browserContext: import('playwright').BrowserContext | null = null; let activePage: import('playwright').Page | null = null; let apiContext: import('playwright').APIRequestContext | null = null; // Store console logs and screenshots for resource access const consoleLogs: string[] = []; const screenshots = new Map<string, string>(); // Debug memory storage const debugMemory = new Map<string, any>(); const tunnelUrls = new Map<number, string>(); // Define types for console messages interface ConsoleMessage { type: string; text: string; location?: { url?: string; lineNumber?: number; columnNumber?: number; }; } // Define types for interactive elements interface InteractiveElement { index: number; tagName: string; id: string; className: string; text: string; bounds: { x: number; y: number; width: number; height: number }; path: string; visible: boolean; type: string; } // Add schema for enhanced page analyzer const EnhancedPageAnalyzerSchema = z.object({ url: z.string().describe("URL to analyze (e.g., http://localhost:4999, https://example.com)"), includeConsole: z.boolean().optional().describe("Whether to include console logs. Default: true"), mapElements: z.boolean().optional().describe("Whether to map interactive elements. Default: true"), fullPage: z.boolean().optional().describe("Whether to capture full page or just viewport. Default: false"), waitForSelector: z.string().optional().describe("Optional CSS selector to wait for before analysis"), waitTime: z.number().optional().describe("Time to wait in milliseconds before analysis. Default: 3000"), device: z.string().optional().describe("Optional device to emulate (e.g., 'iPhone 13', 'Pixel 5')") }); // Add schema for API endpoint tester const ApiEndpointTesterSchema = z.object({ url: z.string().describe("Base URL of the API (e.g., http://localhost:5000/api)"), endpoints: z.array(z.object({ path: z.string().describe("Endpoint path (e.g., /users)"), method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).describe("HTTP method"), data: z.any().optional().describe("Request body data for POST/PUT"), headers: z.record(z.string()).optional().describe("Request headers") })).describe("List of endpoints to test"), authToken: z.string().optional().describe("Optional auth token to include in all requests") }); // Add schema for navigation flow validator const NavigationFlowValidatorSchema = z.object({ startUrl: z.string().describe("URL to start the navigation flow from"), steps: z.array(z.object({ action: z.enum(["click", "fill", "select", "hover", "wait", "navigate", "evaluate"]).describe("Action to perform"), selector: z.string().optional().describe("CSS selector for the element to interact with"), value: z.string().optional().describe("Value to input (for fill or select action)"), url: z.string().optional().describe("URL to navigate to (for navigate action)"), script: z.string().optional().describe("JavaScript to evaluate (for evaluate action)"), waitTime: z.number().optional().describe("Time to wait in ms (for wait action)") })).describe("Sequence of steps to perform"), captureScreenshots: z.boolean().optional().describe("Whether to capture screenshots after each step. Default: true"), includeConsole: z.boolean().optional().describe("Whether to include console logs. Default: true"), device: z.string().optional().describe("Optional device to emulate (e.g., 'iPhone 13', 'Pixel 5')") }); // Add schema for screenshot URL const ScreenshotUrlSchema = z.object({ url: z.string().describe("URL to capture a screenshot of (e.g., http://localhost:4999, https://example.com)"), fullPage: z.boolean().optional().describe("Whether to capture full page or just viewport. Default: false"), selector: z.string().optional().describe("Optional CSS selector to screenshot only that element"), waitForSelector: z.string().optional().describe("Optional CSS selector to wait for before taking screenshot"), waitTime: z.number().optional().describe("Time to wait in milliseconds before taking screenshot. Default: 1000"), device: z.string().optional().describe("Optional device to emulate (e.g., 'iPhone 13', 'Pixel 5')") }); // Add schema for DOM inspection const DomInspectorSchema = z.object({ url: z.string().describe("URL to inspect (e.g., http://localhost:4999, https://example.com)"), selector: z.string().describe("CSS selector to inspect"), includeChildren: z.boolean().optional().describe("Whether to include children elements. Default: false"), includeStyles: z.boolean().optional().describe("Whether to include computed styles. Default: true"), waitTime: z.number().optional().describe("Time to wait in milliseconds before inspecting. Default: 1000") }); // Add schema for console monitor const ConsoleMonitorSchema = z.object({ url: z.string().describe("URL to monitor console logs from"), filterTypes: z.array(z.enum(["log", "info", "warning", "error"])).optional().describe("Types of console messages to capture"), duration: z.number().optional().describe("How long to monitor in milliseconds. Default: 5000"), interactionSelector: z.string().optional().describe("Optional element to click before monitoring") }); // Add schema for accessibility check const AccessibilityCheckSchema = z.object({ url: z.string().describe("URL to check for accessibility issues"), standard: z.enum(["WCAG2A", "WCAG2AA", "WCAG2AAA"]).optional().describe("Accessibility standard to check against. Default: WCAG2AA"), includeScreenshot: z.boolean().optional().describe("Whether to include a screenshot with issues highlighted. Default: true") }); // Add schema for performance analysis const PerformanceAnalysisSchema = z.object({ url: z.string().describe("URL to analyze performance for"), iterations: z.number().optional().describe("Number of test iterations to run. Default: 1"), waitForNetworkIdle: z.boolean().optional().describe("Whether to wait for network to be idle. Default: true"), device: z.string().optional().describe("Optional device to emulate (e.g., 'iPhone 13', 'Pixel 5')") }); // Add schema for visual comparison const VisualComparisonSchema = z.object({ url1: z.string().describe("First URL to compare"), url2: z.string().describe("Second URL to compare"), threshold: z.number().optional().describe("Difference threshold (0.0-1.0). Default: 0.1"), fullPage: z.boolean().optional().describe("Whether to capture full page. Default: false"), selector: z.string().optional().describe("Optional CSS selector to limit comparison") }); // <<< START INSERTION: UI Workflow Validator Schema >>> const UIWorkflowValidatorSchema = z.object({ startUrl: z.string().url().describe("Initial URL for the workflow"), taskDescription: z.string().describe("High-level description of the user task being simulated"), steps: z.array(z.object({ description: z.string().describe("Description of the user action for this step"), action: z.enum([ "navigate", "click", "fill", "select", "hover", "wait", "evaluate", "screenshot", "verifyText", "verifyElementVisible", "verifyElementNotVisible", "verifyUrl" ]).describe("Playwright action or verification to perform"), selector: z.string().optional().describe("CSS selector for interaction or verification"), value: z.string().optional().describe("Value for fill/select or text/URL to verify"), url: z.string().optional().describe("URL for navigate action or verification"), script: z.string().optional().describe("JavaScript for evaluate action"), waitTime: z.number().optional().describe("Time to wait in ms (for wait action)"), isOptional: z.boolean().optional().default(false).describe("If true, failure of this step won't stop the workflow") })).min(1).describe("Sequence of steps representing the user workflow (minimum 1 step)"), captureScreenshots: z.enum(["all", "failure", "none"]).optional().default("failure").describe("When to capture screenshots"), device: z.string().optional().describe("Optional device to emulate (e.g., 'iPhone 13', 'Pixel 5')") }); // <<< END INSERTION: UI Workflow Validator Schema >>> // Add schema for tunnel helper const TunnelHelperSchema = z.object({ localPort: z.number().describe("Local port number to expose (e.g., 3000, 8080)"), action: z.enum(["guide", "store", "retrieve"]).describe("Action to perform: 'guide' shows instructions, 'store' saves tunnel URL, 'retrieve' gets saved URL"), tunnelUrl: z.string().optional().describe("Tunnel URL to store (only for 'store' action)") }); // Add schema for debug memory const DebugMemorySchema = z.object({ action: z.enum(["save", "retrieve", "list", "clear"]).describe("Memory action to perform"), key: z.string().optional().describe("Memory key for save/retrieve operations"), value: z.any().optional().describe("Value to save (for 'save' action)"), category: z.enum(["env", "urls", "selectors", "issues", "fixes", "general"]).optional().describe("Category of memory item") }); // Create a class to manage the debug state and tools class AIVisionDebugServer { private server: Server; constructor() { this.server = new Server( { name: 'ai-vision-debug', version: '1.0.0', }, { capabilities: { resources: { listChanged: true }, tools: { listChanged: true }, }, } ); this.setupRequestHandlers(); this.server.onerror = (error) => logToFile(`[MCP Error] ${error}`); process.on('SIGINT', async () => { await this.cleanup(); process.exit(0); }); } private async cleanup() { try { if (activePage) { await activePage.close().catch(() => {}); activePage = null; } if (browserContext) { await browserContext.close().catch(() => {}); browserContext = null; } if (browserInstance) { await browserInstance.close().catch(() => {}); browserInstance = null; } if (apiContext) { await apiContext.dispose().catch(() => {}); apiContext = null; } await this.server.close(); } catch (error) { logToFile(`Cleanup error: ${error}`); } } /** * Ensure browser is initialized */ private async ensureBrowser(viewportWidth = 1280, viewportHeight = 800, deviceName?: string) { try { if (!browserInstance) { logToFile('Initializing browser...'); browserInstance = await chromium.launch({ headless: true }); } // Always create a new context with the specified settings if (browserContext) { await browserContext.close().catch(() => {}); } const contextOptions: any = {}; if (deviceName && devices[deviceName]) { contextOptions.userAgent = devices[deviceName].userAgent; contextOptions.viewport = devices[deviceName].viewport; contextOptions.deviceScaleFactor = devices[deviceName].deviceScaleFactor; contextOptions.isMobile = devices[deviceName].isMobile; contextOptions.hasTouch = devices[deviceName].hasTouch; } else { contextOptions.viewport = { width: viewportWidth, height: viewportHeight }; contextOptions.userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'; } browserContext = await browserInstance.newContext(contextOptions); // Create a new page if (activePage) { await activePage.close().catch(() => {}); } activePage = await browserContext.newPage(); // Set default timeout to 4 seconds for all operations activePage.setDefaultTimeout(4000); activePage.setDefaultNavigationTimeout(4000); // Set up console logging activePage.on('console', (message: import('playwright').ConsoleMessage) => { const logEntry = `[${message.type()}] ${message.text()}`; consoleLogs.push(logEntry); }); return activePage; } catch (error) { logToFile(`Error ensuring browser: ${error}`); throw error; } } /** * Ensure API context is initialized */ private async ensureApiContext(baseUrl?: string) { try { if (!apiContext) { const options: any = {}; if (baseUrl) { options.baseURL = baseUrl; } apiContext = await request.newContext(options); } return apiContext; } catch (error) { logToFile(`Error ensuring API context: ${error}`); throw error; } } /** * Take a screenshot of a URL using Playwright */ private async screenshotUrl( url: string, fullPage: boolean = false, selector?: string, waitForSelector?: string, waitTime: number = 1000, deviceName?: string ): Promise<{ path: string, fileUuid: string, base64Data: string }> { try { logToFile(`Taking screenshot of URL: ${url}`); // Ensure browser is initialized with proper viewport const page = await this.ensureBrowser(1280, 800, deviceName); // Navigate to the URL with timeout await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 4000 }); // Wait for specified time await page.waitForTimeout(Math.min(waitTime, 4000)); // Wait for selector if specified if (waitForSelector) { await page.waitForSelector(waitForSelector, { timeout: 4000 }); } // Generate a UUID for the file const fileUuid = randomUUID(); const screenshotPath = path.join(TEMP_DIR, `screenshot_${fileUuid}.png`); // Take the screenshot if (selector) { const element = await page.$(selector); if (!element) { throw new Error(`Element not found: ${selector}`); } await element.screenshot({ path: screenshotPath }); } else { await page.screenshot({ path: screenshotPath, fullPage: fullPage }); } // Get the base64 data of the screenshot const buffer = await fsPromises.readFile(screenshotPath); const base64Data = buffer.toString('base64'); // Update the debug session debugSession.currentUrl = url; debugSession.lastScreenshotPath = screenshotPath; debugSession.debugHistory.push(`Screenshot taken of ${url}`); // Add to screenshots collection for resource access const screenshotName = `Screenshot_${new Date().toISOString().replace(/[:.]/g, '-')}`; screenshots.set(screenshotName, base64Data); logToFile(`Screenshot saved to ${screenshotPath}`); return { path: screenshotPath, fileUuid, base64Data }; } catch (error: any) { logToFile(`Error taking screenshot: ${error}`); throw new Error(`Failed to take screenshot of URL ${url}: ${error.message}`); } } /** * Enhanced Page Analyzer tool that combines screenshot, console logs, and interactive element mapping */ private async enhancedPageAnalyzer( url: string, includeConsole: boolean = true, mapElements: boolean = true, fullPage: boolean = false, waitForSelector?: string, waitTime: number = 3000, deviceName?: string ): Promise<{ screenshot: { path: string, base64Data: string }, annotatedScreenshot?: { path: string, base64Data: string }, consoleMessages?: ConsoleMessage[], interactiveElements?: InteractiveElement[], accessibility?: any, performance?: any, pageInfo: { title: string, url: string, loadTime: number, resources?: any } }> { try { logToFile(`Analyzing page: ${url}`); // Ensure browser is initialized with proper viewport const page = await this.ensureBrowser(1280, 800, deviceName); let consoleMessages: ConsoleMessage[] = []; // Capture console output if requested if (includeConsole) { const originalConsoleLog = console.log; page.on('console', (message: import('playwright').ConsoleMessage) => { consoleMessages.push({ type: message.type(), text: message.text(), location: { url: message.location()?.url, lineNumber: message.location()?.lineNumber, columnNumber: message.location()?.columnNumber } }); }); } // Measure page load time and capture resource information const resourcesRequested = new Set(); const resourcesFailed = new Set(); const resourcesReceived = new Set(); page.on('request', (request: import('playwright').Request) => resourcesRequested.add(request.url())); page.on('requestfailed', (request: import('playwright').Request) => resourcesFailed.add(request.url())); page.on('response', (response: import('playwright').Response) => { const status = response.status(); const url = response.url(); if (status >= 400) { resourcesFailed.add(`${url} (${status})`); } else { resourcesReceived.add(url); } }); // Measure page load time const startTime = Date.now(); // Navigate to the URL with timeout await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 4000 }); const loadTime = Date.now() - startTime; // Wait for specified time await page.waitForTimeout(Math.min(waitTime, 4000)); // Wait for selector if specified if (waitForSelector) { await page.waitForSelector(waitForSelector, { timeout: 4000 }); } // Get page title const title = await page.title(); // Generate a UUID for the file const fileUuid = randomUUID(); const screenshotPath = path.join(TEMP_DIR, `analysis_${fileUuid}.png`); // Take the screenshot await page.screenshot({ path: screenshotPath, fullPage: fullPage }); // Get the base64 data of the screenshot const buffer = await fsPromises.readFile(screenshotPath); const base64Data = buffer.toString('base64'); // Collect performance metrics const performanceMetrics = await page.evaluate(() => { const performance = window.performance; if (!performance) return null; const timing = performance.timing || {}; const memory = (performance as any).memory || {}; const navigation = performance.navigation || {}; // Get important timing measures const pageLoadTime = timing.loadEventEnd - timing.navigationStart; const dnsLookupTime = timing.domainLookupEnd - timing.domainLookupStart; const tcpConnectionTime = timing.connectEnd - timing.connectStart; const serverResponseTime = timing.responseEnd - timing.requestStart; const domInteractive = timing.domInteractive - timing.navigationStart; const domContentLoaded = timing.domContentLoadedEventEnd - timing.navigationStart; // Get resource performance entries if available let resources: Array<Record<string, any>> = []; try { resources = performance.getEntriesByType('resource').map(entry => { const e = entry as any; return { name: e.name, entryType: e.entryType, startTime: e.startTime, duration: e.duration, initiatorType: e.initiatorType, transferSize: e.transferSize, encodedBodySize: e.encodedBodySize, decodedBodySize: e.decodedBodySize }; }); } catch (e) { // Ignore if not available } return { pageLoadTime, dnsLookupTime, tcpConnectionTime, serverResponseTime, domInteractive, domContentLoaded, redirectCount: navigation.redirectCount, navigationType: navigation.type, memory: { jsHeapSizeLimit: memory.jsHeapSizeLimit, totalJSHeapSize: memory.totalJSHeapSize, usedJSHeapSize: memory.usedJSHeapSize }, resources: resources.slice(0, 20) // Limit to first 20 resources to avoid excessive data }; }); let interactiveElements: InteractiveElement[] = []; let annotatedScreenshot: { path: string, base64Data: string } | undefined; // Map interactive elements if requested if (mapElements) { interactiveElements = await page.evaluate(() => { function isVisible(element: Element): boolean { if (!element.getBoundingClientRect) return false; const rect = element.getBoundingClientRect(); return ( rect.width > 0 && rect.height > 0 && window.getComputedStyle(element).visibility !== 'hidden' && window.getComputedStyle(element).display !== 'none' ); } function getElementPath(element: Element | null): string { if (!element) return ''; if (element === document.body) return 'body'; if (element === document.documentElement) return 'html'; let path = element.tagName.toLowerCase(); if (element.id) { path += `#${element.id}`; } else if (element.className && typeof element.className === 'string') { path += `.${element.className.trim().replace(/\s+/g, '.')}`; } return `${getElementPath(element.parentElement)} > ${path}`; } // Find interactive elements const interactiveElements: Array<{ index: number; tagName: string; id: string; className: string; text: string; bounds: { x: number; y: number; width: number; height: number }; path: string; visible: boolean; type: string; }> = []; let index = 0; // Helper function to collect interactive elements function collectElements(element: Element) { // Check if element is interactive const tagName = element.tagName.toLowerCase(); const isButton = tagName === 'button' || (tagName === 'input' && (element as HTMLInputElement).type === 'button') || (tagName === 'input' && (element as HTMLInputElement).type === 'submit'); const isLink = tagName === 'a' && (element as HTMLAnchorElement).href; const hasClickListener = element.hasAttribute('onclick') || element.hasAttribute('ng-click') || element.hasAttribute('@click'); const isInput = tagName === 'input' || tagName === 'textarea' || tagName === 'select'; if ((isButton || isLink || hasClickListener || isInput) && isVisible(element)) { const rect = element.getBoundingClientRect(); let text = ''; if (element.textContent) { text = element.textContent.trim().substring(0, 50); } let type = 'unknown'; if (isButton) type = 'button'; else if (isLink) type = 'link'; else if (isInput) type = 'input'; else if (hasClickListener) type = 'clickable'; interactiveElements.push({ index: ++index, tagName, id: element.id || '', className: typeof element.className === 'string' ? element.className : '', text, bounds: { x: rect.left, y: rect.top, width: rect.width, height: rect.height, }, path: getElementPath(element), visible: isVisible(element), type }); } // Process children for (const child of Array.from(element.children)) { collectElements(child); } } // Start from body collectElements(document.body); return interactiveElements; }); // Create annotated screenshot with numbered interactive elements if (interactiveElements.length > 0) { try { const image = await Jimp.read(screenshotPath); const font = await Jimp.loadFont(Jimp.FONT_SANS_16_WHITE); // Draw numbered boxes around interactive elements for (const element of interactiveElements) { const { x, y, width, height } = element.bounds; // Draw rectangle for (let i = 0; i < width; i++) { if (x + i < image.getWidth()) { if (y < image.getHeight()) image.setPixelColor(0xFF0000FF, x + i, y); // Top if (y + height - 1 < image.getHeight()) image.setPixelColor(0xFF0000FF, x + i, y + height - 1); // Bottom } } for (let i = 0; i < height; i++) { if (y + i < image.getHeight()) { if (x < image.getWidth()) image.setPixelColor(0xFF0000FF, x, y + i); // Left if (x + width - 1 < image.getWidth()) image.setPixelColor(0xFF0000FF, x + width - 1, y + i); // Right } } // Draw label background (small square) for (let i = 0; i < 20; i++) { for (let j = 0; j < 20; j++) { if (x + i < image.getWidth() && y + j < image.getHeight()) { image.setPixelColor(0xFF0000FF, x + i, y + j); } } } // Print element index if (x + 5 < image.getWidth() && y + 2 < image.getHeight()) { image.print(font, x + 5, y + 2, element.index.toString()); } } const annotatedPath = path.join(TEMP_DIR, `annotated_${fileUuid}.png`); await image.writeAsync(annotatedPath); const annotatedBuffer = await fsPromises.readFile(annotatedPath); annotatedScreenshot = { path: annotatedPath, base64Data: annotatedBuffer.toString('base64') }; // Add to screenshots collection for resource access const annotatedName = `Annotated_${new Date().toISOString().replace(/[:.]/g, '-')}`; screenshots.set(annotatedName, annotatedScreenshot.base64Data); } catch (error) { logToFile(`Error creating annotated screenshot: ${error}`); // Continue without annotated screenshot } } } // Run a basic accessibility check const accessibilityViolations = await page.evaluate(() => { // Simple accessibility checks we can run directly const violations = []; // Check for images without alt attributes const imagesWithoutAlt = document.querySelectorAll('img:not([alt])'); if (imagesWithoutAlt.length > 0) { violations.push({ rule: 'Images must have alt attributes', elements: Array.from(imagesWithoutAlt).map(el => ({ html: el.outerHTML.substring(0, 100), location: el.getBoundingClientRect() })).slice(0, 5) // Limit to 5 examples }); } // Check for insufficient color contrast (basic check) const textElements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, span, a, button, label'); const lowContrastElements = []; for (const el of Array.from(textElements)) { const style = window.getComputedStyle(el); const bgColor = style.backgroundColor; const color = style.color; // This is a very basic check - a real implementation would calculate actual contrast ratios if (bgColor === 'transparent' || bgColor === 'rgba(0, 0, 0, 0)') { continue; // Skip elements with transparent backgrounds } if (color === bgColor) { lowContrastElements.push({ html: el.outerHTML.substring(0, 100), location: el.getBoundingClientRect(), foreground: color, background: bgColor }); } } if (lowContrastElements.length > 0) { violations.push({ rule: 'Text should have sufficient contrast with its background', elements: lowContrastElements.slice(0, 5) // Limit to 5 examples }); } // Check for missing form labels const inputsWithoutLabels = []; const inputs = document.querySelectorAll('input, select, textarea'); for (const input of Array.from(inputs)) { const id = input.id; if (!id) { inputsWithoutLabels.push({ html: input.outerHTML.substring(0, 100), location: input.getBoundingClientRect() }); continue; } const label = document.querySelector(`label[for="${id}"]`); if (!label) { inputsWithoutLabels.push({ html: input.outerHTML.substring(0, 100), location: input.getBoundingClientRect() }); } } if (inputsWithoutLabels.length > 0) { violations.push({ rule: 'Form inputs should have associated labels', elements: inputsWithoutLabels.slice(0, 5) // Limit to 5 examples }); } // Check for missing lang attribute if (!document.documentElement.hasAttribute('lang')) { violations.push({ rule: 'HTML element should have a lang attribute', elements: [{ html: document.documentElement.outerHTML.substring(0, 100) }] }); } return violations; }); // Add to screenshots collection for resource access const screenshotName = `Screenshot_${new Date().toISOString().replace(/[:.]/g, '-')}`; screenshots.set(screenshotName, base64Data); // Return the results return { screenshot: { path: screenshotPath, base64Data }, annotatedScreenshot, consoleMessages: includeConsole ? consoleMessages : undefined, interactiveElements: mapElements ? interactiveElements : undefined, accessibility: accessibilityViolations, performance: performanceMetrics, pageInfo: { title, url: page.url(), loadTime, resources: { requested: Array.from(resourcesRequested).slice(0, 20), failed: Array.from(resourcesFailed), received: Array.from(resourcesReceived).slice(0, 20) } } }; } catch (error: any) { logToFile(`Error analyzing page: ${error}`); throw new Error(`Failed to analyze page ${url}: ${error.message}`); } } /** * API Endpoint Tester tool */ private async testApiEndpoints( baseUrl: string, endpoints: Array<{ path: string; method: string; data?: any; headers?: Record<string, string>; }>, authToken?: string ): Promise<{ results: Array<{ endpoint: string; method: string; status: number; responseTime: number; responseData?: any; error?: string; requestHeaders?: any; requestBody?: any; }>; successRate: number; averageResponseTime: number; errorSummary?: any; }> { try { logToFile(`Testing API endpoints at base URL: ${baseUrl}`); // Ensure API context const apiContext = await this.ensureApiContext(baseUrl); const results: Array<{ endpoint: string; method: string; status: number; responseTime: number; responseData?: any; error?: string; requestHeaders?: any; requestBody?: any; }> = []; // Process each endpoint for (const endpoint of endpoints) { try { const fullUrl = endpoint.path.startsWith('http') ? endpoint.path : new URL(endpoint.path, baseUrl).toString(); logToFile(`Testing endpoint: ${endpoint.method} ${fullUrl}`); const startTime = Date.now(); // Prepare headers const headers: Record<string, string> = { 'Content-Type': 'application/json', ...endpoint.headers }; // Add auth token if provided if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } // Process request body for non-GET requests let requestBody = null; if (endpoint.data) { requestBody = typeof endpoint.data === 'object' ? JSON.stringify(endpoint.data) : endpoint.data; } // Make the request const requestOptions: any = { method: endpoint.method, headers }; if (requestBody && endpoint.method !== 'GET') { requestOptions.data = requestBody; } const response = await apiContext.fetch(endpoint.path, requestOptions); const responseTime = Date.now() - startTime; // Parse response data let responseData; let responseText = ''; try { responseText = await response.text(); responseData = responseText ? JSON.parse(responseText) : null; } catch (e) { // Response wasn't JSON responseData = responseText || null; } // Add to results results.push({ endpoint: endpoint.path, method: endpoint.method, status: response.status(), responseTime, responseData, requestHeaders: headers, requestBody }); } catch (error: any) { // Handle errors for this endpoint results.push({ endpoint: endpoint.path, method: endpoint.method, status: 0, responseTime: 0, error: error.message, requestHeaders: endpoint.headers, requestBody: endpoint.data }); } } // Calculate success rate and average response time const successfulRequests = results.filter(r => r.status >= 200 && r.status < 300).length; const successRate = (successfulRequests / results.length) * 100; const totalResponseTime = results.reduce((total, r) => total + r.responseTime, 0); const averageResponseTime = results.length > 0 ? totalResponseTime / results.length : 0; // Analyze errors by type const errorSummary = { serverErrors: results.filter(r => r.status >= 500 && r.status < 600).length, clientErrors: results.filter(r => r.status >= 400 && r.status < 500).length, connectionErrors: results.filter(r => r.status === 0).length, responseTimeouts: results.filter(r => r.responseTime > 5000).length, }; logToFile(`API testing completed with ${successfulRequests}/${results.length} successful endpoints`); return { results, successRate, averageResponseTime, errorSummary }; } catch (error: any) { logToFile(`Error testing API endpoints: ${error}`); throw new Error(`Failed to test API endpoints: ${error.message}`); } } /** * Navigation Flow Validator tool */ private async validateNavigationFlow( startUrl: string, steps: Array<{ action: 'click' | 'fill' | 'select' | 'hover' | 'wait' | 'navigate' | 'evaluate'; selector?: string; value?: string; url?: string; script?: string; waitTime?: number; }>, captureScreenshots: boolean = true, includeConsole: boolean = true, deviceName?: string ): Promise<{ success: boolean; steps: Array<{ stepNumber: number; action: string; success: boolean; error?: string; screenshotPath?: string; screenshotBase64?: string; consoleMessages?: ConsoleMessage[]; url?: string; evaluationResult?: any; selector?: string; value?: string; }>; }> { try { logToFile(`Validating navigation flow starting at: ${startUrl}`); // Ensure browser is initialized with proper viewport const page = await this.ensureBrowser(1280, 800, deviceName); let consoleMessages: ConsoleMessage[] = []; // Capture console output if requested if (includeConsole) { page.on('console', (message: import('playwright').ConsoleMessage) => { consoleMessages.push({ type: message.type(), text: message.text(), location: { url: message.location()?.url, lineNumber: message.location()?.lineNumber, columnNumber: message.location()?.columnNumber } }); }); } // Navigate to the starting URL await page.goto(startUrl, { waitUntil: 'networkidle' }); // Initialize results const stepResults: Array<{ stepNumber: number; action: string; success: boolean; error?: string; screenshotPath?: string; screenshotBase64?: string; consoleMessages?: ConsoleMessage[]; url?: string; evaluationResult?: any; selector?: string; value?: string; }> = []; // Track overall success let overallSuccess = true; // Process each step for (const [index, currentStep] of steps.entries()) { const stepNumber = index + 1; consoleMessages = []; try { // Log step logToFile(`Executing step ${stepNumber}: ${currentStep.action}`); // Fix variable declarations at the beginning of the try block in validateNavigationFlow let success = false; // Declare stepEvaluationResult outside the switch statement to use it later in the code let stepEvaluationResult: any; // Perform action based on step type switch (currentStep.action) { case 'click': if (!currentStep.selector) throw new Error('Selector is required for click action'); await page.click(currentStep.selector); stepEvaluationResult = true; success = true; break; case 'fill': if (!currentStep.selector) throw new Error('Selector is required for fill action'); if (!currentStep.value) throw new Error('Value is required for fill action'); await page.fill(currentStep.selector, currentStep.value); success = true; break; case 'select': if (!currentStep.selector) throw new Error('Selector is required for select action'); if (!currentStep.value) throw new Error('Value is required for select action'); await page.selectOption(currentStep.selector, currentStep.value); success = true; break; case 'hover': if (!currentStep.selector) throw new Error('Selector is required for hover action'); await page.hover(currentStep.selector); success = true; break; case 'wait': await page.waitForTimeout(currentStep.waitTime || 1000); success = true; break; case 'navigate': if (!currentStep.url) throw new Error('URL is required for navigate action'); await page.goto(currentStep.url, { waitUntil: 'domcontentloaded', timeout: 4000 }); success = true; break; case 'evaluate': if (!currentStep.script) throw new Error('Script is required for evaluate action'); const evaluationResult = await page.evaluate(currentStep.script); // Store the evaluation result for inclusion in the step result stepEvaluationResult = evaluationResult; success = true; break; default: throw new Error(`Unknown action: ${currentStep.action}`); } // Wait for any potential navigation or Ajax calls await page.waitForTimeout(1000); // Capture screenshot if requested let screenshotPath, screenshotBase64; if (captureScreenshots) { const fileUuid = randomUUID(); screenshotPath = path.join(TEMP_DIR, `step_${stepNumber}_${fileUuid}.png`); await page.screenshot({ path: screenshotPath }); const buffer = await fsPromises.readFile(screenshotPath); screenshotBase64 = buffer.toString('base64'); // Add to screenshots collection for resource access const screenshotName = `Step_${stepNumber}_${new Date().toISOString().replace(/[:.]/g, '-')}`; screenshots.set(screenshotName, screenshotBase64); } // Add to results stepResults.push({ stepNumber, action: currentStep.action, success, screenshotPath: screenshotPath || undefined, screenshotBase64: screenshotBase64 || undefined, consoleMessages, url: await page.url(), selector: currentStep.selector, value: currentStep.value, evaluationResult: stepEvaluationResult }); } catch (error: any) { // Handle error and add failed step to results array const errorMessage = error instanceof Error ? error.message : String(error); logToFile(`Step ${stepNumber} failed: ${errorMessage}`); stepResults.push({ stepNumber, action: currentStep.action, success: false, error: errorMessage, url: await page.url(), selector: currentStep.selector, value: currentStep.value }); // Stop execution on failure overallSuccess = false; break; } } logToFile(`Navigation flow validation completed with ${overallSuccess ? 'success' : 'failure'}`); return { success: overallSuccess, steps: stepResults }; } catch (error: any) { logToFile(`Error validating navigation flow: ${error}`); throw new Error(`Failed to validate navigation flow: ${error.message}`); } } /** * DOM Inspector tool */ private async inspectDomElement( url: string, selector: string, includeChildren: boolean = false, includeStyles: boolean = true, waitTime: number = 1000 ): Promise<{ element: { tagName: string; id: string; className: string; attributes: Record<string, string>; textContent: string; html: string; computedStyles?: Record<string, string>; children?: any[]; bounds: { x: number; y: number; width: number; height: number }; accessibility?: any; }; screenshot?: { path: string; base64Data: string }; }> { try { logToFile(`Inspecting DOM element: ${selector} at ${url}`); // Ensure browser is initialized const page = await this.ensureBrowser(); // Navigate to the URL with timeout await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 4000 }); // Wait for specified time await page.waitForTimeout(Math.min(waitTime, 4000)); // Check if element exists const elementHandle = await page.$(selector); if (!elementHandle) { throw new Error(`Element not found: ${selector}`); } // Get element details using a type safe approach const elementInfo = await page.evaluate( // Explicitly define the function signature expecting a single object (args: { selector: string; includeChildren: boolean; includeStyles: boolean }) => { const { selector, includeChildren, includeStyles } = args; const element = document.querySelector(selector); if (!element) return null; const attributes: Record<string, string> = {}; for (const attr of Array.from(element.attributes)) { attributes[attr.name] = attr.value; } // Get computed styles if requested let computedStyles: Record<string, string> | undefined; if (includeStyles) { computedStyles = {}; const styles = window.getComputedStyle(element); for (const style of Array.from(styles)) { computedStyles[style] = styles.getPropertyValue(style); } } // Get children if requested let children: Array<{ tagName: string; id: string; className: string; attributes: Record<string, string>; textContent: string; html: string; }> | undefined; if (includeChildren) { children = Array.from(element.children).map(child => { // Get child attributes const childAttributes: Record<string, string> = {}; if (child.attributes) { for (const attr of Array.from(child.attributes)) { childAttributes[attr.name] = attr.value; } } return { tagName: child.tagName, id: child.id || '', className: typeof child.className === 'string' ? child.className : '', attributes: childAttributes, textContent: child.textContent ? child.textContent.trim() : '', html: child.outerHTML }; }); } // Get element bounds const bounds = element.getBoundingClientRect(); // Basic accessibility info const accessibility = { ariaLabel: element.getAttribute('aria-label') || '', ariaRole: element.getAttribute('role') || '', tabIndex: element.getAttribute('tabindex') || '', hasKeyboardFocus: element === document.activeElement }; return { tagName: element.tagName, id: element.id || '', className: typeof element.className === 'string' ? element.className : '', attributes, textContent: element.textContent ? element.textContent.trim() : '', html: element.outerHTML, computedStyles, children, bounds: { x: bounds.x, y: bounds.y, width: bounds.width,