Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
113 changes: 113 additions & 0 deletions docs/guide/browser/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,116 @@ For example, to store diffs in a subdirectory of attachments:
resolveDiffPath: ({ arg, attachmentsDir, browserName, ext, root, testFileName }) =>
`${root}/${attachmentsDir}/screenshot-diffs/${testFileName}/${arg}-${browserName}${ext}`
```

#### browser.expect.toMatchScreenshot.comparators

- **Type:** `Record<string, Comparator>`

Register custom screenshot comparison algorithms, like SSIM or perceptual diff implementations.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add links here? That explain what SSIM and perceptual diff mean

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had some issues when writing this... Went through a few iterations as I didn't know how much "beginner-friendly" to be.

I landed on "this is an advanced feature, people that are looking for it already know what they want to do with a custom comparator" so I didn't went into too much detail explaining different diffing algorithms, just provided some search terms.

If you think we should add some links, I can try finding some sources (probably Wikipedia for a general overview).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wikipedia links would be great

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the link (for SSIM) and reworded the final bit to "perceptual similarity metrics", which is more accurate. I don't have a link to this tho, as I believe it's more of an academic topic.


To create a custom comparator, you need to register it in your config. If using TypeScript, declare its options in the `ScreenshotComparatorRegistry` interface.

```ts
import { defineConfig } from 'vitest/config'

// 1. Declare the comparator's options type
declare module 'vitest/browser' {
interface ScreenshotComparatorRegistry {
myCustomComparator: {
sensitivity?: number
ignoreColors?: boolean
}
}
}

