Skip to content

Commit 459efba

Browse files
authored
fix: screenshot masks with Playwright provider (#8357)
1 parent bd2245e commit 459efba

File tree

9 files changed

+118
-32
lines changed

9 files changed

+118
-32
lines changed

packages/browser/providers/playwright.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Protocol } from 'playwright-core/types/protocol'
1212
import '../matchers.js'
1313
import type {} from "vitest/node"
1414
import type {
15+
Locator,
1516
ScreenshotComparatorRegistry,
1617
ScreenshotMatcherOptions,
1718
} from "@vitest/browser/context"
@@ -71,7 +72,9 @@ declare module '@vitest/browser/context' {
7172
export interface UserEventDragAndDropOptions extends PWDragAndDropOptions {}
7273
export interface UserEventUploadOptions extends PWSetInputFiles {}
7374

74-
export interface ScreenshotOptions extends PWScreenshotOptions {}
75+
export interface ScreenshotOptions extends Omit<PWScreenshotOptions, 'mask'> {
76+
mask?: ReadonlyArray<Element | Locator> | undefined
77+
}
7578

7679
export interface CDPSession {
7780
send<T extends keyof Protocol.CommandParameters>(

packages/browser/src/client/tester/context.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { BrowserRunnerState } from '../utils'
1414
import type { Locator as LocatorAPI } from './locators/index'
1515
import { getElementLocatorSelectors } from '@vitest/browser/utils'
1616
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
17-
import { convertElementToCssSelector, processTimeoutOptions } from './utils'
17+
import { convertToSelector, processTimeoutOptions } from './utils'
1818

1919
// this file should not import anything directly, only types and utils
2020

@@ -292,12 +292,19 @@ export const page: BrowserPage = {
292292
const name
293293
= options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png`
294294

295+
const normalizedOptions = 'mask' in options
296+
? {
297+
...options,
298+
mask: (options.mask as Array<Element | Locator>).map(convertToSelector),
299+
}
300+
: options
301+
295302
return ensureAwaited(error => triggerCommand(
296303
'__vitest_screenshot',
297304
[
298305
name,
299306
processTimeoutOptions({
300-
...options,
307+
...normalizedOptions,
301308
element: options.element
302309
? convertToSelector(options.element)
303310
: undefined,
@@ -348,19 +355,6 @@ function convertToLocator(element: Element | Locator): Locator {
348355
return element
349356
}
350357

351-
function convertToSelector(elementOrLocator: Element | Locator): string {
352-
if (!elementOrLocator) {
353-
throw new Error('Expected element or locator to be defined.')
354-
}
355-
if (elementOrLocator instanceof Element) {
356-
return convertElementToCssSelector(elementOrLocator)
357-
}
358-
if ('selector' in elementOrLocator) {
359-
return (elementOrLocator as any).selector
360-
}
361-
throw new Error('Expected element or locator to be an instance of Element or Locator.')
362-
}
363-
364358
function getTaskFullName(task: RunnerTask): string {
365359
return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name
366360
}

packages/browser/src/client/tester/expect/toMatchScreenshot.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import type { ScreenshotMatcherOptions } from '../../../../context'
33
import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types'
44
import type { Locator } from '../locators'
55
import { getBrowserState, getWorkerState } from '../../utils'
6-
import { convertElementToCssSelector } from '../utils'
7-
import { getElementFromUserInput } from './utils'
6+
import { convertToSelector } from '../utils'
87

98
const counters = new Map<string, { current: number }>([])
109

@@ -41,17 +40,28 @@ export default async function toMatchScreenshot(
4140
? nameOrOptions
4241
: `${this.currentTestName} ${counter.current}`
4342

44-
const result = await
45-
getBrowserState().commands.triggerCommand<ScreenshotMatcherOutput>(
43+
const normalizedOptions: Omit<ScreenshotMatcherArguments[2], 'element'> = (
44+
options.screenshotOptions && 'mask' in options.screenshotOptions
45+
? {
46+
...options,
47+
screenshotOptions: {
48+
...options.screenshotOptions,
49+
mask: (options.screenshotOptions.mask as Array<Element | Locator>)
50+
.map(convertToSelector),
51+
},
52+
}
53+
// TS believes `mask` to still be defined as `ReadonlyArray<Element | Locator>`
54+
: options as any
55+
)
56+
57+
const result = await getBrowserState().commands.triggerCommand<ScreenshotMatcherOutput>(
4658
'__vitest_screenshotMatcher',
4759
[
4860
name,
4961
this.currentTestName,
5062
{
51-
element: convertElementToCssSelector(
52-
getElementFromUserInput(actual, toMatchScreenshot, this),
53-
),
54-
...options,
63+
element: convertToSelector(actual),
64+
...normalizedOptions,
5565
},
5666
] satisfies ScreenshotMatcherArguments,
5767
)

packages/browser/src/client/tester/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Locator } from '@vitest/browser/context'
12
import type { BrowserRPC } from '../client'
23
import { getBrowserState, getWorkerState } from '../utils'
34

@@ -197,3 +198,16 @@ export function escapeForTextSelector(text: string | RegExp, exact: boolean): st
197198
}
198199
return `${JSON.stringify(text)}${exact ? 's' : 'i'}`
199200
}
201+
202+
export function convertToSelector(elementOrLocator: Element | Locator): string {
203+
if (!elementOrLocator) {
204+
throw new Error('Expected element or locator to be defined.')
205+
}
206+
if (elementOrLocator instanceof Element) {
207+
return convertElementToCssSelector(elementOrLocator)
208+
}
209+
if ('selector' in elementOrLocator) {
210+
return (elementOrLocator as any).selector
211+
}
212+
throw new Error('Expected element or locator to be an instance of Element or Locator.')
213+
}

packages/browser/src/node/commands/screenshot.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { basename, dirname, relative, resolve } from 'pathe'
66
import { PlaywrightBrowserProvider } from '../providers/playwright'
77
import { WebdriverBrowserProvider } from '../providers/webdriver'
88

9-
interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element'> {
9+
interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> {
1010
element?: string
11+
mask?: readonly string[]
1112
}
1213

1314
export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = async (
@@ -53,18 +54,22 @@ export async function takeScreenshot(
5354
await mkdir(dirname(path), { recursive: true })
5455

5556
if (context.provider instanceof PlaywrightBrowserProvider) {
57+
const mask = options.mask?.map(selector => context.iframe.locator(selector))
58+
5659
if (options.element) {
5760
const { element: selector, ...config } = options
58-
const element = context.iframe.locator(`${selector}`)
61+
const element = context.iframe.locator(selector)
5962
const buffer = await element.screenshot({
6063
...config,
64+
mask,
6165
path: options.save ? savePath : undefined,
6266
})
6367
return { buffer, path }
6468
}
6569

6670
const buffer = await context.iframe.locator('body').screenshot({
6771
...options,
72+
mask,
6873
path: options.save ? savePath : undefined,
6974
})
7075
return { buffer, path }

packages/browser/src/node/commands/screenshotMatcher/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ async function getStableScreenshots({
199199
element: string
200200
name: string
201201
reference: ReturnType<AnyCodec['decode']> | null
202-
screenshotOptions: ScreenshotMatcherOptions['screenshotOptions']
202+
screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions']
203203
signal: AbortSignal
204204
}) {
205205
const screenshotArgument = {

packages/browser/src/node/commands/screenshotMatcher/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BrowserCommandContext, BrowserConfigOptions } from 'vitest/node'
22
import type { ScreenshotMatcherOptions } from '../../../../context'
3+
import type { ScreenshotMatcherArguments } from '../../../shared/screenshotMatcher/types'
34
import type { AnyCodec } from './codecs'
45
import { platform } from 'node:os'
56
import { deepMerge } from '@vitest/utils'
@@ -11,6 +12,7 @@ import { getComparator } from './comparators'
1112
type GlobalOptions = Required<
1213
NonNullable<
1314
NonNullable<BrowserConfigOptions['expect']>['toMatchScreenshot']
15+
& NonNullable<Pick<ScreenshotMatcherArguments[2], 'screenshotOptions'>>
1416
>
1517
>
1618

@@ -234,7 +236,7 @@ export function takeDecodedScreenshot({
234236
context: BrowserCommandContext
235237
element: string
236238
name: string
237-
screenshotOptions: ScreenshotMatcherOptions['screenshotOptions']
239+
screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions']
238240
}): ReturnType<AnyCodec['decode']> {
239241
return takeScreenshot(
240242
context,

packages/browser/src/shared/screenshotMatcher/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ export type ScreenshotMatcherArguments<
55
> = [
66
name: string,
77
testName: string,
8-
options: ScreenshotMatcherOptions<ComparatorName> & { element: string },
8+
options: ScreenshotMatcherOptions<ComparatorName>
9+
& {
10+
element: string
11+
screenshotOptions?: ScreenshotMatcherOptions<ComparatorName>['screenshotOptions'] & { mask?: readonly string[] }
12+
},
913
]
1014

1115
export type ScreenshotMatcherOutput = Promise<

test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ const dataTestId = 'colors-box'
1010
const renderTestCase = (colors: readonly [string, string, string]) =>
1111
render(`
1212
<div style="--size: ${blockSize}px; display: flex; justify-content: center; height: var(--size); width: calc(var(--size) * ${blocks});" data-testid="${dataTestId}">
13-
<div style="background-color: ${colors[0]}; width: var(--size);"></div>
14-
<div style="background-color: ${colors[1]}; width: var(--size);"></div>
15-
<div style="background-color: ${colors[2]}; width: var(--size);"></div>
13+
<div data-testid="${dataTestId}-1" style="background-color: ${colors[0]}; width: var(--size);"></div>
14+
<div data-testid="${dataTestId}-2" style="background-color: ${colors[1]}; width: var(--size);"></div>
15+
<div data-testid="${dataTestId}-3" style="background-color: ${colors[2]}; width: var(--size);"></div>
1616
</div>
1717
`)
1818

@@ -316,4 +316,58 @@ describe('.toMatchScreenshot', () => {
316316
await expect(locator).toMatchScreenshot()
317317
},
318318
)
319+
320+
// `mask` is a Playwright-only screenshot feature
321+
test.runIf(server.provider === 'playwright')(
322+
"works with masks",
323+
async ({ onTestFinished }) => {
324+
const filename = globalThis.crypto.randomUUID()
325+
const path = join(
326+
'__screenshots__',
327+
'toMatchScreenshot.test.ts',
328+
`${filename}-${server.browser}-${server.platform}.png`,
329+
)
330+
331+
onTestFinished(async () => {
332+
await server.commands.removeFile(path)
333+
})
334+
335+
renderTestCase([
336+
'oklch(39.6% 0.141 25.723)',
337+
'oklch(40.5% 0.101 131.063)',
338+
'oklch(37.9% 0.146 265.522)',
339+
])
340+
341+
const locator = page.getByTestId(dataTestId)
342+
343+
const maskColor = 'oklch(84.1% 0.238 128.85)'
344+
const mask = [page.getByTestId(`${dataTestId}-3`)]
345+
346+
// Create reference with the third box masked
347+
await locator.screenshot({
348+
save: true,
349+
path,
350+
maskColor,
351+
mask,
352+
})
353+
354+
// Change the last box's color so we're sure `mask` works
355+
// The test would otherwise fail as the screenshots wouldn't match
356+
renderTestCase([
357+
'oklch(39.6% 0.141 25.723)',
358+
'oklch(40.5% 0.101 131.063)',
359+
'oklch(39.6% 0.141 25.723)',
360+
])
361+
362+
await expect(locator).toMatchScreenshot(
363+
filename,
364+
{
365+
screenshotOptions: {
366+
maskColor,
367+
mask,
368+
},
369+
},
370+
)
371+
},
372+
)
319373
})

0 commit comments

Comments
 (0)