import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { prepareComponentRender } from '../visual/renderer.js';
import {
screenshotHTML,
compareImages,
saveVisualDiff,
closeBrowser
} from '../visual/screenshot.js';
export interface VisualDiffInput {
oldFile: string;
newFile: string;
outputDir?: string;
width?: number;
height?: number;
props?: Record<string, unknown>;
}
export interface VisualDiffResult {
success: boolean;
diffPercentage: number;
diffPixels: number;
dimensions: { width: number; height: number };
paths: {
old: string;
new: string;
diff: string;
combined: string;
};
summary: string;
oldScreenshot?: string; // base64
newScreenshot?: string; // base64
diffImage?: string; // base64
}
/**
* Perform a visual diff between two React component files
*/
export async function visualDiffComponents(
input: VisualDiffInput
): Promise<VisualDiffResult> {
const { oldFile, newFile, outputDir, width = 800, height = 600 } = input;
// Validate files exist
const oldPath = resolve(oldFile);
const newPath = resolve(newFile);
if (!existsSync(oldPath)) {
throw new Error(`Old file not found: ${oldPath}`);
}
if (!existsSync(newPath)) {
throw new Error(`New file not found: ${newPath}`);
}
let oldRender: { htmlPath: string; cleanup: () => void } | null = null;
let newRender: { htmlPath: string; cleanup: () => void } | null = null;
try {
// Prepare both components for rendering
const renderOptions = { width, height };
oldRender = await prepareComponentRender(oldPath, renderOptions);
newRender = await prepareComponentRender(newPath, renderOptions);
// Take screenshots
const screenshotOptions = { width, height };
const [oldScreenshot, newScreenshot] = await Promise.all([
screenshotHTML(oldRender.htmlPath, screenshotOptions),
screenshotHTML(newRender.htmlPath, screenshotOptions)
]);
// Compare images
const comparison = compareImages(oldScreenshot, newScreenshot);
// Save to files
const savedPaths = saveVisualDiff(
oldScreenshot,
newScreenshot,
comparison.diffBuffer,
outputDir
);
const paths = {
old: savedPaths.oldPath,
new: savedPaths.newPath,
diff: savedPaths.diffPath,
combined: savedPaths.combinedPath
};
// Generate summary
const summary = generateSummary(comparison.diffPercentage, comparison.diffPixels);
return {
success: true,
diffPercentage: Math.round(comparison.diffPercentage * 100) / 100,
diffPixels: comparison.diffPixels,
dimensions: {
width: comparison.width,
height: comparison.height
},
paths,
summary,
oldScreenshot: oldScreenshot.toString('base64'),
newScreenshot: newScreenshot.toString('base64'),
diffImage: comparison.diffBuffer.toString('base64')
};
} finally {
// Cleanup temporary files
oldRender?.cleanup();
newRender?.cleanup();
}
}
function generateSummary(diffPercentage: number, diffPixels: number): string {
if (diffPercentage === 0) {
return 'No visual differences detected. The components render identically.';
}
if (diffPercentage < 1) {
return `Minor visual differences detected (${diffPercentage.toFixed(2)}% changed, ${diffPixels} pixels). ` +
'This may be due to anti-aliasing or sub-pixel rendering differences.';
}
if (diffPercentage < 5) {
return `Small visual changes detected (${diffPercentage.toFixed(2)}% changed, ${diffPixels} pixels). ` +
'Likely minor style adjustments such as spacing or color tweaks.';
}
if (diffPercentage < 20) {
return `Moderate visual changes detected (${diffPercentage.toFixed(2)}% changed, ${diffPixels} pixels). ` +
'Notable style changes including layout or significant color differences.';
}
if (diffPercentage < 50) {
return `Significant visual changes detected (${diffPercentage.toFixed(2)}% changed, ${diffPixels} pixels). ` +
'Major layout or design changes.';
}
return `Major visual redesign detected (${diffPercentage.toFixed(2)}% changed, ${diffPixels} pixels). ` +
'The component appearance has changed substantially.';
}
/**
* Format the result for MCP output
*/
export function formatVisualDiffResult(result: VisualDiffResult): string {
const lines: string[] = [];
lines.push('# Visual Diff Report');
lines.push('');
lines.push(`## Summary`);
lines.push(result.summary);
lines.push('');
lines.push('## Statistics');
lines.push(`- **Difference**: ${result.diffPercentage}%`);
lines.push(`- **Changed Pixels**: ${result.diffPixels.toLocaleString()}`);
lines.push(`- **Image Size**: ${result.dimensions.width}x${result.dimensions.height}`);
lines.push('');
lines.push('## Output Files');
lines.push(`- **Old Component**: ${result.paths.old}`);
lines.push(`- **New Component**: ${result.paths.new}`);
lines.push(`- **Diff Image**: ${result.paths.diff}`);
lines.push(`- **Combined View**: ${result.paths.combined}`);
lines.push('');
lines.push('> Open the **Combined View** image to see old, new, and diff side by side.');
return lines.join('\n');
}
/**
* Cleanup resources (call when server shuts down)
*/
export async function cleanup(): Promise<void> {
await closeBrowser();
}