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
text/typescript
#!/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,