Skip to content

Commit 8273918

Browse files
committed
feat: fixed visual regression testing, by using VisualWorld base class
1 parent e617638 commit 8273918

15 files changed

+480
-174
lines changed

.changeset/afraid-glasses-move.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@quickpickle/vitest-browser": minor
3+
---
4+
5+
Switched to VisualWorld class for visual regression testing

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"devDependencies": {
3131
"playwright": "^1.52.0",
3232
"pnpm": "^9.15.9",
33-
"vitest": "^3.1.3"
33+
"vitest": "^3.1.4"
3434
},
3535
"dependencies": {
3636
"@changesets/cli": "^2.29.3"

packages/browser/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ The following is a pretty basic setup for testing components in Svelte, Vue, or
5757
## Known Issues:
5858

5959
* Reactivity is currently broken for Svelte and Vue tests.
60-
* Screenshot comparisons don't work yet.
6160
* Selecting elements by css selector doesn't work yet.
61+
* Performing screenshot comparisons may result in an extra screenshot
62+
being created for @vitest/browser versions below 3.2.0
6263

6364
## Suspected Issues:
6465

@@ -70,5 +71,5 @@ The following is a pretty basic setup for testing components in Svelte, Vue, or
7071

7172
[x] basic actions and outcomes in English, to match @quickpickle/playwright
7273
[x] basic tests for rendering Svelte, Vue, and React components
73-
[ ] full tests for all actions and outcomes, matching @quickpickle/playwright
74+
[x] full tests for all actions and outcomes, matching @quickpickle/playwright
7475
[ ] some sort of Storybook-esque presentation using Vitest UI

packages/browser/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,15 @@
9797
"@types/react": "^19.1.4",
9898
"@vitejs/plugin-react": "^4.4.1",
9999
"@vitejs/plugin-vue": "^5.2.4",
100-
"@vitest/browser": "^3.1.3",
100+
"@vitest/browser": "^3.1.4",
101101
"react": "^19.1.0",
102102
"rollup": "^4",
103103
"svelte": "^5",
104104
"vite": "^6",
105-
"vitest": "^3.1.3"
105+
"vitest": "^3.1.4"
106106
},
107107
"dependencies": {
108+
"buffer": "^6.0.3",
108109
"quickpickle": "workspace:^",
109110
"vitest-browser-react": "^0.1.1",
110111
"vitest-browser-svelte": "^0.1.0",

packages/browser/rollup.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default {
4343
}),
4444
],
4545
external: [
46+
/^buffer/,
4647
'quickpickle',
4748
'vite',
4849
'vitest',

packages/browser/src/VitestBrowserWorld.ts

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,33 @@ import { Before, QuickPickleWorld, QuickPickleWorldInterface } from 'quickpickle
22
import type { BrowserPage, Locator, UserEvent, ScreenshotOptions } from '@vitest/browser/context'
33
import { defaultsDeep } from 'lodash-es'
44
import type { TestContext } from 'vitest';
5-
import type { InfoConstructor } from 'quickpickle/dist/world';
5+
import { ScreenshotComparisonOptions, VisualConfigSetting, VisualWorld, VisualWorldInterface, type InfoConstructor } from 'quickpickle';
6+
import { commands } from '@vitest/browser/context';
7+
import { Buffer } from 'buffer'
8+
69

710
/// <reference types="@vitest/browser/providers/playwright" />
811

9-
export type VitestWorldConfig = {
12+
export interface VitestWorldConfigSetting extends VisualConfigSetting {
1013
componentDir?: string;
11-
screenshotDir?: string;
12-
screenshotOptions?: Partial<ScreenshotOptions>;
1314
}
1415

15-
export const defaultVitestWorldConfig:VitestWorldConfig = {
16+
export const defaultVitestWorldConfig:VitestWorldConfigSetting = {
1617
componentDir: '', // directory in which components are kept, relative to project root
1718
screenshotDir: 'screenshots', // directory in which to save screenshots, relative to project root (default: "screenshots")
18-
screenshotOptions: {}, // options for the default screenshot comparisons
19+
screenshotOpts: { // options for the default screenshot comparisons
20+
threshold: 0.1,
21+
alpha: 0.6,
22+
maxDiffPercentage: .01,
23+
},
1924
}
2025

2126
export type ActionsInterface = {
2227
clicks: any[];
2328
doubleClicks: any[];
2429
}
2530

26-
export type VitestBrowserWorldInterface = QuickPickleWorldInterface & {
31+
export interface VitestBrowserWorldInterface extends VisualWorldInterface {
2732
/**
2833
* The `render` function must be provided by the World Constructor
2934
* and must be tailored for the framework being used. It should render
@@ -49,7 +54,7 @@ export type VitestBrowserWorldInterface = QuickPickleWorldInterface & {
4954
userEvent: UserEvent;
5055
}
5156

52-
export class VitestBrowserWorld extends QuickPickleWorld implements VitestBrowserWorldInterface {
57+
export class VitestBrowserWorld extends VisualWorld implements VitestBrowserWorldInterface {
5358

5459
actions:ActionsInterface = {
5560
clicks: [],
@@ -59,7 +64,7 @@ export class VitestBrowserWorld extends QuickPickleWorld implements VitestBrowse
5964
userEvent!: UserEvent;
6065
async render(name:string|any,props?:any,renderOptions?:any){};
6166
async cleanup(){};
62-
private _page:Locator|null = null;
67+
_page!:Locator|null;
6368

6469
constructor(context:TestContext, info:InfoConstructor) {
6570
info = defaultsDeep(info || {}, { config: { worldConfig: defaultVitestWorldConfig } } )
@@ -75,22 +80,6 @@ export class VitestBrowserWorld extends QuickPickleWorld implements VitestBrowse
7580
this.userEvent = browserContext.userEvent;
7681
}
7782

78-
get screenshotDir() {
79-
return this.sanitizePath(this.worldConfig.screenshotDir)
80-
}
81-
82-
get screenshotFilename() {
83-
return `${this.toString().replace(/^.+?Feature: /, '').replace(' ' + this.info.step, '')}.png`
84-
}
85-
86-
async screenshot(options:{name?:string, locator?:Locator} = {}) {
87-
let locator = options.locator || this.page
88-
let path = options.name
89-
? this.fullPath(`${this.screenshotDir}/${options.name}${(this.info.explodedIdx ? ` (${this.info.tags.join(',')})` : '')}.png`.replace(/\.png\.png$/i, '.png'))
90-
: this.fullPath(`${this.screenshotDir}/${this.screenshotFilename}`)
91-
await locator.screenshot({ path })
92-
}
93-
9483
get page():Locator {
9584
if (!this._page) throw new Error('You must render a component before running tests.')
9685
return this._page
@@ -220,9 +209,75 @@ export class VitestBrowserWorld extends QuickPickleWorld implements VitestBrowse
220209
if (toBePresent === (matchingElements.length === 0)) throw new Error(`The${toBeVisible ? '' : ' hidden'} element "${locator}" was unexpectedly ${toBePresent ? 'not present' : 'present'}.`)
221210
}
222211

212+
async screenshot(opts?:{
213+
bufferOnly?:boolean
214+
name?:string
215+
locator?:Locator
216+
}):Promise<any> {
217+
let path
218+
if (!opts?.bufferOnly) path = this.getScreenshotPath(opts?.name)
219+
let locator = opts?.locator ?? this.page
220+
return locator.screenshot({ path })
221+
}
222+
223+
async expectScreenshotMatch(locator:Locator, filename?:string, opts:ScreenshotComparisonOptions={}) {
224+
225+
const filepath = this.getScreenshotPath(filename)
226+
let expectedImg:string
227+
228+
/**
229+
* Load existing screenshot, or save it if it doesn't yet exist
230+
*/
231+
try {
232+
expectedImg = await commands.readFile(this.getScreenshotPath(filename), 'base64')
233+
}
234+
catch(e) {
235+
// If the screenshot doesn't exist, save it and pass the test
236+
await locator.screenshot()
237+
console.warn(`new visual regression test: ${this.screenshotDir}/${this.screenshotFilename}`)
238+
return
239+
}
240+
241+
let expected = Buffer.from(expectedImg, 'base64')
242+
243+
/**
244+
* Get the screenshot
245+
*/
246+
// type does not include the "save" option in the docs: see https://vitest.dev/guide/browser/locators#screenshot
247+
let screenshotOptions = { save:false, base64:true } as ScreenshotOptions
248+
let actualImg = await locator.screenshot(screenshotOptions) as string|{ base64:string }
249+
let actual = Buffer.from(typeof actualImg === 'string' ? actualImg : actualImg.base64, 'base64')
250+
251+
/**
252+
* Compare the two screenshots
253+
*/
254+
let screenshotOpts = defaultsDeep(opts, this.worldConfig.screenshotOpts)
255+
let matchResult = null
256+
try {
257+
matchResult = await this.screenshotDiff(actual, expected, screenshotOpts)
258+
}
259+
catch(e) {}
260+
console.log({
261+
pass: matchResult?.pass,
262+
diffPercentage: matchResult?.diffPercentage,
263+
filename,
264+
locator,
265+
}.toString())
266+
267+
if (!matchResult?.pass) {
268+
await commands.writeFile(`${filepath}.actual.png`, actual.toString('base64'), 'base64');
269+
if (matchResult?.diff) await commands.writeFile(`${filepath}.diff.png`, matchResult.diff.toString('base64'), 'base64');
270+
throw new Error(`Screenshot does not match the snapshot.
271+
Diff percentage: ${matchResult?.diffPercentage?.toFixed(2) ?? '100'}%
272+
Max allowed: ${screenshotOpts.maxDiffPercentage}%
273+
Diffs at: ${filepath}.(diff|actual).png`)
274+
}
275+
}
276+
223277
/**
224278
* Waits for a certain amount of time
225279
* @param ms number
280+
* @deprecated use `wait` method instead
226281
*/
227282
async waitForTimeout(ms:number) {
228283
await new Promise(r => setTimeout(r, ms))

packages/browser/src/actions.steps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,10 @@ When('I uncheck (the ){string}( checkbox)( box)', async function (world:VitestBr
148148
// })
149149

150150
When('I wait (for ){int}ms', async function (world:VitestBrowserWorld, num) {
151-
await world.waitForTimeout(num)
151+
await world.wait(num)
152152
})
153153
When('I wait (for ){float} second(s)', async function (world:VitestBrowserWorld, num) {
154-
await world.waitForTimeout(num * 1000)
154+
await world.wait(num * 1000)
155155
})
156156

157157
// ================

packages/browser/src/outcomes.steps.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -232,34 +232,16 @@ Then('a/an/the (value of )(the ){string} {word} should not/NOT be/equal {int}',
232232

233233
// Visual regression testing
234234
Then('(the )screenshot/snapshot should match', async function (world:VitestBrowserWorld) {
235-
await expect(world.page).toMatchImageSnapshot({
236-
...world.worldConfig.screenshotOptions,
237-
customSnapshotsDir: world.screenshotDir,
238-
customSnapshotIdentifier: world.screenshotFilename,
239-
})
235+
await world.expectScreenshotMatch(world.page)
240236
})
241237
Then('(the )screenshot/snapshot {string} should match', async function (world:VitestBrowserWorld, name:string) {
242-
let explodedTags = world.info.explodedIdx ? `_(${world.info.tags.join(',')})` : ''
243-
await expect(world.page).toMatchImageSnapshot({
244-
...world.worldConfig.screenshotOptions,
245-
customSnapshotsDir: world.screenshotDir,
246-
customSnapshotIdentifier: `${name}${explodedTags}`,
247-
})
238+
await world.expectScreenshotMatch(world.page, name)
248239
})
249240
Then('(the )screenshot/snapshot of the {string} {word} should match', async function (world:VitestBrowserWorld, identifier, role) {
250241
let locator = await world.getLocator(world.page, identifier, role)
251-
await expect(locator).toMatchImageSnapshot({
252-
...world.worldConfig.screenshotOptions,
253-
customSnapshotsDir: world.screenshotDir,
254-
customSnapshotIdentifier: world.screenshotFilename,
255-
})
242+
await world.expectScreenshotMatch(locator)
256243
})
257244
Then('(the )screenshot/snapshot {string} of the {string} {word} should match', async function (world:VitestBrowserWorld, name, identifier, role) {
258245
let locator = await world.getLocator(world.page, identifier, role)
259-
let explodedTags = world.info.explodedIdx ? `_(${world.info.tags.join(',')})` : ''
260-
await expect(locator).toMatchImageSnapshot({
261-
...world.worldConfig.screenshotOptions,
262-
customSnapshotsDir: world.screenshotDir,
263-
customSnapshotIdentifier: `${name}${explodedTags}`,
264-
})
246+
await world.expectScreenshotMatch(locator, name)
265247
})

packages/browser/tests/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__screenshots__/

packages/browser/tests/generic/browser-actions.feature

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ Feature: Actions step definitions in Vitest Browser
168168

169169
Scenario: Taking a default screenshot
170170
When I take a screenshot
171-
Then the screenshot "Actions step definitions in Vitest Browser_Taking a default screenshot_01.png" should exist--delete it
171+
Then the screenshot "Feature Actions step definitions in Vitest Browser_Taking a default screenshot_01.png" should exist--delete it
172172

173173
Scenario: Taking a named screenshot
174174
When I take a screenshot named "pickles"
@@ -184,10 +184,10 @@ Feature: Actions step definitions in Vitest Browser
184184

185185
@sequential
186186
Scenario: Cleaning up the screenshots with exploded tags
187-
Then the screenshot "Actions step definitions in Vitest Browser_Taking a default screenshot with exploded tags (sequential,tag1)_01.png" should exist--delete it
188-
And the screenshot "Actions step definitions in Vitest Browser_Taking a default screenshot with exploded tags (sequential,tag2)_01.png" should exist--delete it
187+
Then the screenshot "Feature Actions step definitions in Vitest Browser_Taking a default screenshot with exploded tags (sequential,tag1)_01.png" should exist--delete it
188+
And the screenshot "Feature Actions step definitions in Vitest Browser_Taking a default screenshot with exploded tags (sequential,tag2)_01.png" should exist--delete it
189189

190190
@sequential @skip-ci
191191
Scenario: Cleaning up the screenshots with exploded tags
192-
And the screenshot "temp (sequential,tag1).png" should exist--delete it
193-
And the screenshot "temp (sequential,tag2).png" should exist--delete it
192+
And the screenshot "temp_(sequential,tag1).png" should exist--delete it
193+
And the screenshot "temp_(sequential,tag2).png" should exist--delete it

0 commit comments

Comments
 (0)