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
23 changes: 23 additions & 0 deletions .changeset/fix-ignore-preset-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@wdio/image-comparison-core": patch
"@wdio/visual-service": patch
---

fix: resolve ignore* presets with resemble last-wins semantics

After #1175, pixelmatch threshold and AA forgiveness were derived independently from each flag in the ignore list. Combined modes such as `ignoreLess` with the default `ignoreAntialiasing: true` still inherited AA forgiveness instead of following resemble's last-wins preset model.

**What changed**

- Added `resolveComparePreset` with resemble last-wins ordering matching `prepareIgnoreOptions`
- `ignoreLess`, `ignoreAlpha`, `ignoreColors`, and `ignoreNothing` now apply their own threshold and AA rules when active
- Default-only `ignoreAntialiasing: true` still uses the forgiving preset; explicit `ignoreAntialiasing: false` stays strict

**Migration**

- No action needed if you use a single ignore flag or rely on defaults
- Multi-flag combos now match resemble v9 last-wins behaviour; review tests if you combine ignore flags

### Committers: 1

- Wim Selles ([@wswebcreation](https://github.com/wswebcreation))
40 changes: 39 additions & 1 deletion packages/image-comparison-core/src/helpers/options.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { defaultOptions, methodCompareOptions, screenMethodCompareOptions, createBeforeScreenshotOptions, buildAfterScreenshotOptions, prepareIgnoreOptions } from './options.js'
import { defaultOptions, methodCompareOptions, screenMethodCompareOptions, createBeforeScreenshotOptions, buildAfterScreenshotOptions, prepareIgnoreOptions, resolveActiveIgnorePreset, resolveComparePreset } from './options.js'
import type { ClassOptions } from './options.interfaces.js'
import type { ScreenMethodImageCompareCompareOptions } from '../methods/images.interfaces.js'
import type { InstanceData } from '../methods/instanceData.interfaces.js'
Expand Down Expand Up @@ -490,4 +490,42 @@ describe('options', () => {
})).toEqual(['alpha', 'antialiasing', 'less'])
})
})

describe('resolveActiveIgnorePreset', () => {
it.each([
[['alpha'], 'alpha'],
[['antialiasing'], 'antialiasing'],
[['colors'], 'colors'],
[['less'], 'less'],
[['nothing'], 'nothing'],
[[], null],
] as const)('returns %s for ignore list %j', (ignoreList, expected) => {
expect(resolveActiveIgnorePreset([...ignoreList])).toBe(expected)
})

it('uses last-wins order matching prepareIgnoreOptions', () => {
expect(resolveActiveIgnorePreset(['antialiasing', 'less'])).toBe('less')
expect(resolveActiveIgnorePreset(['alpha', 'antialiasing', 'colors', 'less', 'nothing'])).toBe('nothing')
})
})

