Skip to content
Open
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
38 changes: 38 additions & 0 deletions .changeset/fix-ignore-options-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@wdio/image-comparison-core": patch
"@wdio/visual-service": patch
---

fix: ignore* option parity with resemble (pixelmatch)

After v10 switched to pixelmatch, the public `ignore*` API did not fully match resemble.js preset behaviour. Combined modes such as `ignoreLess` with the default `ignoreAntialiasing: true` still inherited AA forgiveness, and `ignoreColors` used BT.601 grayscale instead of resemble brightness-only comparison.

**What changed**

- Multiple `ignore*` flags now follow resemble last-wins ordering (`ignoreAlpha` → `ignoreAntialiasing` → `ignoreColors` → `ignoreLess` → `ignoreNothing`) instead of composing independently
- `ignoreLess`, `ignoreAlpha`, `ignoreColors`, and `ignoreNothing` now apply their own threshold and AA rules when active — they no longer inherit default `ignoreAntialiasing: true` forgiveness
- `ignoreColors` now compares brightness only using resemble luma weights (`0.3/0.59/0.11`), matching resemble v9 behaviour
- Added golden fixture parity tests for all ignore modes
- JSDoc and README document preset mapping, last-wins semantics, and default vs resemble v9
- Logs a WDIO warning when multiple `ignore*` flags are enabled, naming which option wins

**Preset reference**

| Active preset | threshold | AA forgiven |
|---|---|---|
| `ignoreNothing` | 0 | no |
| `ignoreLess` | ~16/255 | no |
| `ignoreColors` | ~16/255 | no (brightness only) |
| `ignoreAlpha` | ~16/255 | no |
| `ignoreAntialiasing` (default) | ~32/255 | yes |

**Migration**

- No action needed if you use a single ignore flag or rely on defaults (`ignoreAntialiasing: true`), behaviour is unchanged for typical users
- Set `ignoreAntialiasing: false` when you need strict comparison where anti-aliased pixels count as differences
- Multi-flag combos now match resemble v9 last-wins behaviour; review tests if you combine ignore flags
- `ignoreColors` results may differ slightly from v10 but align with resemble v9

### Committers: 1

