import puppeteer, { Browser, Page } from 'puppeteer';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
export interface ScreenshotOptions {
width?: number;
height?: number;
waitForSelector?: string;
waitTime?: number;
}
const DEFAULT_OPTIONS: Required<ScreenshotOptions> = {
width: 800,
height: 600,
waitForSelector: '#root',
waitTime: 1000
};
let browserInstance: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (!browserInstance) {
browserInstance = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-web-security',
'--allow-file-access-from-files'
]
});
}
return browserInstance;
}
export async function closeBrowser(): Promise<void> {
if (browserInstance) {
await browserInstance.close();
browserInstance = null;
}
}
/**
* Take a screenshot of an HTML file
*/
export async function screenshotHTML(
htmlPath: string,
options: ScreenshotOptions = {}
): Promise<Buffer> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.setViewport({
width: opts.width,
height: opts.height,
deviceScaleFactor: 2
});
await page.goto(`file://${htmlPath}`, {
waitUntil: 'networkidle0',
timeout: 30000
});
// Wait for the component to render
if (opts.waitForSelector) {
await page.waitForSelector(opts.waitForSelector, { timeout: 10000 });
}
// Additional wait for any animations/transitions
await new Promise(resolve => setTimeout(resolve, opts.waitTime));
// Get the actual content size
const element = await page.$(opts.waitForSelector || 'body');
const boundingBox = element ? await element.boundingBox() : null;
let screenshotData: Uint8Array;
if (boundingBox) {
// Screenshot just the component with some padding
const padding = 20;
screenshotData = await page.screenshot({
type: 'png',
clip: {
x: Math.max(0, boundingBox.x - padding),
y: Math.max(0, boundingBox.y - padding),
width: boundingBox.width + padding * 2,
height: boundingBox.height + padding * 2
}
});
} else {
screenshotData = await page.screenshot({
type: 'png',
fullPage: true
});
}
// Ensure we return a proper Node.js Buffer
const screenshot = Buffer.from(screenshotData);
return screenshot;
} finally {
await page.close();
}
}
/**
* Compare two PNG buffers and generate a diff image
*/
export function compareImages(
img1Buffer: Buffer,
img2Buffer: Buffer
): {
diffBuffer: Buffer;
diffPixels: number;
diffPercentage: number;
width: number;
height: number;
} {
const img1 = PNG.sync.read(img1Buffer);
const img2 = PNG.sync.read(img2Buffer);
// Use the maximum dimensions
const width = Math.max(img1.width, img2.width);
const height = Math.max(img1.height, img2.height);
// Create canvases of the same size
const normalizedImg1 = new PNG({ width, height });
const normalizedImg2 = new PNG({ width, height });
const diff = new PNG({ width, height });
// Fill with white background
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (width * y + x) << 2;
normalizedImg1.data[idx] = 255;
normalizedImg1.data[idx + 1] = 255;
normalizedImg1.data[idx + 2] = 255;
normalizedImg1.data[idx + 3] = 255;
normalizedImg2.data[idx] = 255;
normalizedImg2.data[idx + 1] = 255;
normalizedImg2.data[idx + 2] = 255;
normalizedImg2.data[idx + 3] = 255;
}
}
// Copy original images onto normalized canvases
PNG.bitblt(img1, normalizedImg1, 0, 0, img1.width, img1.height, 0, 0);
PNG.bitblt(img2, normalizedImg2, 0, 0, img2.width, img2.height, 0, 0);
const diffPixels = pixelmatch(
normalizedImg1.data,
normalizedImg2.data,
diff.data,
width,
height,
{
threshold: 0.1,
includeAA: true,
diffColor: [255, 0, 0], // Red for differences
diffColorAlt: [0, 255, 0], // Green for anti-aliased
alpha: 0.3
}
);
const totalPixels = width * height;
const diffPercentage = (diffPixels / totalPixels) * 100;
return {
diffBuffer: PNG.sync.write(diff),
diffPixels,
diffPercentage,
width,
height
};
}
/**
* Create a side-by-side comparison image
*/
export function createComparisonImage(
oldBuffer: Buffer,
newBuffer: Buffer,
diffBuffer: Buffer
): Buffer {
const oldImg = PNG.sync.read(oldBuffer);
const newImg = PNG.sync.read(newBuffer);
const diffImg = PNG.sync.read(diffBuffer);
const maxHeight = Math.max(oldImg.height, newImg.height, diffImg.height);
const totalWidth = oldImg.width + newImg.width + diffImg.width + 40; // 40px for gaps
const combined = new PNG({ width: totalWidth, height: maxHeight + 60 }); // 60px for labels
// Fill with light gray background
for (let y = 0; y < combined.height; y++) {
for (let x = 0; x < combined.width; x++) {
const idx = (combined.width * y + x) << 2;
combined.data[idx] = 245;
combined.data[idx + 1] = 245;
combined.data[idx + 2] = 245;
combined.data[idx + 3] = 255;
}
}
// Position images with labels space
const labelHeight = 30;
let xOffset = 10;
// Old image
PNG.bitblt(oldImg, combined, 0, 0, oldImg.width, oldImg.height, xOffset, labelHeight);
xOffset += oldImg.width + 10;
// New image
PNG.bitblt(newImg, combined, 0, 0, newImg.width, newImg.height, xOffset, labelHeight);
xOffset += newImg.width + 10;
// Diff image
PNG.bitblt(diffImg, combined, 0, 0, diffImg.width, diffImg.height, xOffset, labelHeight);
return PNG.sync.write(combined);
}
/**
* Save images to a directory for inspection
*/
export function saveVisualDiff(
oldBuffer: Buffer,
newBuffer: Buffer,
diffBuffer: Buffer,
outputDir?: string
): { oldPath: string; newPath: string; diffPath: string; combinedPath: string } {
const dir = outputDir || join(tmpdir(), `mcp-visual-diff-${randomUUID()}`);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const oldPath = join(dir, 'old.png');
const newPath = join(dir, 'new.png');
const diffPath = join(dir, 'diff.png');
const combinedPath = join(dir, 'combined.png');
writeFileSync(oldPath, oldBuffer);
writeFileSync(newPath, newBuffer);
writeFileSync(diffPath, diffBuffer);
const combinedBuffer = createComparisonImage(oldBuffer, newBuffer, diffBuffer);
writeFileSync(combinedPath, combinedBuffer);
return { oldPath, newPath, diffPath, combinedPath };
}