Skip to content

Commit 2a518ff

Browse files
fix: pass matcher threshold to core as saveAboveTolerance (#1113)
* fix: pass matcher threshold to core as saveAboveTolerance When using visual matchers with a threshold (e.g., toMatchScreenSnapshot('tag', 0.9)) and alwaysSaveActualImage: false, images were still saved even when the comparison passed within the threshold. The matcher now passes the expected threshold to the core as saveAboveTolerance, ensuring images are only saved when mismatch exceeds the user's acceptable threshold. Fixes #1111 Co-authored-by: Cursor <[email protected]> * chore: add extra UI test * chore: update changelog --------- Co-authored-by: Cursor <[email protected]>
1 parent ad25ea6 commit 2a518ff

4 files changed

Lines changed: 139 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@wdio/visual-service": patch
3+
---
4+
5+
# 🐛 Bugfixes
6+
7+
## #1111 Pass matcher threshold to core as saveAboveTolerance
8+
9+
When using visual matchers like `toMatchScreenSnapshot('tag', 0.9)` with `alwaysSaveActualImage: false`, the actual image was still being saved even when the comparison passed within the threshold.
10+
11+
The root cause was that the matcher's expected threshold was not being passed to the core comparison logic. The core used `saveAboveTolerance` (defaulting to 0) to decide whether to save images, while the matcher used the user-provided threshold to determine pass/fail - these were disconnected.
12+
13+
This fix ensures the matcher passes the expected threshold to the core as `saveAboveTolerance`, so images are only saved when the mismatch actually exceeds the user's acceptable threshold.
14+
15+
16+
# Committers: 1
17+
18+
- Wim Selles ([@wswebcreation](https://github.com/wswebcreation))

packages/visual-service/src/matcher.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,25 @@ function parseMatcherParams (
119119
*/
120120
options.returnAllCompareData = true
121121

122+
/**
123+
* Pass the expected threshold to the core as `saveAboveTolerance` so it knows
124+
* when to save actual images (only when mismatch exceeds the threshold).
125+
* This ensures that when `alwaysSaveActualImage: false`, images are not saved
126+
* if the comparison passes within the user's acceptable threshold.
127+
* Only set if user hasn't explicitly set saveAboveTolerance.
128+
* For numeric thresholds, use that value; otherwise default to 0 (same as comparison default).
129+
* @see https://github.com/webdriverio/visual-testing/issues/1111
130+
*/
131+
if (options.saveAboveTolerance === undefined) {
132+
// Only set saveAboveTolerance for numeric thresholds (including undefined which defaults to 0)
133+
// Asymmetric matchers can't be converted to a numeric tolerance
134+
if (typeof expectedResult === 'number') {
135+
options.saveAboveTolerance = expectedResult
136+
} else if (expectedResult === undefined) {
137+
options.saveAboveTolerance = DEFAULT_EXPECTED_RESULT
138+
}
139+
}
140+
122141
return { expectedResult, options }
123142
}
124143

packages/visual-service/tests/matcher.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,72 @@ describe('custom visual matcher', () => {
145145
expect(results.pass).toBe(false)
146146
expect(results.message()).toMatchSnapshot()
147147
})
148+
149+
describe('saveAboveTolerance threshold passthrough (#1111)', () => {
150+
it('should pass numeric expectedResult as saveAboveTolerance to checkScreen', async () => {
151+
await toMatchScreenSnapshot(browser, 'foo', 0.9, {})
152+
expect(browser.checkScreen).toHaveBeenCalledWith('foo', {
153+
returnAllCompareData: true,
154+
saveAboveTolerance: 0.9
155+
})
156+
})
157+
158+
it('should pass numeric expectedResult as saveAboveTolerance to checkFullPageScreen', async () => {
159+
await toMatchFullPageSnapshot(browser, 'foo', 0.5, {})
160+
expect(browser.checkFullPageScreen).toHaveBeenCalledWith('foo', {
161+
returnAllCompareData: true,
162+
saveAboveTolerance: 0.5
163+
})
164+
})
165+
166+
it('should pass numeric expectedResult as saveAboveTolerance to checkElement', async () => {
167+
await toMatchElementSnapshot(browser as any as WebdriverIO.Element, 'foo', 1.5, {})
168+
expect(browser.checkElement).toHaveBeenCalledWith(browser, 'foo', {
169+
returnAllCompareData: true,
170+
saveAboveTolerance: 1.5
171+
})
172+
})
173+
174+
it('should pass numeric expectedResult as saveAboveTolerance to checkTabbablePage', async () => {
175+
await toMatchTabbablePageSnapshot(browser, 'foo', 2.0, {})
176+
expect(browser.checkTabbablePage).toHaveBeenCalledWith('foo', {
177+
returnAllCompareData: true,
178+
saveAboveTolerance: 2.0
179+
})
180+
})
181+
182+
it('should use default saveAboveTolerance of 0 when no threshold is provided', async () => {
183+
await toMatchScreenSnapshot(browser, 'foo')
184+
expect(browser.checkScreen).toHaveBeenCalledWith('foo', {
185+
returnAllCompareData: true,
186+
saveAboveTolerance: 0
187+
})
188+
})
189+
190+
it('should not override user-provided saveAboveTolerance', async () => {
191+
await toMatchScreenSnapshot(browser, 'foo', 0.9, { saveAboveTolerance: 0.1 })
192+
expect(browser.checkScreen).toHaveBeenCalledWith('foo', {
193+
returnAllCompareData: true,
194+
saveAboveTolerance: 0.1 // User's explicit value is preserved
195+
})
196+
})
197+
198+
it('should not set saveAboveTolerance for asymmetric matchers', async () => {
199+
await toMatchScreenSnapshot(browser, 'foo', expect.any(Number))
200+
expect(browser.checkScreen).toHaveBeenCalledWith('foo', {
201+
returnAllCompareData: true
202+
// No saveAboveTolerance - can't convert asymmetric matcher to number
203+
})
204+
})
205+
206+
it('should set saveAboveTolerance to 0 when options object is passed without threshold', async () => {
207+
// When only options are passed (no expectedResult), threshold defaults to 0
208+
await toMatchScreenSnapshot(browser, 'foo', { hideScrollBars: true })
209+
expect(browser.checkScreen).toHaveBeenCalledWith('foo', {
210+
hideScrollBars: true,
211+
returnAllCompareData: true,
212+
saveAboveTolerance: 0
213+
})
214+
})
215+
})
148216
})

tests/specs/matcher.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/// <reference types="@wdio/visual-service" />
1+
import { readdirSync } from 'node:fs'
2+
import { join } from 'node:path'
23
import { browser, expect } from '@wdio/globals'
34

45
describe('@wdio/visual-service matcher', () => {
@@ -36,4 +37,36 @@ describe('@wdio/visual-service matcher', () => {
3637
]
3738
})
3839
})
40+
41+
it(`should NOT save actual image when mismatch is within threshold (#1111) for '${browserName}'`, async () => {
42+
const tag = 'threshold-test-1111'
43+
const actualFolder = join(process.cwd(), '.tmp/actual')
44+
const subtitle = await $('.hero__subtitle')
45+
const getActualImageCount = () => {
46+
try {
47+
return readdirSync(actualFolder).filter(f => f.includes(tag)).length
48+
} catch {
49+
return 0
50+
}
51+
}
52+
53+
// 1. Save the original subtitle as baseline
54+
await browser.saveElement(subtitle, tag)
55+
56+
// 2. Manipulate the subtitle to create a small text difference
57+
await browser.execute(
58+
'arguments[0].innerHTML = "Test Demo Page";',
59+
subtitle
60+
)
61+
62+
const beforeCount = getActualImageCount()
63+
64+
// 3. Run the matcher with a threshold (90%) higher than the expected mismatch
65+
await expect(subtitle).toMatchElementSnapshot(tag, 90)
66+
67+
const afterCount = getActualImageCount()
68+
69+
// 4. With the fix: no new actual image should be saved when mismatch is within threshold
70+
expect(afterCount).toBe(beforeCount)
71+
})
3972
})

0 commit comments

Comments
 (0)