describe('resolveComparePreset', () => {
it.each([
[['nothing'], { threshold: 0, includeAA: true }],
[['less'], { threshold: 0.063, includeAA: true }],
[['antialiasing'], { threshold: 0.13, includeAA: false }],
[['alpha'], { threshold: 0.063, includeAA: true }],
[['colors'], { threshold: 0.063, includeAA: true }],
[[], { threshold: 0.063, includeAA: true }],
] as const)('maps ignore list %j to pixelmatch settings', (ignoreList, expected) => {
expect(resolveComparePreset([...ignoreList])).toEqual(expected)
})

it('applies last-wins preset for multi-flag lists', () => {
expect(resolveComparePreset(['antialiasing', 'less'])).toEqual({
threshold: 0.063,
includeAA: true,
})
})
})
})
49 changes: 46 additions & 3 deletions packages/image-comparison-core/src/helpers/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,53 @@ export function buildAfterScreenshotOptions({
return afterOptions
}

export function prepareIgnoreOptions(imageCompareOptions: MethodImageCompareCompareOptions): ComparisonIgnoreOption[] {
const ignoreDefaults: ComparisonIgnoreOption[] = ['alpha', 'antialiasing', 'colors', 'less', 'nothing']
/** Resemble last-wins order, must match `prepareIgnoreOptions` filter order. */
export const IGNORE_PRESET_ORDER: ComparisonIgnoreOption[] = ['alpha', 'antialiasing', 'colors', 'less', 'nothing']

const COMPARE_PRESET_SETTINGS: Record<ComparisonIgnoreOption, { threshold: number; includeAA: boolean }> = {
nothing: { threshold: 0, includeAA: true },
less: { threshold: 0.063, includeAA: true },
antialiasing: {
// Resemble's ignoreAntialiasing uses 32/255 per-channel tolerance (~0.13 YIQ).
threshold: 0.13,
includeAA: false,
},
alpha: { threshold: 0.063, includeAA: true },
colors: { threshold: 0.063, includeAA: true },
}

/**
* Returns the active resemble preset from an ignore list using last-wins semantics.
*/
export function resolveActiveIgnorePreset(ignoreList: ComparisonIgnoreOption[]): ComparisonIgnoreOption | null {
let activePreset: ComparisonIgnoreOption | null = null

for (const preset of IGNORE_PRESET_ORDER) {
if (ignoreList.includes(preset)) {
activePreset = preset
}
}

return ignoreDefaults.filter((option) =>
return activePreset
}

/**
* Maps an ignore list to pixelmatch threshold and AA settings via resemble last-wins presets.
* An empty list yields the strict preset (e.g. `ignoreAntialiasing: false` with no other flags).
*/
export function resolveComparePreset(ignoreList: ComparisonIgnoreOption[]): { threshold: number; includeAA: boolean } {
const activePreset = resolveActiveIgnorePreset(ignoreList)

if (activePreset) {
return COMPARE_PRESET_SETTINGS[activePreset]
}

// Default strict tolerance: 16/255 per channel (~6.3% of max YIQ distance).
return { threshold: 0.063, includeAA: true }
}

export function prepareIgnoreOptions(imageCompareOptions: MethodImageCompareCompareOptions): ComparisonIgnoreOption[] {
return IGNORE_PRESET_ORDER.filter((option) =>
Object.keys(imageCompareOptions).find(
(key: keyof typeof imageCompareOptions) => key.toLowerCase().includes(option) && imageCompareOptions[key],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,40 @@ describe('executeImageCompare', () => {
)
})

it('should resolve ignoreLess over default antialiasing via last-wins preset order', async () => {
const { resolveComparePreset } = await import('../helpers/options.js')
const optionsWithIgnoreLess = {
...mockOptions,
compareOptions: {
...mockOptions.compareOptions,
method: {
ignoreAntialiasing: true,
ignoreLess: true,
}
}
}

await executeImageCompare({
isViewPortScreenshot: true,
isNativeContext: false,
options: optionsWithIgnoreLess,
testContext: mockTestContext
})

expect(compareImagesPixelmatch.default).toHaveBeenCalledWith(
expect.any(Buffer),
expect.any(Buffer),
{
ignore: ['antialiasing', 'less'],
scaleToSameSize: true
}
)
expect(resolveComparePreset(['antialiasing', 'less'])).toEqual({
threshold: 0.063,
includeAA: true,
})
})

it('should handle ignore options from compareOptions', async () => {
const optionsWithIgnore = {
...mockOptions,
Expand Down
46 changes: 22 additions & 24 deletions packages/image-comparison-core/src/pixelmatch/compareImages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,22 +169,28 @@ describe('pixelmatch adapter - compareImages', () => {
})

describe('ignore option mapping', () => {
it('passes threshold=0 and includeAA=true for ignore: nothing', async () => {
it.each([
['nothing', { threshold: 0, includeAA: true }],
['less', { threshold: 0.063, includeAA: true }],
['antialiasing', { threshold: 0.13, includeAA: false }],
['alpha', { threshold: 0.063, includeAA: true }],
['colors', { threshold: 0.063, includeAA: true }],
] as const)('passes preset settings for ignore: %s', async (ignore, expected) => {
pixelmatchFn.mockImplementation(() => 0)

await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'nothing' })
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore })

expect(pixelmatchFn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), expect.anything(),
expect.any(Number), expect.any(Number),
expect.objectContaining({ threshold: 0, includeAA: true })
expect.objectContaining(expected)
)
})

it('passes threshold=0.063 and includeAA=true for ignore: less', async () => {
it('passes threshold=0.063 and includeAA=true when no ignore option is given', async () => {
pixelmatchFn.mockImplementation(() => 0)

await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'less' })
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {})

expect(pixelmatchFn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), expect.anything(),
Expand All @@ -193,22 +199,12 @@ describe('pixelmatch adapter - compareImages', () => {
)
})

it('passes threshold=0.13 and includeAA=false for ignore: antialiasing', async () => {
pixelmatchFn.mockImplementation(() => 0)

await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'antialiasing' })

expect(pixelmatchFn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), expect.anything(),
expect.any(Number), expect.any(Number),
expect.objectContaining({ threshold: 0.13, includeAA: false })
)
})

it('passes threshold=0.063 and includeAA=true when no ignore option is given', async () => {
it('uses last-wins preset when ignoreLess follows ignoreAntialiasing', async () => {
pixelmatchFn.mockImplementation(() => 0)

await compareImages(Buffer.from('img1'), Buffer.from('img2'), {})
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {
ignore: ['antialiasing', 'less']
})

expect(pixelmatchFn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), expect.anything(),
Expand All @@ -217,24 +213,26 @@ describe('pixelmatch adapter - compareImages', () => {
)
})

it('accepts ignore as an array and uses less threshold with antialiasing forgiveness', async () => {
it('uses last-wins preset when ignoreNothing follows other flags', async () => {
pixelmatchFn.mockImplementation(() => 0)

await compareImages(Buffer.from('img1'), Buffer.from('img2'), {
ignore: ['antialiasing', 'less']
ignore: ['antialiasing', 'less', 'nothing']
})

expect(pixelmatchFn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), expect.anything(),
expect.any(Number), expect.any(Number),
expect.objectContaining({ threshold: 0.063, includeAA: false })
expect.objectContaining({ threshold: 0, includeAA: true })
)
})

it('passes threshold=0.063 and includeAA=true for ignore: alpha without antialiasing', async () => {
it('uses last-wins preset when ignoreLess follows ignoreAlpha', async () => {
pixelmatchFn.mockImplementation(() => 0)

await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'alpha' })
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {
ignore: ['alpha', 'less']
})

expect(pixelmatchFn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), expect.anything(),
Expand Down
25 changes: 2 additions & 23 deletions packages/image-comparison-core/src/pixelmatch/compareImages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pixelmatch from 'pixelmatch'
import { resolveComparePreset } from '../helpers/options.js'
import { decodeImage, resizeBilinear, encodeImage, type RawImage } from '../utils/imageUtils.js'
import type { CompareData, ComparisonOptions, ComparisonIgnoreOption } from './compare.interfaces.js'

Expand All @@ -10,28 +11,6 @@ function resolveIgnoreList(ignore: ComparisonOptions['ignore']): ComparisonIgnor
return Array.isArray(ignore) ? ignore : [ignore]
}

function toPixelmatchOptions(ignoreList: ComparisonIgnoreOption[]): { threshold: number; includeAA: boolean } {
if (ignoreList.includes('nothing')) {
return { threshold: 0, includeAA: true }
}

const forgivesAA = ignoreList.includes('antialiasing')
const threshold = ignoreList.includes('less')
? 0.063
: forgivesAA
// Resemble's ignoreAntialiasing uses 32/255 per-channel tolerance which
// corresponds to ~0.13 in YIQ perceptual distance.
? 0.13
// Default strict tolerance: 16/255 per channel maps to ~6.3% of max YIQ distance.
: 0.063

return {
threshold,
// pixelmatch includeAA=true disables AA forgiveness; false enables it.
includeAA: !forgivesAA,
}
}

function grayscalePixels(pixels: Buffer, totalPixels: number): void {
for (let i = 0; i < totalPixels * 4; i += 4) {
const luma = Math.round(0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2])
Expand Down Expand Up @@ -133,7 +112,7 @@ export default async function compareImages(
zeroIgnoredBoxes(pixels2, width, ignoredBoxes)
}

const { threshold, includeAA } = toPixelmatchOptions(ignoreList)
const { threshold, includeAA } = resolveComparePreset(ignoreList)
const outputPixels = new Uint8Array(totalPixels * 4)

// Use magenta [255, 0, 255] for both diff and AA pixels.
Expand Down
Loading