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/all-houses-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@wdio/image-comparison-core": major
"@wdio/visual-service": major
---

### 💥 Breaking change: new image comparison engine

We replaced the engine that powers every visual comparison. This is a breaking change, so please read the migration note below before upgrading.

**The problem**

Visual tests were flaky. Tests failed on differences that are impossible to see by eye, like sub-pixel font rendering, 1px anti-aliasing on edges and small shadow shifts between runs. The old engine (resemble.js) compared raw RGB values, which does not match how human vision works, and on larger screenshots it quietly skipped about a third of the pixels. So you got failures that were not real, and in some cases real changes that could slip through.

On top of that, all the image handling (decode, crop, composite, rotate, resize) ran through [`jimp`](https://github.com/jimp-dev/jimp), a large dependency that we only used a small slice of and that is no longer actively maintained.

**The solution**

Two things changed under the hood:

1. The comparison engine is now [pixelmatch](https://github.com/mapbox/pixelmatch). It compares images the way the eye perceives them (in the YIQ colour space) and detects anti-aliasing by checking both images at once. Invisible rendering noise now passes, and real regressions still fail.
2. `jimp` has been removed completely. PNG decode and encode now go through the small [`fast-png`](https://github.com/image-js/fast-png) library, and the handful of image operations we still need (crop, composite, canvas, opacity, rotate, resize) live in a tiny internal helper. The bundled resemble file is gone too. The net effect is a much lighter dependency footprint with no loss in functionality.

**What you need to do**

- Your public API does not change. `checkScreen`, `checkElement`, `checkFullPageScreen` and the matchers all work exactly as before, and the same `ignore` options are supported.
- Because the new engine measures differences differently, mismatch percentages will not match the old numbers exactly. You should re-run your suite once and re-accept your baselines so they are generated with the new engine. After that your tests should be noticeably more stable.

**Also fixed in this release**

- **Top-row artifact on full page screenshots:** Jimp's `contain()` centred the image, shifting content by 1px and creating a false diff across the top row. Replaced with buffer-level padding that anchors content at (0,0).
- **Ignored region 1px under-coverage:** The device-pixel size of an ignored region used `Math.floor`, which could drop a pixel when `cssSize * DPR` had a fractional part. Width and height now use `Math.ceil` so the full element is always covered. Position still uses `Math.floor`.
- **Comparison sensitivity matches what you were used to:** Switching engines meant retuning how strict a comparison is. The pixelmatch threshold is now aligned with the old resemble tolerances, so a difference that used to fail still fails and one that used to pass still passes. The diff highlight also uses a single consistent colour instead of varying per run.
- **Different image sizes no longer crash the comparison:** When a baseline and the actual screenshot had slightly different dimensions, the old flow threw an error and you lost the result. Both images are now normalised to the same size before they are compared, so a size change is reported as a visual difference you can review instead of a hard failure.
- **More reliable ignore regions with WebDriver BiDi:** With BiDi the calculated element bounds can be off by a pixel or two, which sometimes left part of an ignored element just outside the ignored area and caused a false diff. The BiDi emulated flow now uses a larger `ignoreRegionPadding` so the whole element stays covered.

### Committers: 1

- Wim Selles ([@wswebcreation](https://github.com/wswebcreation))
10 changes: 5 additions & 5 deletions .github/workflows/scheduled-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to run tests against'
description: "Branch to run tests against"
type: string
required: true
default: 'main'
default: "main"
schedule:
- cron: '0 */2 * * *' # Every 2 hours; disable via GitHub UI or let SCHEDULED_TESTS_UNTIL expire
- cron: "0 */2 * * *" # Every 2 hours; disable via GitHub UI or let SCHEDULED_TESTS_UNTIL expire

permissions:
contents: read
Expand Down Expand Up @@ -87,7 +87,7 @@ jobs:
LAMBDATEST_USERNAME: ${{ secrets.LAMBDATEST_USERNAME }}
LAMBDATEST_ACCESS_KEY: ${{ secrets.LAMBDATEST_ACCESS_KEY }}
BUILD_PREFIX: true
run: pnpm test.lambdatest.desktop --maxConcurrency=4
run: pnpm test.lambdatest.desktop --maxConcurrency=3

- name: 📤 Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
Expand Down Expand Up @@ -203,7 +203,7 @@ jobs:
LAMBDATEST_USERNAME: ${{ secrets.LAMBDATEST_USERNAME }}
LAMBDATEST_ACCESS_KEY: ${{ secrets.LAMBDATEST_ACCESS_KEY }}
BUILD_PREFIX: true
run: pnpm test.lambdatest.sims.web --maxConcurrency=6
run: pnpm test.lambdatest.sims.web --maxConcurrency=4

- name: 📤 Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ jobs:
LAMBDATEST_USERNAME: ${{ secrets.LAMBDATEST_USERNAME }}
LAMBDATEST_ACCESS_KEY: ${{ secrets.LAMBDATEST_ACCESS_KEY }}
BUILD_PREFIX: true
run: pnpm test.lambdatest.desktop --maxConcurrency=4
run: pnpm test.lambdatest.desktop --maxConcurrency=3

- name: 📤 Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ build

# folders
.tmp
.pixelmatch-tmp/
/__snapshots__/
.idea/
localBaseline/
Expand Down
3 changes: 2 additions & 1 deletion packages/image-comparison-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"watch:tsc": "pnpm run build:tsc -w"
},
"dependencies": {
"jimp": "^1.6.1",
"fast-png": "^8.0.0",
"pixelmatch": "^7.2.0",
"@wdio/logger": "^9.18.0",
"@wdio/types": "^9.27.0"
},
Expand Down
15 changes: 3 additions & 12 deletions packages/image-comparison-core/src/helpers/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { MethodImageCompareCompareOptions, ScreenMethodImageCompareCompareO
import type { BeforeScreenshotOptions, BeforeScreenshotResult } from './beforeScreenshot.interfaces.js'
import type { AfterScreenshotOptions } from './afterScreenshot.interfaces.js'
import type { InstanceData } from '../methods/instanceData.interfaces.js'
import type { ComparisonIgnoreOption } from '../resemble/compare.interfaces.js'
import type { ComparisonIgnoreOption } from '../pixelmatch/compare.interfaces.js'
import {
logAllDeprecatedCompareOptions,
isStorybook,
Expand Down Expand Up @@ -58,12 +58,6 @@ export function defaultOptions(options: ClassOptions): DefaultOptions {
waitForFontsLoaded: options.waitForFontsLoaded ?? true,
alwaysSaveActualImage: options.alwaysSaveActualImage ?? true,

/**
* Compare options (merged sequentially):
* 1. Default options (fallback)
* 2. Root compareOptions (deprecated but supported)
* 3. User-provided compareOptions
*/
compareOptions: {
...DEFAULT_COMPARE_OPTIONS,
...logAllDeprecatedCompareOptions(options),
Expand Down Expand Up @@ -270,13 +264,10 @@ export function buildAfterScreenshotOptions({
return afterOptions
}

/**
* Prepare ignore options for resemble.js comparison
*/
export function prepareIgnoreOptions(imageCompareOptions: MethodImageCompareCompareOptions): ComparisonIgnoreOption[] {
const resembleIgnoreDefaults: ComparisonIgnoreOption[] = ['alpha', 'antialiasing', 'colors', 'less', 'nothing']
const ignoreDefaults: ComparisonIgnoreOption[] = ['alpha', 'antialiasing', 'colors', 'less', 'nothing']

return resembleIgnoreDefaults.filter((option) =>
return ignoreDefaults.filter((option) =>
Object.keys(imageCompareOptions).find(
(key: keyof typeof imageCompareOptions) => key.toLowerCase().includes(option) && imageCompareOptions[key],
),
Expand Down
Loading
Loading