import { PNG } from 'pngjs'; import pixelmatch from 'pixelmatch'; import { z } from 'zod'; import { config } from '../../config.js'; import { readBaseline, resolveBaselinePath } from '../../lib/baselines.js'; import { errorResult, jsonResult, toErrorMessage } from '../shared.js'; import { browserManager } from './browserManager.js'; function toDataUri(png) { return `data:image/png;base64,${png.toString('base64')}`; } export function comparePngBuffers(baselinePng, currentPng, threshold = 0.1) { const baseline = PNG.sync.read(baselinePng); const current = PNG.sync.read(currentPng); if (baseline.width !== current.width || baseline.height !== current.height) { throw new Error(`Screenshot dimensions differ from baseline (${baseline.width}x${baseline.height} vs ${current.width}x${current.height}). ` + 'Recapture the baseline at the same viewport/selector size.'); } const { width, height } = baseline; const diff = new PNG({ width, height }); const mismatchedPixels = pixelmatch(baseline.data, current.data, diff.data, width, height, { threshold, }); const total = width * height; const mismatchPercent = total === 0 ? 0 : (mismatchedPixels / total) * 100; return { width, height, mismatchedPixels, mismatchPercent, diffPng: PNG.sync.write(diff), }; } export function registerBrowserVisualDiffTool(server) { server.registerTool('browser_visual_diff', { title: 'Visual Screenshot Diff', description: 'Capture the current page/element and compare it to a previously saved baseline with pixelmatch. Returns mismatch stats and a diff PNG data URI.', inputSchema: { name: z.string().min(1).describe('Baseline name previously saved with browser_screenshot_baseline.'), selector: z.string().optional().describe('Optional CSS selector to screenshot for comparison.'), fullPage: z .boolean() .optional() .default(false) .describe('Capture the full scrollable page when selector is omitted.'), threshold: z .number() .min(0) .max(1) .optional() .default(0.1) .describe('pixelmatch threshold (0–1). Lower is stricter.'), }, }, async ({ name, selector, fullPage, threshold }) => { try { const baselinePng = readBaseline(name); const session = await browserManager.getSession(); let currentPng; if (selector) { const element = await session.page.locator(selector).first(); await element.waitFor({ state: 'visible', timeout: 5000 }); currentPng = await element.screenshot({ type: 'png' }); } else { currentPng = await session.page.screenshot({ fullPage, type: 'png' }); } const comparison = comparePngBuffers(baselinePng, currentPng, threshold ?? 0.1); const diffDataUri = toDataUri(comparison.diffPng); return jsonResult({ name, baselinePath: resolveBaselinePath(name), selector: selector ?? null, width: comparison.width, height: comparison.height, mismatchedPixels: comparison.mismatchedPixels, mismatchPercent: Number(comparison.mismatchPercent.toFixed(4)), passed: comparison.mismatchedPixels === 0, threshold: threshold ?? 0.1, diffDataUri, truncatedDiffDataUri: diffDataUri.length > config.maxToolOutputChars ? `${diffDataUri.slice(0, config.maxToolOutputChars)}...` : diffDataUri, }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } //# sourceMappingURL=visualDiff.js.map