Skip to content
Merged
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
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](https://en.wikipedia.org/wiki/Structural_similarity_index_measure) or other perceptual similarity metrics.

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