- Wim Selles ([@wswebcreation](https://github.com/wswebcreation))
37 changes: 37 additions & 0 deletions packages/image-comparison-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,40 @@ npm install @wdio/image-comparison-core --save-dev
```

Instructions on how to get started can be found in the [visual testing](https://webdriver.io/docs/visual-testing) docs on the WebdriverIO project page.

## `ignore*` comparison options (pixelmatch)

v10 uses [pixelmatch](https://github.com/mapbox/pixelmatch) instead of resemble.js. The public `ignore*` API is preserved and mapped to resemble-style presets via last-wins semantics.

### Defaults vs resemble v9

| | v9 (resemble.js) | v10 default |
|---|---|---|
| AA forgiveness | opt-in (`ignoreAntialiasing: true`) | on by default (`ignoreAntialiasing: true`) |
| Strict comparison | default | set `ignoreAntialiasing: false` |
| Engine | resemble RGB/brightness | pixelmatch YIQ perceptual distance |

No config change is needed if you rely on forgiving comparison behaviour.

### Preset mapping

| Option | Preprocessing | pixelmatch threshold | AA forgiven |
|---|---|---|---|
| *(none, `ignoreAntialiasing: false`)* | — | ~16/255 (`0.063`) | no |
| `ignoreAntialiasing` | — | ~32/255 (`0.13`) | yes |
| `ignoreLess` | — | ~16/255 (`0.063`) | no |
| `ignoreAlpha` | alpha → opaque | ~16/255 (`0.063`) | no |
| `ignoreColors` | resemble luma grayscale | ~16/255 (`0.063`) | no |
| `ignoreNothing` | — | `0` | no |

Thresholds are calibrated to resemble outcomes; the underlying algorithm is YIQ perceptual distance, not resemble's RGB math.

### Last-wins semantics

When multiple `ignore*` flags are enabled, the active preset is the **last** one in this order (matching resemble.js):

`alpha` → `antialiasing` → `colors` → `less` → `nothing`

Example: `ignoreLess: true` with the default `ignoreAntialiasing: true` resolves to the `ignoreLess` preset — strict AA, not forgiving.

Golden fixture tests documenting expected pass/fail behaviour live in [`tests/fixtures/ignore-options/`](./tests/fixtures/ignore-options/).
19 changes: 14 additions & 5 deletions packages/image-comparison-core/src/base.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,27 +90,36 @@ export interface BaseMobileWebScreenshotOptions {

export interface BaseImageCompareOptions {
/**
* Compare images and discard alpha
* Ignore alpha-channel differences during comparison.
* Preprocessing sets all alpha values to opaque before pixelmatch runs.
* Preset: strict threshold (~16/255), AA not forgiven.
* @default false
*/
ignoreAlpha?: boolean;
/**
* Compare images and forgive anti-aliasing differences
* Forgive anti-aliased pixels during comparison (pixelmatch `includeAA: false`).
* Preset: relaxed threshold (~32/255), AA forgiven.
* When combined with other ignore flags, last-wins order applies
* (`alpha` → `antialiasing` → `colors` → `less` → `nothing`).
* @default true
*/
ignoreAntialiasing?: boolean;
/**
* Compare images in black and white mode
* Compare brightness only, ignoring hue differences.
* Preprocessing converts both images to grayscale using resemble luma (`0.3/0.59/0.11`).
* Preset: strict threshold (~16/255), AA not forgiven.
* @default false
*/
ignoreColors?: boolean;
/**
* Compare with reduced color sensitivity
* Use a relaxed RGB tolerance (~16/255 per channel in YIQ space).
* Preset: strict threshold, AA not forgiven (does not inherit default AA forgiveness).
* @default false
*/
ignoreLess?: boolean;
/**
* Compare with maximum sensitivity
* Use zero tolerance — any pixel difference counts as a mismatch.
* Preset: threshold `0`, AA not forgiven.
* @default false
*/
ignoreNothing?: boolean;
Expand Down
44 changes: 29 additions & 15 deletions packages/image-comparison-core/src/helpers/options.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,31 +159,38 @@ export interface ClassOptions {
diffPixelBoundingBoxProximity?: number;

/**
* Ignore alpha channel when comparing images.
* Ignore alpha-channel differences during comparison.
* Preprocessing sets all alpha values to opaque before pixelmatch runs.
* Preset: strict threshold (~16/255), AA not forgiven.
*/
ignoreAlpha?: boolean;

/**
* Forgive anti-aliasing differences when comparing images.
* Forgive anti-aliased pixels during comparison (pixelmatch `includeAA: false`).
* Preset: relaxed threshold (~32/255), AA forgiven.
* When combined with other ignore flags, last-wins order applies
* (`alpha` → `antialiasing` → `colors` → `less` → `nothing`).
* Defaults to `true` so sub-pixel rendering noise is ignored out of the box.
* Set to `false` for strict pixel comparison where AA pixels count as mismatches.
* Set to `false` for strict comparison where AA pixels count as mismatches.
*/
ignoreAntialiasing?: boolean;

/**
* Compare two images in black and white only.
* Compare brightness only, ignoring hue differences.
* Preprocessing converts both images to grayscale using resemble luma (`0.3/0.59/0.11`).
* Preset: strict threshold (~16/255), AA not forgiven.
*/
ignoreColors?: boolean;

/**
* Compare images with reduced sensitivity.
* red = 16, green = 16, blue = 16, alpha = 16, minBrightness = 16, maxBrightness = 240
* Use a relaxed RGB tolerance (~16/255 per channel in YIQ space).
* Preset: strict threshold, AA not forgiven (does not inherit default AA forgiveness).
*/
ignoreLess?: boolean;

/**
* Compare images with full sensitivity.
* red = 0, green = 0, blue = 0, alpha = 0, minBrightness = 0, maxBrightness = 255
* Use zero tolerance — any pixel difference counts as a mismatch.
* Preset: threshold `0`, AA not forgiven.
*/
ignoreNothing?: boolean;

Expand Down Expand Up @@ -437,29 +444,36 @@ export interface CompareOptions {
diffPixelBoundingBoxProximity: number;

/**
* Compare images and discard the alpha channel.
* Ignore alpha-channel differences during comparison.
* Preprocessing sets all alpha values to opaque before pixelmatch runs.
* Preset: strict threshold (~16/255), AA not forgiven.
*/
ignoreAlpha: boolean;

/**
* Forgive anti-aliasing differences when comparing images.
* Forgive anti-aliased pixels during comparison (pixelmatch `includeAA: false`).
* Preset: relaxed threshold (~32/255), AA forgiven.
* When combined with other ignore flags, last-wins order applies
* (`alpha` → `antialiasing` → `colors` → `less` → `nothing`).
*/
ignoreAntialiasing: boolean;

/**
* Compare two black-and-white versions of the images, ignoring colors.
* Compare brightness only, ignoring hue differences.
* Preprocessing converts both images to grayscale using resemble luma (`0.3/0.59/0.11`).
* Preset: strict threshold (~16/255), AA not forgiven.
*/
ignoreColors: boolean;

/**
* Use a less sensitive comparison setting:
* red = 16, green = 16, blue = 16, alpha = 16, minBrightness = 16, maxBrightness = 240
* Use a relaxed RGB tolerance (~16/255 per channel in YIQ space).
* Preset: strict threshold, AA not forgiven (does not inherit default AA forgiveness).
*/
ignoreLess: boolean;

/**
* Use the most sensitive comparison setting:
* red = 0, green = 0, blue = 0, alpha = 0, minBrightness = 0, maxBrightness = 255
* Use zero tolerance — any pixel difference counts as a mismatch.
* Preset: threshold `0`, AA not forgiven.
*/
ignoreNothing: boolean;

Expand Down
69 changes: 67 additions & 2 deletions packages/image-comparison-core/src/helpers/options.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { describe, it, expect } from 'vitest'
import { defaultOptions, methodCompareOptions, screenMethodCompareOptions, createBeforeScreenshotOptions, buildAfterScreenshotOptions, prepareIgnoreOptions } from './options.js'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { join } from 'node:path'
import logger from '@wdio/logger'
import { defaultOptions, methodCompareOptions, screenMethodCompareOptions, createBeforeScreenshotOptions, buildAfterScreenshotOptions, prepareIgnoreOptions, resolveActiveIgnorePreset, resolveComparePreset, warnOnMultipleIgnorePresets } 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'
import type { BeforeScreenshotResult } from './beforeScreenshot.interfaces.js'

const log = logger('test')

vi.mock('@wdio/logger', () => import(join(process.cwd(), '__mocks__', '@wdio/logger')))

describe('options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('defaultOptions', () => {
it('should return the default options when no options are provided', () => {
expect(defaultOptions({})).toMatchSnapshot()
Expand Down Expand Up @@ -490,4 +499,60 @@ 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,
})
})
})

describe('warnOnMultipleIgnorePresets', () => {
it('does not warn when a single ignore preset is enabled', () => {
warnOnMultipleIgnorePresets(['antialiasing'])

expect(log.warn).not.toHaveBeenCalled()
})

it('warns with the active preset when multiple ignore flags are enabled', () => {
warnOnMultipleIgnorePresets(['antialiasing', 'less'])

expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining('Multiple ignore* compare options are enabled'),
'ignoreAntialiasing, ignoreLess',
'ignoreLess',
)
})
})
})
Loading
Loading