// 2. Implement the comparator
export default defineConfig({
test: {
browser: {
expect: {
toMatchScreenshot: {
comparators: {
myCustomComparator: async (
reference,
actual,
{
createDiff, // always provided by Vitest
sensitivity = 0.01,
ignoreColors = false,
}
) => {
// ...algorithm implementation
return { pass, diff, message }
},
},
},
},
},
},
})
```

Then use it in your tests:

```ts
await expect(locator).toMatchScreenshot({
comparatorName: 'myCustomComparator',
comparatorOptions: {
sensitivity: 0.08,
ignoreColors: true,
},
})
```

**Comparator Function Signature:**

```ts
type Comparator<Options> = (
reference: {
metadata: { height: number; width: number }
data: TypedArray
},
actual: {
metadata: { height: number; width: number }
data: TypedArray
},
options: {
createDiff: boolean
} & Options
) => Promise<{
pass: boolean
diff: TypedArray | null
message: string | null
}> | {
pass: boolean
diff: TypedArray | null
message: string | null
}
```

The `reference` and `actual` images are decoded using the appropriate codec (currently only PNG). The `data` property is a flat `TypedArray` (`Buffer`, `Uint8Array`, or `Uint8ClampedArray`) containing pixel data in RGBA format:

- **4 bytes per pixel**: red, green, blue, alpha (from `0` to `255` each)
- **Row-major order**: pixels are stored left-to-right, top-to-bottom
- **Total length**: `width × height × 4` bytes
- **Alpha channel**: always present. Images without transparency have alpha values set to `255` (fully opaque)

::: tip Performance Considerations
The `createDiff` option indicates whether a diff image is needed. During [stable screenshot detection](/guide/browser/visual-regression-testing#how-visual-tests-work), Vitest calls comparators with `createDiff: false` to avoid unnecessary work.

**Respect this flag to keep your tests fast**.
:::

::: warning Handle Missing Options
The `options` parameter in `toMatchScreenshot()` is optional, so users might not provide all your comparator options. Always make them optional with default values:

```ts
myCustomComparator: (
reference,
actual,
{ createDiff, threshold = 0.1, maxDiff = 100 },
) => {
// ...comparison logic
}
```
:::
18 changes: 18 additions & 0 deletions docs/guide/browser/visual-regression-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,24 @@ $ vitest --update
Review updated screenshots before committing to make sure changes are
intentional.

## How Visual Tests Work

Visual regression tests need stable screenshots to compare against. But pages aren't instantly stable as images load, animations finish, fonts render, and layouts settle.

Vitest handles this automatically through "Stable Screenshot Detection":

1. Vitest takes a first screenshot (or uses the reference screenshot if available) as baseline
1. It takes another screenshot and compares it with the baseline
- If the screenshots match, the page is stable and testing continues
- If they differ, Vitest uses the newest screenshot as the baseline and repeats
1. This continues until stability is achieved or the timeout is reached

This ensures that transient visual changes (like loading spinners or animations) don't cause false failures. If something never stops animating though, you'll hit the timeout, so consider [disabling animations during testing](#disable-animations).

If a stable screenshot is captured after retries (one or more) and a reference screenshot exists, Vitest performs a final comparison with the reference using `createDiff: true`. This will generate a diff image if they don't match.

During stability detection, Vitest calls comparators with `createDiff: false` since it only needs to know if screenshots match. This keeps the detection process fast.

## Configuring Visual Tests

### Global Configuration
Expand Down
3 changes: 2 additions & 1 deletion packages/browser-playwright/src/playwright.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable ts/method-signature-style */

import type { CustomComparatorsRegistry } from '@vitest/browser'
import type { MockedModule } from '@vitest/mocker'
import type {
Browser,
Expand Down Expand Up @@ -556,7 +557,7 @@ declare module 'vitest/node' {
extends Omit<
ScreenshotMatcherOptions,
'comparatorName' | 'comparatorOptions'
> {}
>, CustomComparatorsRegistry {}

export interface ToMatchScreenshotComparators
extends ScreenshotComparatorRegistry {}
Expand Down
3 changes: 2 additions & 1 deletion packages/browser-webdriverio/src/webdriverio.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CustomComparatorsRegistry } from '@vitest/browser'
import type { Capabilities } from '@wdio/types'
import type {
ScreenshotComparatorRegistry,
Expand Down Expand Up @@ -306,7 +307,7 @@ declare module 'vitest/node' {
extends Omit<
ScreenshotMatcherOptions,
'comparatorName' | 'comparatorOptions'
> {}
>, CustomComparatorsRegistry {}

export interface ToMatchScreenshotComparators
extends ScreenshotComparatorRegistry {}
Expand Down
9 changes: 8 additions & 1 deletion packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface ScreenshotOptions {
save?: boolean
}

export interface ScreenshotComparatorRegistry {
interface StandardScreenshotComparators {
pixelmatch: {
/**
* The maximum number of pixels that are allowed to differ between the captured
Expand Down Expand Up @@ -136,6 +136,13 @@ export interface ScreenshotComparatorRegistry {
}
}

export interface ScreenshotComparatorRegistry extends StandardScreenshotComparators {}

export type NonStandardScreenshotComparators = Omit<
ScreenshotComparatorRegistry,
keyof StandardScreenshotComparators
>

export interface ScreenshotMatcherOptions<
ComparatorName extends keyof ScreenshotComparatorRegistry = keyof ScreenshotComparatorRegistry
> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import type { BrowserCommandContext } from 'vitest/node'
import type { ScreenshotComparatorRegistry } from '../../../../../context'
import type { Comparator } from '../types'
import { pixelmatch } from './pixelmatch'

const comparators = new Map(Object.entries({
pixelmatch,
} satisfies {
const comparators: {
[ComparatorName in keyof ScreenshotComparatorRegistry]: Comparator<
ScreenshotComparatorRegistry[ComparatorName]
>
}))
} = {
pixelmatch,
}

export function getComparator<ComparatorName extends keyof ScreenshotComparatorRegistry>(
comparator: ComparatorName,
context: BrowserCommandContext,
): Comparator<ScreenshotComparatorRegistry[ComparatorName]> {
if (comparators.has(comparator)) {
return comparators.get(comparator)!
if (comparator in comparators) {
return comparators[comparator]
}

const customComparators = context
.project
.config
.browser
.expect
?.toMatchScreenshot
?.comparators

if (customComparators && comparator in customComparators) {
return customComparators[comparator]
}

throw new Error(`Unrecognized comparator ${comparator}`)
Expand Down
12 changes: 11 additions & 1 deletion packages/browser/src/node/commands/screenshotMatcher/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
NonStandardScreenshotComparators,
ScreenshotComparatorRegistry,
ScreenshotMatcherOptions,
} from '@vitest/browser/context'
Expand Down Expand Up @@ -47,12 +48,21 @@ export type Comparator<Options extends Record<string, unknown>> = (
} & Options
) => Promisable<{ pass: boolean; diff: TypedArray | null; message: string | null }>

type CustomComparatorsToRegister = {
[Key in keyof NonStandardScreenshotComparators]: Comparator<NonStandardScreenshotComparators[Key]>
}

export type CustomComparatorsRegistry
= keyof CustomComparatorsToRegister extends never
? { comparators?: Record<string, Comparator<Record<string, unknown>>> }
: { comparators: CustomComparatorsToRegister }

declare module 'vitest/node' {
export interface ToMatchScreenshotOptions
extends Omit<
ScreenshotMatcherOptions,
'comparatorName' | 'comparatorOptions'
> {}
>, CustomComparatorsRegistry {}

export interface ToMatchScreenshotComparators
extends ScreenshotComparatorRegistry {}
Expand Down
9 changes: 5 additions & 4 deletions packages/browser/src/node/commands/screenshotMatcher/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { basename, dirname, extname, join, relative, resolve } from 'pathe'
import { getCodec } from './codecs'
import { getComparator } from './comparators'

type GlobalOptions = Required<
type GlobalOptions = Required<Omit<
NonNullable<
NonNullable<BrowserConfigOptions['expect']>['toMatchScreenshot']
& NonNullable<Pick<ScreenshotMatcherArguments[2], 'screenshotOptions'>>
>
>
>,
'comparators'
>>

const defaultOptions = {
comparatorName: 'pixelmatch',
Expand Down Expand Up @@ -140,7 +141,7 @@ export function resolveOptions(

return {
codec: getCodec(extension),
comparator: getComparator(resolvedOptions.comparatorName),
comparator: getComparator(resolvedOptions.comparatorName, context),
resolvedOptions,
paths: {
reference: resolvedOptions.resolveScreenshotPath(resolvePathData),
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import BrowserPlugin from './plugin'
import { ParentBrowserProject } from './projectParent'
import { setupBrowserRpc } from './rpc'

export type { CustomComparatorsRegistry } from './commands/screenshotMatcher/types'

export function defineBrowserCommand<T extends unknown[]>(
fn: BrowserCommand<T>,
): BrowserCommand<T> {
Expand Down
51 changes: 51 additions & 0 deletions test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const renderTestCase = (colors: readonly [string, string, string]) =>
</div>
`)

