Files
longbridge__developers/scripts/opencli/visual-diff.ts
twosugar 53666718af feat(migrate): opencli — url/dom/visual/interaction diff toolkit
- crawl-routes.ts: parse sitemap XML (flat + sitemapindex) → Route[]
- url-diff.ts: symmetric set diff (A △ B = ∅) hard gate for T18
- snapshot.ts: per-URL HTML capture (fetch mode); MCP screenshots documented
- dom-diff.ts: heading/links/codeblocks/components Jaccard ≥ 0.95
- visual-diff.ts: odiff-bin wrapper, 0.1% pixel threshold
- interaction-assertions.ts: theme/search/sidebar/copy/component checks (MCP ref)
- report.ts: aggregate results → dist-diff/report.md + report.json
- README.md: 5 layers + MCP-driven canonical flow

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-17 19:46:49 +08:00

189 lines
5.5 KiB
TypeScript
Raw Permalink 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.
#!/usr/bin/env bun
/**
* visual-diff.ts — Pixel-level visual comparison via odiff-bin.
*
* Wraps the `odiff` binary (from odiff-bin devDependency) using execSync.
* Returns pixel difference percentage and path to the diff PNG.
*
* Usage:
* bun run scripts/opencli/visual-diff.ts <old.png> <new.png> [--diff <diff.png>]
* bun run scripts/opencli/visual-diff.ts --help
*
* Exit codes:
* 0 Images are identical or below threshold (default: 0.1%)
* 1 Visual difference exceeds threshold or tool error
*/
import { execSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createRequire } from "node:module";
const DEFAULT_THRESHOLD_PERCENT = 0.1; // 0.1% pixel diff allowed
export interface VisualDiffResult {
changed: number; // % pixel difference (0100)
diffPngPath: string;
pass: boolean;
error?: string;
}
/** Resolve the odiff binary path from node_modules. */
function resolveOdiffBin(): string {
// odiff-bin ships the binary at odiff-bin/bin/odiff (linux/mac/win)
try {
const require = createRequire(import.meta.url);
const pkg = require.resolve("odiff-bin/package.json");
const pkgDir = dirname(pkg);
// Try common paths: bin/odiff, odiff
for (const rel of ["bin/odiff", "odiff"]) {
const candidate = join(pkgDir, rel);
if (existsSync(candidate)) return candidate;
}
} catch {
// fall through to PATH
}
// Fall back to PATH
return "odiff";
}
/**
* Run a pixel-level visual diff between two PNG files.
* Uses odiff-bin binary via execSync.
*
* @param oldPng Path to baseline screenshot
* @param newPng Path to candidate screenshot
* @param diffPng Path to write the diff overlay PNG
* @param threshold % pixel diff to consider a pass (0100, default 0.1)
*/
export function visualDiff(
oldPng: string,
newPng: string,
diffPng: string,
threshold = DEFAULT_THRESHOLD_PERCENT,
): VisualDiffResult {
// Ensure diff output directory exists
mkdirSync(dirname(resolve(diffPng)), { recursive: true });
const bin = resolveOdiffBin();
// odiff CLI: odiff <base> <compare> <diff> [options]
// --threshold: per-pixel color threshold (01 float)
const cmd = `${JSON.stringify(bin)} ${JSON.stringify(resolve(oldPng))} ${JSON.stringify(resolve(newPng))} ${JSON.stringify(resolve(diffPng))} --threshold 0.1 --antialiasing`;
let stdout = "";
let exitCode = 0;
try {
stdout = execSync(cmd, { encoding: "utf-8" }).toString();
} catch (err: unknown) {
const execErr = err as { stdout?: string; stderr?: string; status?: number };
stdout = execErr.stdout ?? "";
exitCode = execErr.status ?? 1;
// odiff exits 1 when diff found — not a fatal error
if (exitCode !== 1) {
return {
changed: 100,
diffPngPath: diffPng,
pass: false,
error: `odiff failed (exit ${exitCode}): ${execErr.stderr ?? ""}`,
};
}
}
// Parse odiff output: "Pixels changed: 1234 (0.12%)" or "Images are equal!"
let changed = 0;
const equalMatch = /Images are equal/i.test(stdout);
const percentMatch = stdout.match(/\(([0-9.]+)%\)/);
if (equalMatch) {
changed = 0;
} else if (percentMatch) {
changed = parseFloat(percentMatch[1]);
} else if (exitCode === 0) {
changed = 0;
} else {
// Fallback: treat as 100% diff if we can't parse
changed = 100;
}
return {
changed,
diffPngPath: diffPng,
pass: changed <= threshold,
};
}
// ---- CLI main ----
function printHelp() {
console.log(`
visual-diff.ts — pixel-level visual comparison between two PNG screenshots.
Wraps odiff-bin binary.
Usage:
bun run scripts/opencli/visual-diff.ts <old.png> <new.png> [options]
bun run scripts/opencli/visual-diff.ts --help
Arguments:
<old.png> Baseline screenshot (vitepress)
<new.png> Candidate screenshot (astro)
Options:
--diff <path> Output diff PNG path (default: dist-diff/visual/<slug>.diff.png)
--threshold <pct> % pixel difference to pass (default: ${DEFAULT_THRESHOLD_PERCENT})
--json Output machine-readable JSON
--help Show this help
Exit codes:
0 Pass (diff ≤ threshold)
1 Fail (diff > threshold) or odiff error
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.length === 0) {
printHelp();
process.exit(0);
}
const jsonMode = args.includes("--json");
const diffIdx = args.indexOf("--diff");
const threshIdx = args.indexOf("--threshold");
const threshold = threshIdx !== -1 ? parseFloat(args[threshIdx + 1]) : DEFAULT_THRESHOLD_PERCENT;
const positional = args.filter((a) => !a.startsWith("--"));
if (positional.length < 2) {
console.error("Error: two PNG paths required. Run --help for usage.");
process.exit(1);
}
const [oldPng, newPng] = positional;
const diffPng = diffIdx !== -1 ? args[diffIdx + 1] : `dist-diff/visual/${Date.now()}.diff.png`;
const result = visualDiff(oldPng, newPng, diffPng, threshold);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.error) {
console.error(`Error: ${result.error}`);
} else {
console.log(`Pixel difference: ${result.changed.toFixed(2)}%`);
console.log(`Threshold: ${threshold}% — ${result.pass ? "PASS" : "FAIL"}`);
if (!result.pass) {
console.log(`Diff PNG: ${result.diffPngPath}`);
}
}
}
process.exit(result.pass ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});