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
JavaScript
/**
* 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