Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/ui-scripts/lib/__node_tests__/visual-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@
*/

import { describe, it, expect } from 'vitest'
import { PNG } from 'pngjs'
import {
badgeFor,
thumb,
indexByName,
sourceLinkFor,
appUrlFor,
dilateMask,
matchesWhenShifted,
esc,
normalizeA11y,
normalizeImpact,
Expand Down Expand Up @@ -272,6 +274,82 @@ describe('dilateMask', () => {
})
})

// An opaque white image with the given rectangles painted solid black. Hard
// edges, so a one-pixel move registers as a real difference instead of being
// written off as antialiasing by pixelmatch's `includeAA: false`.
function image(
w: number,
h: number,
rects: Array<{ x: number; y: number; w: number; h: number }>
) {
const png = new PNG({ width: w, height: h })
png.data.fill(255)
for (const r of rects) {
for (let y = r.y; y < r.y + r.h; y++) {
for (let x = r.x; x < r.x + r.w; x++) {
const i = (y * w + x) * 4
png.data[i] = 0
png.data[i + 1] = 0
png.data[i + 2] = 0
}
}
}
return png
}

describe('matchesWhenShifted', () => {
// Big enough that a 1px move is unambiguous, and far enough from the edges to
// shift in any direction without clipping.
const box = [{ x: 6, y: 6, w: 8, h: 8 }]
const baseline = image(24, 24, box)
const matches = (actual: PNG, maxShift = 1) =>
matchesWhenShifted(baseline, actual, 0.1, maxShift)

it('matches an identical image', () => {
expect(matches(image(24, 24, box))).toBe(true)
})

it('matches a one-pixel horizontal shift in either direction', () => {
expect(matches(image(24, 24, [{ x: 7, y: 6, w: 8, h: 8 }]))).toBe(true)
expect(matches(image(24, 24, [{ x: 5, y: 6, w: 8, h: 8 }]))).toBe(true)
})

it('matches a one-pixel vertical shift', () => {
expect(matches(image(24, 24, [{ x: 6, y: 7, w: 8, h: 8 }]))).toBe(true)
})

it('matches a diagonal shift', () => {
expect(matches(image(24, 24, [{ x: 5, y: 7, w: 8, h: 8 }]))).toBe(true)
})

it('matches when the actual is a pixel taller but otherwise identical', () => {
expect(matches(image(24, 25, box))).toBe(true)
})

it('rejects a shift larger than the budget', () => {
expect(matches(image(24, 24, [{ x: 9, y: 6, w: 8, h: 8 }]))).toBe(false)
})

it('matches that larger shift once the budget allows it', () => {
expect(matches(image(24, 24, [{ x: 9, y: 6, w: 8, h: 8 }]), 3)).toBe(true)
})

it('rejects a real change that no shift can explain', () => {
const recolored = image(24, 24, box)
const i = (10 * 24 + 10) * 4
recolored.data[i] = 255
recolored.data[i + 1] = 0
recolored.data[i + 2] = 0
expect(matches(recolored)).toBe(false)
})

it('considers only the identity offset when maxShift is 0', () => {
const shifted = image(24, 24, [{ x: 7, y: 6, w: 8, h: 8 }])
expect(matches(shifted, 0)).toBe(false)
expect(matches(image(24, 24, box), 0)).toBe(true)
})
})

