diff --git a/.changeset/fix-ignore-preset-resolution.md b/.changeset/fix-ignore-preset-resolution.md new file mode 100644 index 000000000..1765781be --- /dev/null +++ b/.changeset/fix-ignore-preset-resolution.md @@ -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)) diff --git a/packages/image-comparison-core/src/helpers/options.test.ts b/packages/image-comparison-core/src/helpers/options.test.ts index d1977f6ed..35f92f74d 100644 --- a/packages/image-comparison-core/src/helpers/options.test.ts +++ b/packages/image-comparison-core/src/helpers/options.test.ts @@ -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' @@ -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, + }) + }) + }) }) diff --git a/packages/image-comparison-core/src/helpers/options.ts b/packages/image-comparison-core/src/helpers/options.ts index 83cf20f6f..bcc29dfc2 100644 --- a/packages/image-comparison-core/src/helpers/options.ts +++ b/packages/image-comparison-core/src/helpers/options.ts @@ -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 = { + 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], ), diff --git a/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts b/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts index d7eb777ce..702691c5e 100644 --- a/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts +++ b/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts @@ -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, diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts index 562a88127..6cb3cdc97 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts @@ -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(), @@ -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(), @@ -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(), diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.ts index 1a515faf5..536ffc881 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.ts @@ -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' @@ -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]) @@ -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.