Files
Hubert Gancarczyk de0dbbda66 feat(tools): implement screenshot-diff tool (#229)
## Summary

Adds a new `screenshot-diff` tool for comparing mobile screenshots
during UI QA and visual regression checks. The tool can compare two
saved PNGs, or compare one saved PNG against one live full-resolution
device capture, then returns a compact text summary and generated diff
artifacts.

## Context

Mobile UI validation often needs more than a single screenshot: small
layout, color, typography,
copy, clipping, and regression changes are easy to miss manually. This
feature gives agents a
deterministic before/after comparison path while keeping
`screenshot-diff` as supporting evidence.

## What Changed

- Registered a new `screenshot-diff` tool in the tool server.
- Supports saved PNG comparisons and one-sided live capture via
`captureBaseline` or
`captureCurrent`.
- Adds PNG diffing with fixed internal defaults, changed-region
detection, status-bar-band
ignoring, full-size diff output, and downscaled context diff output.
- Adds optional OCR/text analysis for moved, appeared, disappeared,
changed, and restyled text.
- Updates MCP content rendering to attach the downscaled context diff
image plus the text summary.

1. Capture a stable full-resolution baseline.
2. Perform an interaction or apply a UI change.
3. Compare the current screen against the baseline.
4. Review a summarized diff plus generated artifacts.
2026-06-01 15:04:25 +02:00

246 lines
7.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
extractOcrTextBlocks,
parseTesseractTsv,
} from "../src/tools/screenshot-diff/screenshot-diff-ocr";
describe("Tesseract TSV parsing", () => {
it("normalizes block and word confidence to a 0-1 scale", () => {
const blocks = parseTesseractTsv(
tsv([
row({ wordNum: 1, left: 10, top: 20, width: 40, height: 10, conf: 95, text: "Hello" }),
row({ wordNum: 2, left: 56, top: 20, width: 50, height: 10, conf: 93, text: "World" }),
])
);
expect(blocks).toHaveLength(1);
expect(blocks[0]?.text).toBe("Hello World");
expect(blocks[0]?.confidence).toBe(0.94);
expect(blocks[0]?.words.map((word) => word.confidence)).toEqual([0.95, 0.93]);
});
it("throws a clear error for malformed non-empty TSV headers", () => {
expect(() =>
parseTesseractTsv("level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\ttext\n")
).toThrow(
/Invalid Tesseract TSV: missing required column\(s\): left, top, width, height, conf/
);
});
it("parses valid TSV with a UTF-8 BOM-prefixed header", () => {
const blocks = parseTesseractTsv(
`\uFEFF${tsv([row({ left: 10, top: 20, width: 44, height: 12, conf: 91, text: "Ready" })])}`
);
expect(blocks.map((block) => block.text)).toEqual(["Ready"]);
});
it("parses headerless TSV output", () => {
const blocks = parseTesseractTsv(
[
"1\t1\t0\t0\t0\t0\t0\t0\t1206\t2622\t-1\t",
"2\t1\t1\t0\t0\t0\t76\t244\t151\t34\t-1\t",
"3\t1\t1\t1\t0\t0\t76\t244\t151\t34\t-1\t",
"4\t1\t1\t1\t1\t0\t76\t244\t151\t34\t-1\t",
"5\t1\t1\t1\t1\t1\t76\t248\t33\t27\t69.401016\t<",
"5\t1\t1\t1\t1\t2\t128\t244\t99\t34\t96.929596\tBack",
].join("\n")
);
expect(blocks.map((block) => block.text)).toEqual(["< Back"]);
});
it("returns no blocks for empty input", () => {
expect(parseTesseractTsv("")).toEqual([]);
});
it("preserves meaningful symbol-only OCR tokens", () => {
const blocks = parseTesseractTsv(
tsv([
row({ wordNum: 1, left: 10, top: 20, width: 8, text: "$" }),
row({ wordNum: 2, left: 24, top: 20, width: 46, text: "19.99" }),
row({ lineNum: 2, wordNum: 1, left: 10, top: 38, width: 24, text: "50" }),
row({ lineNum: 2, wordNum: 2, left: 40, top: 38, width: 10, text: "%" }),
row({ lineNum: 3, wordNum: 1, left: 10, top: 56, width: 10, text: "A" }),
row({ lineNum: 3, wordNum: 2, left: 26, top: 56, width: 10, text: "+" }),
row({ lineNum: 3, wordNum: 3, left: 42, top: 56, width: 10, text: "B" }),
row({ lineNum: 3, wordNum: 4, left: 58, top: 56, width: 10, text: "=" }),
row({ lineNum: 3, wordNum: 5, left: 74, top: 56, width: 10, text: "C" }),
row({ lineNum: 4, wordNum: 1, left: 10, top: 74, width: 120, text: "user@example.com" }),
])
);
expect(blocks.map((block) => block.text)).toEqual(["$ 19.99 50 % A + B = C user@example.com"]);
});
it("orders merged words visually by x position before word number", () => {
const blocks = parseTesseractTsv(
tsv([
row({ wordNum: 2, left: 10, top: 20, width: 34, text: "Left" }),
row({ wordNum: 1, left: 52, top: 20, width: 42, text: "Right" }),
])
);
expect(blocks[0]?.text).toBe("Left Right");
});
it("merges wrapped lines in the same OCR block and paragraph", () => {
const blocks = parseTesseractTsv(
tsv([
row({ lineNum: 1, wordNum: 1, left: 10, top: 20, width: 42, text: "Apple" }),
row({ lineNum: 1, wordNum: 2, left: 58, top: 20, width: 64, text: "Account" }),
row({ lineNum: 2, wordNum: 1, left: 10, top: 38, width: 48, text: "iCloud" }),
row({ lineNum: 2, wordNum: 2, left: 64, top: 38, width: 30, text: "and" }),
row({ lineNum: 2, wordNum: 3, left: 100, top: 38, width: 36, text: "more" }),
])
);
expect(blocks.map((block) => block.text)).toEqual(["Apple Account iCloud and more"]);
});
it("keeps same-block wrapped lines in different OCR paragraphs separate", () => {
const blocks = parseTesseractTsv(
tsv([
row({ parNum: 1, lineNum: 1, wordNum: 1, left: 10, top: 20, width: 40, text: "First" }),
row({ parNum: 1, lineNum: 1, wordNum: 2, left: 56, top: 20, width: 32, text: "line" }),
row({ parNum: 2, lineNum: 2, wordNum: 1, left: 10, top: 37, width: 48, text: "Second" }),
row({ parNum: 2, lineNum: 2, wordNum: 2, left: 64, top: 37, width: 30, text: "line" }),
])
);
expect(blocks.map((block) => block.text)).toEqual(["First line", "Second line"]);
});
it("keeps two-column text split into separate blocks", () => {
const blocks = parseTesseractTsv(
tsv([
row({ lineNum: 1, wordNum: 1, left: 10, top: 20, width: 32, text: "Left" }),
row({ lineNum: 1, wordNum: 2, left: 48, top: 20, width: 46, text: "column" }),
row({ lineNum: 1, wordNum: 3, left: 180, top: 20, width: 40, text: "Right" }),
row({ lineNum: 1, wordNum: 4, left: 226, top: 20, width: 24, text: "rail" }),
row({ lineNum: 2, wordNum: 1, left: 10, top: 38, width: 66, text: "Continued" }),
row({ lineNum: 2, wordNum: 2, left: 82, top: 38, width: 34, text: "copy" }),
row({ lineNum: 2, wordNum: 3, left: 180, top: 38, width: 38, text: "More" }),
row({ lineNum: 2, wordNum: 4, left: 224, top: 38, width: 30, text: "data" }),
])
);
expect(blocks.map((block) => block.text)).toEqual([
"Left column Continued copy",
"Right rail More data",
]);
});
it("keeps all-caps labels separate from nearby cross-block content", () => {
const blocks = parseTesseractTsv(
tsv([
row({
blockNum: 1,
lineNum: 1,
wordNum: 1,
left: 74,
top: 1790,
width: 139,
height: 23,
conf: 97,
text: "ALBUM",
}),
row({
blockNum: 2,
lineNum: 1,
wordNum: 1,
left: 74,
top: 1842,
width: 72,
height: 29,
conf: 96,
text: "The",
}),
row({
blockNum: 2,
lineNum: 1,
wordNum: 2,
left: 160,
top: 1842,
width: 103,
height: 29,
conf: 96,
text: "Venue",
}),
])
);
expect(blocks.map((block) => block.text)).toEqual(["ALBUM", "The Venue"]);
});
});
describe("extractOcrTextBlocks", () => {
it("returns unavailable without crashing when the system tesseract binary is missing", async () => {
const originalPath = process.env.PATH;
process.env.PATH = "";
try {
const result = await extractOcrTextBlocks("/tmp/does-not-need-to-exist.png");
expect(result).toEqual({
status: "unavailable",
provider: "tesseract",
blocks: [],
});
} finally {
process.env.PATH = originalPath;
}
});
});
interface RowOptions {
level?: number;
pageNum?: number;
blockNum?: number;
parNum?: number;
lineNum?: number;
wordNum?: number;
left?: number;
top?: number;
width?: number;
height?: number;
conf?: number;
text: string;
}
function row(options: RowOptions): string {
const {
level = 5,
pageNum = 1,
blockNum = 1,
parNum = 1,
lineNum = 1,
wordNum = 1,
left = 10,
top = 20,
width = 40,
height = 12,
conf = 90,
text,
} = options;
return [
level,
pageNum,
blockNum,
parNum,
lineNum,
wordNum,
left,
top,
width,
height,
conf,
text,
].join("\t");
}
function tsv(rows: string[]): string {
return [
"level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext",
...rows,
].join("\n");
}