describe('esc', () => {
it('escapes HTML-significant characters', () => {
expect(esc('<span class="x">a & b</span>')).toBe(
Expand Down
79 changes: 77 additions & 2 deletions packages/ui-scripts/lib/commands/visual-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,16 @@ type Result = {
status: Status
numDiff?: number
sizeMismatch?: boolean
// 'unchanged' only because the actual matched the baseline after realignment.
layoutShifted?: boolean
}

type Args = {
actualDir: string
baselineDir: string
outputDir: string
threshold: number
maxShift: number
failOnMissingBaseline: boolean
prNumber?: string
prUrl?: string
Expand Down Expand Up @@ -225,6 +228,55 @@ function diffMask(baseline: PNG, actual: PNG, threshold: number) {
}
}

// Copy a w*h window out of `src` at (sx, sy). Callers clamp to the source bounds.
function crop(src: PNG, sx: number, sy: number, w: number, h: number): PNG {
const out = new PNG({ width: w, height: h })
PNG.bitblt(src, out, sx, sy, w, h, 0, 0)
return out
}

// Is `actual` pixel-identical to `baseline` after being shifted up to
// `maxShift` pixels in any direction, diagonals included?
//
// A one-pixel rounding moves the whole painted subtree and lights up every edge
// in it — thousands of pixels, the same order of magnitude as a genuine small
// recolor, so a pixel-count tolerance cannot separate the two.
//
// (0, 0) is included because this comparison crops to the overlap while the
// caller's pads, which is what lets "one pixel taller, same content" pass.
/** @internal — exported only for tests; not part of the package's public API. */
export function matchesWhenShifted(
baseline: PNG,
actual: PNG,
threshold: number,
maxShift: number
): boolean {
for (let dy = -maxShift; dy <= maxShift; dy++) {
for (let dx = -maxShift; dx <= maxShift; dx++) {
// A positive dx means the content sits that many pixels further right
// than in the baseline, so actual (x, y) lines up with baseline
// (x - dx, y - dy).
const x0 = Math.max(0, dx)
const y0 = Math.max(0, dy)
const w = Math.min(actual.width, baseline.width + dx) - x0
const h = Math.min(actual.height, baseline.height + dy) - y0
if (w <= 0 || h <= 0) continue

// No output buffer — only the count matters here.
const numDiff = pixelmatch(
crop(actual, x0, y0, w, h).data,
crop(baseline, x0 - dx, y0 - dy, w, h).data,
undefined,
w,
h,
{ threshold, includeAA: false }
)
if (numDiff === 0) return true
}
}
return false
}

// How much unchanged pixels are dimmed in the diff image so the changed pixels
// stand out. DESAT blends each pixel toward its own grayscale (0 = keep color,
// 1 = fully gray); DIM then scales brightness (0.5 = half).
Expand Down Expand Up @@ -896,6 +948,8 @@ function row(
? `<div class="meta">${r.numDiff} pixels differ${
r.sizeMismatch ? ' · size mismatch' : ''
}</div>`
: r.layoutShifted
? `<div class="meta">layout shifted · ${r.numDiff} pixels differ before realignment</div>`
: ''
const source = sourceLinkFor(r.name, meta, sourceBaseUrl)
const hasBoth = r.status === 'changed' || r.status === 'unchanged'
Expand Down Expand Up @@ -1431,6 +1485,7 @@ function run(args: Args): number {
baselineDir,
outputDir,
threshold,
maxShift,
failOnMissingBaseline
} = args

Expand Down Expand Up @@ -1471,16 +1526,30 @@ function run(args: Args): number {
actual: padded
} = diffMask(baseline, actual, threshold)

const status: Status =
// Is the change just a layout shift? Checked only after the straight
// comparison fails; the size guard stops a real layout change from being
// shifted away.
let status: Status =
numDiff === 0 && !sizeMismatch ? 'unchanged' : 'changed'
let layoutShifted = false
if (
status === 'changed' &&
maxShift > 0 &&
Math.abs(baseline.width - actual.width) <= maxShift &&
Math.abs(baseline.height - actual.height) <= maxShift &&
matchesWhenShifted(baseline, actual, threshold, maxShift)
Comment thread
matyasf marked this conversation as resolved.
) {
status = 'unchanged'
layoutShifted = true
}

if (status === 'changed') {
const highlight = highlightImage(padded, changed, width, height)
mkdirSync(join(outputDir, 'diff'), { recursive: true })
writeFileSync(join(outputDir, 'diff', name), PNG.sync.write(highlight))
}

results.push({ name, status, numDiff, sizeMismatch })
results.push({ name, status, numDiff, sizeMismatch, layoutShifted })
}

let a11y: A11y | null = null
Expand Down Expand Up @@ -1580,6 +1649,12 @@ export default {
describe: 'pixelmatch color threshold (0-1)',
default: 0.1
},
'max-shift': {
type: 'number',
describe:
'Treat a screenshot as unchanged when it matches its baseline exactly after being shifted up to this many pixels in any direction. Absorbs whole-pixel layout rounding. 0 requires an exact match.',
default: 1
},
'fail-on-missing-baseline': {
type: 'boolean',
describe: 'Exit non-zero if actual screenshots have no matching baseline',
Expand Down
Loading