singularity-forge/src/resources/extensions/browser-tools/tools/visual-diff.ts
TÂCHES 9201c0ce16 fix: document silent catch handlers in browser-tools (#1037)
Add descriptive comments to all empty catch blocks explaining why the
error is intentionally swallowed. Covers networkidle timeouts, optional
screenshots, best-effort file writes, response body reads, route
cleanup, and page metadata refreshes.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:33:00 -06:00

209 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ExtensionAPI } from "@gsd/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import type { ToolDeps } from "../state.js";
/**
* Visual regression diffing — compare current page screenshot against a stored baseline.
*/
const BASELINE_DIR = ".gsd/browser-baselines";
export function registerVisualDiffTools(pi: ExtensionAPI, deps: ToolDeps): void {
pi.registerTool({
name: "browser_visual_diff",
label: "Browser Visual Diff",
description:
"Compare current page screenshot against a stored baseline pixel-by-pixel. " +
"Returns similarity score (01), diff pixel count, and optionally generates a diff image highlighting changes. " +
"On first run with no baseline, saves the current screenshot as the baseline. " +
"Baselines are stored in .gsd/browser-baselines/ (gitignored, environment-specific).",
parameters: Type.Object({
name: Type.Optional(
Type.String({
description:
"Baseline name (default: auto-generated from URL + viewport). " +
"Use consistent names to compare the same view across runs.",
}),
),
selector: Type.Optional(
Type.String({
description: "CSS selector to scope comparison to a specific element instead of full viewport.",
}),
),
threshold: Type.Optional(
Type.Number({
description:
"Pixel matching threshold 01 (default: 0.1). " +
"Higher values are more tolerant of anti-aliasing and rendering differences.",
}),
),
updateBaseline: Type.Optional(
Type.Boolean({
description: "If true, overwrite the existing baseline with the current screenshot (default: false).",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
try {
const { page: p } = await deps.ensureBrowser();
const { mkdir, readFile, writeFile } = await import("node:fs/promises");
const pathMod = await import("node:path");
const baselineDir = pathMod.resolve(process.cwd(), BASELINE_DIR);
await mkdir(baselineDir, { recursive: true });
// Ensure .gitignore
const gitignorePath = pathMod.join(baselineDir, ".gitignore");
await writeFile(gitignorePath, "*\n!.gitignore\n").catch(() => { /* best-effort — .gitignore may already exist or dir may be read-only */ });
// Generate baseline name
const url = p.url();
const viewport = p.viewportSize();
const vpSuffix = viewport ? `${viewport.width}x${viewport.height}` : "unknown";
const autoName = deps.sanitizeArtifactName(
`${new URL(url).pathname.replace(/\//g, "-")}-${vpSuffix}`,
`baseline-${vpSuffix}`,
);
const name = deps.sanitizeArtifactName(params.name ?? autoName, autoName);
const baselinePath = pathMod.join(baselineDir, `${name}.png`);
const diffPath = pathMod.join(baselineDir, `${name}-diff.png`);
// Capture current screenshot as PNG (needed for pixel comparison)
let currentBuffer: Buffer;
if (params.selector) {
const locator = p.locator(params.selector).first();
currentBuffer = await locator.screenshot({ type: "png" });
} else {
currentBuffer = await p.screenshot({ type: "png", fullPage: false });
}
// Check if baseline exists
let baselineBuffer: Buffer | null = null;
try {
baselineBuffer = await readFile(baselinePath) as Buffer;
} catch {
// No baseline yet
}
if (!baselineBuffer || params.updateBaseline) {
// Save as new baseline
await writeFile(baselinePath, currentBuffer);
return {
content: [{
type: "text",
text: baselineBuffer
? `Baseline updated: ${baselinePath}\nSize: ${(currentBuffer.length / 1024).toFixed(1)} KB`
: `Baseline created (first run): ${baselinePath}\nSize: ${(currentBuffer.length / 1024).toFixed(1)} KB\nRe-run to compare against this baseline.`,
}],
details: {
baselinePath,
baselineCreated: !baselineBuffer,
baselineUpdated: !!baselineBuffer,
sizeBytes: currentBuffer.length,
},
};
}
// Perform pixel comparison using sharp for PNG decoding
const sharp = (await import("sharp")).default;
const baselineMeta = await sharp(baselineBuffer).metadata();
const currentMeta = await sharp(currentBuffer).metadata();
const bWidth = baselineMeta.width ?? 0;
const bHeight = baselineMeta.height ?? 0;
const cWidth = currentMeta.width ?? 0;
const cHeight = currentMeta.height ?? 0;
// If dimensions differ, report mismatch
if (bWidth !== cWidth || bHeight !== cHeight) {
return {
content: [{
type: "text",
text: `Dimension mismatch: baseline is ${bWidth}x${bHeight}, current is ${cWidth}x${cHeight}. Cannot compare.\nUse updateBaseline: true to reset.`,
}],
details: {
match: false,
dimensionMismatch: true,
baselineDimensions: { width: bWidth, height: bHeight },
currentDimensions: { width: cWidth, height: cHeight },
},
};
}
// Extract raw RGBA pixel data
const baselineRaw = await sharp(baselineBuffer).ensureAlpha().raw().toBuffer();
const currentRaw = await sharp(currentBuffer).ensureAlpha().raw().toBuffer();
const width = bWidth;
const height = bHeight;
const totalPixels = width * height;
const threshold = params.threshold ?? 0.1;
// Simple pixel-by-pixel comparison (avoiding pixelmatch dependency)
const diffData = Buffer.alloc(width * height * 4);
let diffPixels = 0;
const thresholdSq = threshold * threshold * 255 * 255 * 3;
for (let i = 0; i < totalPixels; i++) {
const offset = i * 4;
const dr = baselineRaw[offset] - currentRaw[offset];
const dg = baselineRaw[offset + 1] - currentRaw[offset + 1];
const db = baselineRaw[offset + 2] - currentRaw[offset + 2];
const distSq = dr * dr + dg * dg + db * db;
if (distSq > thresholdSq) {
diffPixels++;
// Mark diff pixels as red
diffData[offset] = 255; // R
diffData[offset + 1] = 0; // G
diffData[offset + 2] = 0; // B
diffData[offset + 3] = 255; // A
} else {
// Dim unchanged pixels
diffData[offset] = currentRaw[offset] >> 1;
diffData[offset + 1] = currentRaw[offset + 1] >> 1;
diffData[offset + 2] = currentRaw[offset + 2] >> 1;
diffData[offset + 3] = 255;
}
}
const similarity = 1 - (diffPixels / totalPixels);
const match = diffPixels === 0;
// Save diff image
await sharp(diffData, { raw: { width, height, channels: 4 } })
.png()
.toFile(diffPath);
return {
content: [{
type: "text",
text: match
? `Visual diff: MATCH (100% similar)\nBaseline: ${baselinePath}`
: `Visual diff: ${(similarity * 100).toFixed(2)}% similar\nDiff pixels: ${diffPixels} of ${totalPixels} (${((diffPixels / totalPixels) * 100).toFixed(2)}%)\nDiff image: ${diffPath}\nBaseline: ${baselinePath}`,
}],
details: {
match,
similarity,
diffPixels,
totalPixels,
diffPercentage: (diffPixels / totalPixels) * 100,
dimensions: { width, height },
baselinePath,
diffImagePath: match ? undefined : diffPath,
threshold,
},
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Visual diff failed: ${err.message}` }],
details: { error: err.message },
isError: true,
};
}
},
});
}