declare module 'vitest/browser' {
interface ScreenshotComparatorRegistry {
failing: Record<string, never>
}
}

/**
* ## Screenshot Testing Strategy
*
Expand Down Expand Up @@ -370,4 +376,49 @@ describe('.toMatchScreenshot', () => {
)
},
)

test('can use custom comparators', async ({ onTestFinished }) => {
const filename = globalThis.crypto.randomUUID()
const path = join(
'__screenshots__',
'toMatchScreenshot.test.ts',
`${filename}-${server.browser}-${server.platform}.png`,
)

onTestFinished(async () => {
await server.commands.removeFile(path)
})

renderTestCase([
'oklch(39.6% 0.141 25.723)',
'oklch(40.5% 0.101 131.063)',
'oklch(37.9% 0.146 265.522)',
])

const locator = page.getByTestId(dataTestId)

// Create a reference screenshot by explicitly saving one
await locator.screenshot({
save: true,
path,
})

// Test that `toMatchScreenshot()` correctly uses a custom comparator; since
// the element hasn't changed, it should match, but this custom comparator
// will always fail
await expect(locator).toMatchScreenshot(filename)

let errorMessage: string

try {
await expect(locator).toMatchScreenshot(filename, {
comparatorName: 'failing',
timeout: 100,
})
} catch (error) {
errorMessage = error.message
}

expect(errorMessage).matches(/^Could not capture a stable screenshot within 100ms\.$/m)
})
})
9 changes: 8 additions & 1 deletion test/browser/fixtures/expect-dom/vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ export default defineConfig({
provider,
instances,
isolate: false,
expect: {
toMatchScreenshot: {
comparators: {
failing: () => ({ pass: false, diff: null, message: null }),
},
},
},
},
setupFiles: './setup.ts',
},
})
})
Loading