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

554 lines 22.8 kB
/** * Visual Validation Tools * * Provides visual validation capabilities for UI components including: * - Visual regression testing * - Element visibility checks * - Layout validation * - Component rendering verification * - Map/chart validation */ import sharp from 'sharp'; export class VisualValidationTools { /** * Validate that a UI component is properly rendered */ static async validateComponent(page, selector, options = {}) { const issues = []; const metrics = {}; const startTime = Date.now(); try { // Wait for element to exist const element = await page.waitForSelector(selector, { state: 'attached', timeout: options.timeout || 10000 }).catch(() => null); if (!element) { issues.push({ type: 'missing', severity: 'critical', message: `Element "${selector}" not found`, element: selector }); return { passed: false, issues, screenshots: { current: await this.captureScreenshot(page) }, metrics }; } // Check visibility if (options.checkVisibility !== false) { const isVisible = await element.isVisible(); if (!isVisible) { issues.push({ type: 'hidden', severity: 'critical', message: `Element "${selector}" is not visible`, element: selector }); } // Check if element is obscured const isObscured = await this.isElementObscured(page, element); if (isObscured) { issues.push({ type: 'hidden', severity: 'warning', message: `Element "${selector}" might be obscured by other elements`, element: selector }); } } // Check dimensions const box = await element.boundingBox(); if (box) { metrics.visibleArea = box.width * box.height; if (options.expectedDimensions) { if (options.expectedDimensions.minWidth && box.width < options.expectedDimensions.minWidth) { issues.push({ type: 'misaligned', severity: 'warning', message: `Element width (${box.width}px) is less than expected (${options.expectedDimensions.minWidth}px)`, element: selector, expected: options.expectedDimensions.minWidth, actual: box.width }); } if (options.expectedDimensions.minHeight && box.height < options.expectedDimensions.minHeight) { issues.push({ type: 'misaligned', severity: 'warning', message: `Element height (${box.height}px) is less than expected (${options.expectedDimensions.minHeight}px)`, element: selector, expected: options.expectedDimensions.minHeight, actual: box.height }); } } } // Check interactivity if (options.checkInteractivity) { const interactiveCount = await page.evaluate((sel) => { const el = document.querySelector(sel); if (!el) return 0; const interactive = el.querySelectorAll('button, a, input, select, textarea, [onclick], [role="button"]'); return interactive.length; }, selector); metrics.interactiveElements = interactiveCount; if (interactiveCount === 0) { issues.push({ type: 'interaction', severity: 'info', message: `No interactive elements found in "${selector}"`, element: selector }); } } // Check layout if (options.checkLayout) { const layoutIssues = await this.checkLayout(page, selector); issues.push(...layoutIssues); } metrics.renderTime = Date.now() - startTime; // Take screenshot const screenshot = await this.captureElementScreenshot(page, element); return { passed: issues.filter(i => i.severity === 'critical').length === 0, issues, screenshots: { current: screenshot }, metrics }; } catch (error) { issues.push({ type: 'rendering', severity: 'critical', message: `Validation error: ${error instanceof Error ? error.message : String(error)}`, element: selector }); return { passed: false, issues, screenshots: { current: await this.captureScreenshot(page) }, metrics }; } } /** * Validate Google Maps or similar map components */ static async validateMap(page, mapSelector = '[id*="map"], .map-container, #map', options = {}) { console.error('🗺️ Validating map component...'); // First do basic component validation const basicValidation = await this.validateComponent(page, mapSelector, { checkVisibility: true, expectedDimensions: { minWidth: 200, minHeight: 200 }, timeout: options.timeout }); // Map-specific validation const mapSpecific = await page.evaluate((selector) => { const mapEl = document.querySelector(selector); if (!mapEl) { return { hasMapContainer: false, hasMapTiles: false, hasMarkers: false, markerCount: 0, loadErrors: ['Map container not found'] }; } // Check for Google Maps const isGoogleMaps = !!window.google?.maps; const hasGoogleMapDiv = !!mapEl.querySelector('.gm-style'); // Check for Leaflet const isLeaflet = !!window.L?.map; const hasLeafletDiv = !!mapEl.querySelector('.leaflet-container'); // Check for Mapbox const isMapbox = !!window.mapboxgl; const hasMapboxDiv = !!mapEl.querySelector('.mapboxgl-canvas'); // Determine provider let provider = 'unknown'; if (isGoogleMaps || hasGoogleMapDiv) provider = 'google'; else if (isLeaflet || hasLeafletDiv) provider = 'leaflet'; else if (isMapbox || hasMapboxDiv) provider = 'mapbox'; // Check for map tiles (images that make up the map) const tiles = mapEl.querySelectorAll('img[src*="tile"], img[src*="map"], .leaflet-tile, canvas'); const hasTiles = tiles.length > 0; // Check for markers const markers = mapEl.querySelectorAll('.gm-style img[src*="marker"], ' + // Google Maps markers '.leaflet-marker-icon, ' + // Leaflet markers '.mapboxgl-marker, ' + // Mapbox markers '[class*="marker"], ' + // Generic markers 'img[alt*="marker"]'); // Collect any map-related errors from console const errors = []; // Check Google Maps specific errors if (provider === 'google') { const googleErrors = document.querySelectorAll('.gm-err-container'); googleErrors.forEach(err => { errors.push(err.textContent || 'Google Maps error'); }); // Check if API key is in the URL const scripts = Array.from(document.querySelectorAll('script[src*="maps.googleapis.com"]')); const hasApiKey = scripts.some(s => s.getAttribute('src')?.includes('key=')); if (!hasApiKey) { errors.push('Google Maps API key not found in script tag'); } } return { hasMapContainer: true, hasMapTiles: hasTiles, hasMarkers: markers.length > 0, markerCount: markers.length, mapProvider: provider, loadErrors: errors }; }, mapSelector); // Check for API key issues in network requests let apiKeyStatus = 'valid'; if (options.checkApiKey) { const apiKeyCheck = await page.evaluate(() => { // Check for Google Maps API errors if (window.google?.maps) { // Google Maps loaded successfully return 'valid'; } // Check for error messages in the DOM const errorMessages = Array.from(document.querySelectorAll('*')).filter(el => { const text = el.textContent || ''; return text.includes('API key') || text.includes('InvalidKeyMapError') || text.includes('RefererNotAllowedMapError'); }); if (errorMessages.length > 0) { return 'invalid'; } return 'unknown'; }); if (apiKeyCheck === 'invalid') { apiKeyStatus = 'invalid'; mapSpecific.loadErrors.push('API key validation failed'); } } // Add map-specific issues if (!mapSpecific.hasMapContainer) { basicValidation.issues.push({ type: 'missing', severity: 'critical', message: 'Map container element not found', element: mapSelector }); } if (!mapSpecific.hasMapTiles) { basicValidation.issues.push({ type: 'rendering', severity: 'critical', message: 'Map tiles are not loading - map appears blank', element: mapSelector }); } if (options.checkMarkers && !mapSpecific.hasMarkers) { basicValidation.issues.push({ type: 'missing', severity: 'warning', message: 'No map markers found', element: mapSelector }); } if (options.expectedMarkerCount && mapSpecific.markerCount !== options.expectedMarkerCount) { basicValidation.issues.push({ type: 'rendering', severity: 'warning', message: `Expected ${options.expectedMarkerCount} markers but found ${mapSpecific.markerCount}`, element: mapSelector, expected: options.expectedMarkerCount, actual: mapSpecific.markerCount }); } if (mapSpecific.loadErrors.length > 0) { mapSpecific.loadErrors.forEach(error => { basicValidation.issues.push({ type: 'rendering', severity: 'critical', message: error, element: mapSelector }); }); } // Log validation results console.error('🗺️ Map validation complete:', { provider: mapSpecific.mapProvider, hasTiles: mapSpecific.hasMapTiles, markerCount: mapSpecific.markerCount, errors: mapSpecific.loadErrors.length }); return { ...basicValidation, mapSpecific: { ...mapSpecific, apiKeyStatus } }; } /** * Perform visual regression testing */ static async visualRegression(page, selector, baselineImage, options = {}) { const threshold = options.threshold || 5; // 5% difference allowed by default try { // Capture current screenshot const element = await page.$(selector); if (!element) { return { passed: false, issues: [{ type: 'missing', severity: 'critical', message: `Element "${selector}" not found for visual regression`, element: selector }], screenshots: { current: await this.captureScreenshot(page) }, metrics: {} }; } const currentScreenshot = await this.captureElementScreenshot(page, element); if (!baselineImage) { // No baseline, just return current return { passed: true, issues: [{ type: 'missing', severity: 'info', message: 'No baseline image provided. Current image captured as baseline.', element: selector }], screenshots: { current: currentScreenshot }, metrics: {} }; } // Compare images const comparison = await this.compareImages(Buffer.from(baselineImage, 'base64'), Buffer.from(currentScreenshot, 'base64'), options.highlightDifferences); const issues = []; if (comparison.diffPercentage > threshold) { issues.push({ type: 'rendering', severity: 'critical', message: `Visual regression detected: ${comparison.diffPercentage.toFixed(2)}% difference (threshold: ${threshold}%)`, element: selector, expected: `≤ ${threshold}% difference`, actual: `${comparison.diffPercentage.toFixed(2)}% difference` }); } return { passed: comparison.diffPercentage <= threshold, issues, screenshots: { baseline: baselineImage, current: currentScreenshot, diff: comparison.diffImage }, metrics: { visualDifference: comparison.diffPercentage, pixelsDifferent: comparison.diffPixels } }; } catch (error) { return { passed: false, issues: [{ type: 'rendering', severity: 'critical', message: `Visual regression test failed: ${error instanceof Error ? error.message : String(error)}`, element: selector }], screenshots: { current: await this.captureScreenshot(page) }, metrics: {} }; } } /** * Check if element is obscured by other elements */ static async isElementObscured(page, element) { return await page.evaluate((el) => { const rect = el.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const topElement = document.elementFromPoint(centerX, centerY); return topElement !== el && !el.contains(topElement); }, element); } /** * Check layout issues */ static async checkLayout(page, selector) { return await page.evaluate((sel) => { const issues = []; const element = document.querySelector(sel); if (!element) return issues; const rect = element.getBoundingClientRect(); const styles = window.getComputedStyle(element); // Check if element is off-screen if (rect.right < 0 || rect.left > window.innerWidth) { issues.push({ type: 'misaligned', severity: 'warning', message: 'Element is horizontally off-screen', element: sel }); } if (rect.bottom < 0 || rect.top > window.innerHeight) { issues.push({ type: 'misaligned', severity: 'warning', message: 'Element is vertically off-screen', element: sel }); } // Check for overflow if (styles.overflow === 'hidden' && (element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight)) { issues.push({ type: 'misaligned', severity: 'warning', message: 'Element content is clipped due to overflow:hidden', element: sel }); } // Check z-index issues const zIndex = parseInt(styles.zIndex) || 0; if (zIndex < 0) { issues.push({ type: 'misaligned', severity: 'info', message: 'Element has negative z-index and might be hidden behind other elements', element: sel }); } return issues; }, selector); } /** * Capture screenshot of entire page */ static async captureScreenshot(page) { try { const screenshot = await page.screenshot({ type: 'png', fullPage: false // Just viewport for performance }); return screenshot.toString('base64'); } catch (error) { console.error('Failed to capture screenshot:', error); return ''; } } /** * Capture screenshot of specific element */ static async captureElementScreenshot(page, element) { try { const screenshot = await element.screenshot({ type: 'png' }); return screenshot.toString('base64'); } catch (error) { console.error('Failed to capture element screenshot:', error); return ''; } } /** * Compare two images and generate diff */ static async compareImages(baseline, current, highlightDifferences = true) { try { const baselineImg = sharp(baseline); const currentImg = sharp(current); // Get metadata const [baselineMeta, currentMeta] = await Promise.all([ baselineImg.metadata(), currentImg.metadata() ]); // Ensure same dimensions if (baselineMeta.width !== currentMeta.width || baselineMeta.height !== currentMeta.height) { return { diffPercentage: 100, diffPixels: Math.max(baselineMeta.width * baselineMeta.height, currentMeta.width * currentMeta.height) }; } // Get raw pixel data const [baselineData, currentData] = await Promise.all([ baselineImg.raw().toBuffer(), currentImg.raw().toBuffer() ]); let diffPixels = 0; const totalPixels = baselineMeta.width * baselineMeta.height; // Create diff image if requested let diffBuffer; if (highlightDifferences) { diffBuffer = Buffer.from(currentData); // Compare pixels and highlight differences for (let i = 0; i < baselineData.length; i += 3) { // RGB channels const r1 = baselineData[i]; const g1 = baselineData[i + 1]; const b1 = baselineData[i + 2]; const r2 = currentData[i]; const g2 = currentData[i + 1]; const b2 = currentData[i + 2]; // Calculate difference const diff = Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2); if (diff > 10) { // Threshold for considering pixels different diffPixels++; // Highlight difference in red if (highlightDifferences) { diffBuffer[i] = 255; // Red diffBuffer[i + 1] = 0; // Green diffBuffer[i + 2] = 0; // Blue } } } } else { // Just count different pixels for (let i = 0; i < baselineData.length; i += 3) { const diff = Math.abs(baselineData[i] - currentData[i]) + Math.abs(baselineData[i + 1] - currentData[i + 1]) + Math.abs(baselineData[i + 2] - currentData[i + 2]); if (diff > 10) { diffPixels++; } } } const diffPercentage = (diffPixels / totalPixels) * 100; let diffImage; if (diffBuffer) { const diffImg = await sharp(diffBuffer, { raw: { width: baselineMeta.width, height: baselineMeta.height, channels: 3 } }).png().toBuffer(); diffImage = diffImg.toString('base64'); } return { diffPercentage, diffPixels, diffImage }; } catch (error) { console.error('Image comparison failed:', error); return { diffPercentage: 100, diffPixels: 0 }; } } } //# sourceMappingURL=visual-validation-tools.js.map