mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
de0dbbda66
## 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.
213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { formatScreenshotDiffSummary } from "../src/tools/screenshot-diff/screenshot-diff-summary";
|
|
|
|
describe("formatScreenshotDiffSummary", () => {
|
|
it("emits overview, text, and region sections for unchanged screenshots", () => {
|
|
const summary = formatScreenshotDiffSummary({
|
|
totalPixels: 100,
|
|
differentPixels: 0,
|
|
mismatchPercentage: 0,
|
|
imageSize: { width: 10, height: 10 },
|
|
regions: [],
|
|
textAnalysis: {
|
|
status: "ok",
|
|
provider: "ocr",
|
|
changes: [],
|
|
},
|
|
});
|
|
|
|
expect(summary).toContain("Screenshot diff summary");
|
|
expect(summary).toContain("Overall:");
|
|
expect(summary).toContain("- status: unchanged");
|
|
expect(summary).toContain("- pixel_mismatch: 0% - no pixel change");
|
|
expect(summary).toContain("Text changes:");
|
|
expect(summary).toContain("- text_analysis: status=ok provider=ocr shown=0 total=0 omitted=0");
|
|
expect(summary).toContain("Regions:");
|
|
});
|
|
|
|
it("merges movement and appearance evidence for the same text", () => {
|
|
const summary = formatScreenshotDiffSummary({
|
|
totalPixels: 100,
|
|
differentPixels: 12,
|
|
mismatchPercentage: 12,
|
|
imageSize: { width: 1200, height: 2400 },
|
|
regions: [],
|
|
textAnalysis: {
|
|
status: "ok",
|
|
provider: "ocr",
|
|
changes: [
|
|
{
|
|
kind: "moved",
|
|
text: "A luxury villa just 400 meters from Jadro Beach on the be...",
|
|
normalizedText: "a luxury villa just 400 meters from jadro beach on the be",
|
|
baselineBounds: { x: 73, y: 1488, width: 1030, height: 177 },
|
|
currentBounds: { x: 149, y: 1652, width: 826, height: 210 },
|
|
delta: { x: 76, y: 164, width: -204, height: 33 },
|
|
confidence: 0.92,
|
|
source: "ocr",
|
|
reasonCodes: ["exact_normalized_match", "position_delta"],
|
|
},
|
|
{
|
|
kind: "font_changed",
|
|
text: "A luxury villa just 400 meters from Jadro Beach on the be...",
|
|
normalizedText: "a luxury villa just 400 meters from jadro beach on the be",
|
|
baselineBounds: { x: 73, y: 1488, width: 1030, height: 177 },
|
|
currentBounds: { x: 149, y: 1652, width: 826, height: 210 },
|
|
confidence: 0.96,
|
|
source: "ocr",
|
|
reasonCodes: [
|
|
"exact_normalized_match",
|
|
"text_color_delta",
|
|
"text_contrast_delta",
|
|
"bbox_geometry_delta",
|
|
"component_shape_delta",
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(summary).toContain("- text_analysis: status=ok provider=ocr shown=1 total=1 omitted=0");
|
|
expect(summary).toContain(
|
|
'- Moved/restyled: "A luxury villa just 400 meters from Jadro Beach on the be..."'
|
|
);
|
|
expect(summary).toContain(" - from x=0.0608 y=0.62 w=0.8583 h=0.0738");
|
|
expect(summary).toContain(" - to x=0.1242 y=0.6883 w=0.6883 h=0.0875");
|
|
expect(summary).toContain(" - delta: dx=+0.0633 dy=+0.0683 dw=-0.17 dh=+0.0138");
|
|
expect(summary).toContain(" - appearance: color, contrast, size/layout, shape/rendering");
|
|
expect(summary).not.toContain("reason_codes");
|
|
expect(summary).not.toContain("confidence=");
|
|
});
|
|
|
|
it("summarizes font evidence with semantic labels", () => {
|
|
const summary = formatScreenshotDiffSummary({
|
|
totalPixels: 100,
|
|
differentPixels: 12,
|
|
mismatchPercentage: 12,
|
|
regions: [],
|
|
textAnalysis: {
|
|
status: "ok",
|
|
provider: "ocr",
|
|
changes: [
|
|
{
|
|
kind: "font_changed",
|
|
text: "Header",
|
|
baselineBounds: { x: 10, y: 20, width: 100, height: 24 },
|
|
currentBounds: { x: 10, y: 20, width: 112, height: 28 },
|
|
confidence: 0.823,
|
|
source: "ocr",
|
|
reasonCodes: [
|
|
"exact_normalized_match",
|
|
"bbox_geometry_delta",
|
|
"stroke_width_delta",
|
|
"text_color_delta",
|
|
"ssim_delta",
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(summary).toContain(
|
|
'- Restyled: "Header" (color, size/layout, weight/stroke, shape/rendering)'
|
|
);
|
|
});
|
|
|
|
it("distinguishes skipped and unavailable text analysis states", () => {
|
|
const skipped = formatScreenshotDiffSummary({
|
|
totalPixels: 2,
|
|
differentPixels: 0,
|
|
mismatchPercentage: 0,
|
|
dimensionMismatch: {
|
|
expected: { width: 2, height: 1 },
|
|
actual: { width: 1, height: 2 },
|
|
},
|
|
regions: [],
|
|
textAnalysis: {
|
|
status: "skipped",
|
|
provider: "ocr",
|
|
changes: [],
|
|
},
|
|
});
|
|
const unavailable = formatScreenshotDiffSummary({
|
|
totalPixels: 100,
|
|
differentPixels: 12,
|
|
mismatchPercentage: 12,
|
|
regions: [],
|
|
textAnalysis: {
|
|
status: "unavailable",
|
|
provider: "ocr",
|
|
changes: [],
|
|
},
|
|
});
|
|
|
|
expect(skipped).toContain("- status: dimension_mismatch");
|
|
expect(skipped).toContain("- text_analysis: status=skipped provider=ocr");
|
|
expect(unavailable).toContain("- text_analysis: status=unavailable provider=ocr");
|
|
expect(skipped).not.toContain("warnings");
|
|
expect(unavailable).not.toContain("warnings");
|
|
});
|
|
|
|
it("preserves omitted counts for regions and text changes", () => {
|
|
const summary = formatScreenshotDiffSummary({
|
|
totalPixels: 100,
|
|
differentPixels: 12,
|
|
mismatchPercentage: 12,
|
|
regions: Array.from({ length: 9 }, (_, index) => ({
|
|
bounds: { x: index, y: index, width: 10, height: 10 },
|
|
pixelCount: index + 1,
|
|
averageColor: {
|
|
delta: { r: 1, g: 2, b: 3 },
|
|
dominantChange: { channel: "red", direction: "increase", magnitude: 1 },
|
|
},
|
|
})),
|
|
textAnalysis: {
|
|
status: "ok",
|
|
provider: "ocr",
|
|
changes: Array.from({ length: 11 }, (_, index) => ({
|
|
kind: "appeared" as const,
|
|
text: `Item ${index}`,
|
|
currentBounds: { x: 10, y: index * 20, width: 100, height: 20 },
|
|
confidence: 0.8,
|
|
source: "ocr" as const,
|
|
reasonCodes: ["missing_in_baseline" as const],
|
|
})),
|
|
},
|
|
});
|
|
|
|
expect(summary).toContain("- changed_areas: shown=8 total=9 omitted=1");
|
|
expect(summary).toContain(
|
|
"- text_analysis: status=ok provider=ocr shown=10 total=11 omitted=1"
|
|
);
|
|
expect(summary).toContain("- regions: shown=8 total=9 omitted=1");
|
|
expect(summary).not.toContain("- Region 9:");
|
|
expect(summary).not.toContain('- Appeared: "Item 10"');
|
|
expect(summary).not.toContain("average_rgb_delta");
|
|
});
|
|
|
|
it("escapes and truncates quoted OCR text", () => {
|
|
const summary = formatScreenshotDiffSummary({
|
|
totalPixels: 100,
|
|
differentPixels: 1,
|
|
mismatchPercentage: 1,
|
|
regions: [],
|
|
textAnalysis: {
|
|
status: "ok",
|
|
provider: "ocr",
|
|
changes: [
|
|
{
|
|
kind: "appeared",
|
|
text: 'This "quoted" OCR text has whitespace and is intentionally very long',
|
|
currentBounds: { x: 10, y: 20, width: 100, height: 20 },
|
|
confidence: 0.8,
|
|
source: "ocr",
|
|
reasonCodes: ["missing_in_baseline"],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(summary).toContain('"This \\"quoted\\" OCR text has whitespace and is intentionall..."');
|
|
});
|
|
});
|