Skip to content

Commit 5643107

Browse files
authored
fix(diff): include maximum RGB distance at threshold one (#2508)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
1 parent eefe37b commit 5643107

5 files changed

Lines changed: 87 additions & 2 deletions

File tree

src/__tests__/cli-diff.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,46 @@ describe('cli diff commands', () => {
269269
}
270270
});
271271

272+
test.each([false, true])(
273+
'diff screenshot honors threshold 1 for saved images (json=%s)',
274+
async (json) => {
275+
const dir = mkdtempForTestSync('cli-diff-threshold-');
276+
const baseline = path.join(dir, 'baseline.png');
277+
const current = path.join(dir, 'current.png');
278+
const diffOut = path.join(dir, 'diff.png');
279+
fs.writeFileSync(baseline, solidPngBuffer(2, 2, { r: 0, g: 0, b: 0 }));
280+
fs.writeFileSync(current, solidPngBuffer(2, 2, { r: 255, g: 255, b: 255 }));
281+
fs.writeFileSync(diffOut, 'stale diff');
282+
283+
const result = await runCliCapture([
284+
'diff',
285+
'screenshot',
286+
'--baseline',
287+
baseline,
288+
current,
289+
'--threshold',
290+
'1',
291+
'--out',
292+
diffOut,
293+
...(json ? ['--json'] : []),
294+
]);
295+
assert.equal(result.code, null);
296+
assert.equal(result.calls.length, 0);
297+
assert.equal(result.stderr, '');
298+
if (json) {
299+
const payload = JSON.parse(result.stdout);
300+
assert.equal(payload.success, true);
301+
assert.equal(payload.data.match, true);
302+
assert.equal(payload.data.differentPixels, 0);
303+
assert.equal(payload.data.diffPath, undefined);
304+
} else {
305+
assert.match(result.stdout, /Screenshots match\./);
306+
assert.doesNotMatch(result.stdout, /Diff image:/);
307+
}
308+
assert.equal(fs.existsSync(diffOut), false);
309+
},
310+
);
311+
272312
test('diff screenshot rejects overlay refs with supplied current image', async () => {
273313
const dir = mkdtempForTestSync('cli-diff-test-');
274314
const baseline = path.join(dir, 'baseline.png');

src/commands/capture/diff.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const diffCommandFacet = defineCommandFacet({
5959
text: {
6060
summary: 'Diff snapshot or screenshot',
6161
cliDetail:
62-
'Live iOS simulator screenshot diffs normalize status-bar chrome by default; use screenshot --normalize-status-bar when capturing reusable baselines.',
62+
'Screenshot --threshold is a per-pixel RGB tolerance: 0 requires exact colors and 1 ignores color differences; image dimensions must still match. Live iOS simulator screenshot diffs normalize status-bar chrome by default; use screenshot --normalize-status-bar when capturing reusable baselines.',
6363
},
6464
metadata: diffCommandMetadata,
6565
run: (client, input) => client.capture.diff(input),

src/screenshot-diff/__tests__/screenshot-diff.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,49 @@ test('threshold controls sensitivity: small differences ignored at default thres
343343
assert.equal(strict.differentPixels, 25);
344344
});
345345

346+
test.each([
347+
[
348+
{ r: 0, g: 0, b: 0 },
349+
{ r: 255, g: 255, b: 255 },
350+
],
351+
[
352+
{ r: 255, g: 0, b: 0 },
353+
{ r: 0, g: 255, b: 255 },
354+
],
355+
[
356+
{ r: 0, g: 255, b: 0 },
357+
{ r: 255, g: 0, b: 255 },
358+
],
359+
[
360+
{ r: 0, g: 0, b: 255 },
361+
{ r: 255, g: 255, b: 0 },
362+
],
363+
])('threshold 1 includes the maximum RGB distance from %j to %j', async (before, after) => {
364+
const dir = tmpDir();
365+
const baseline = path.join(dir, 'baseline.png');
366+
const current = path.join(dir, 'current.png');
367+
const outputPath = path.join(dir, 'diff.png');
368+
writeSolidPng(baseline, 2, 2, before);
369+
writeSolidPng(current, 2, 2, after);
370+
371+
const belowMaximum = await compareScreenshots(baseline, current, {
372+
threshold: 1 - Number.EPSILON / 2,
373+
outputPath,
374+
});
375+
assert.equal(belowMaximum.match, false);
376+
assert.equal(belowMaximum.differentPixels, 4);
377+
assert.equal(fs.existsSync(outputPath), true);
378+
379+
const maximum = await compareScreenshots(baseline, current, { threshold: 1, outputPath });
380+
assert.equal(maximum.match, true);
381+
assert.equal(maximum.differentPixels, 0);
382+
assert.equal(maximum.mismatchPercentage, 0);
383+
assert.equal(maximum.totalPixels, 4);
384+
assert.equal(maximum.regions, undefined);
385+
assert.equal(maximum.diffPath, undefined);
386+
assert.equal(fs.existsSync(outputPath), false);
387+
});
388+
346389
test('throws INVALID_ARGS when baseline file does not exist', async () => {
347390
const dir = tmpDir();
348391
const current = path.join(dir, 'current.png');

src/screenshot-diff/screenshot-diff.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ export type ScreenshotDiffOptions = {
7272
// white (255,255,255): √(255² + 255² + 255²) = 255√3 ≈ 441.67.
7373
// We use this as the denominator so threshold 0–1 maps linearly to the full
7474
// color distance range: 0 = exact match only, 1 = everything matches.
75-
const COLOR_DISTANCE_SCALE = 255 * Math.sqrt(3);
75+
// Match the per-pixel square-root rounding so the maximum stays inclusive.
76+
const COLOR_DISTANCE_SCALE = Math.sqrt(3 * 255 ** 2);
7677

7778
export async function compareScreenshots(
7879
baselinePath: string,

website/docs/docs/commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,7 @@ agent-device record stop # Stop active recording
952952
- `screenshot --scale <factor> --overlay-refs` writes a smaller image and draws refs for that final image size; avoid very small scales when text, icons, or labels need to remain readable.
953953
- `diff screenshot` compares the current live screenshot to `--baseline`, or compares `--baseline` to an optional saved `current.png` path without requiring an active session. Its text output reports ranked changed regions with screen-space rectangles, changed-pixel counts, and each region's share of the diff; JSON also includes normalized rectangles. The earlier best-effort `ocr` and `nonTextDeltas` analyzers are retired; their optional result fields remain for source compatibility but are no longer emitted, so use the baseline/current images and diff artifact with vision for qualitative interpretation. It writes a diff PNG with a light grayscale current-screen context, red-tinted changed pixels, and outlined changed regions when `--out` is provided. Live iOS simulator diffs normalize status-bar chrome by default; use `screenshot --normalize-status-bar` when capturing reusable baselines.
954954
- `diff screenshot --overlay-refs` additionally writes a separate current-screen overlay guide for live captures without using that annotated image for the pixel comparison. If current-screen refs intersect changed regions, the output lists the best ref matches under those regions. Saved-image comparisons do not have live accessibility refs, so `--overlay-refs` is unavailable when a `current.png` path is provided.
955+
- `diff screenshot --threshold <0-1>` sets the per-pixel RGB tolerance (default `0.1`): `0` requires exact colors and `1` ignores all color differences. Image dimensions must still match at every threshold.
955956
- In `--json` mode, each overlay ref also includes a screenshot-space `center` point for coordinate fallback like `press <x> <y>`.
956957
- Burned-in touch overlays are exported only on macOS hosts, because the overlay pipeline depends on Swift + AVFoundation helpers.
957958
- On Linux or other non-macOS hosts, `record stop` still succeeds and returns the raw video plus telemetry sidecar, and includes `overlayWarning` when burn-in overlays were skipped.

0 commit comments

Comments
 (0)