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>
209 lines
7.2 KiB
TypeScript
209 lines
7.2 KiB
TypeScript
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 (0–1), 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 0–1 (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,
|
||
};
|
||
}
|
||
},
|
||
});
|
||
}
|