[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix: screenshot masks with Playwright provider (#8357)

authored by

Raul Macarie and committed by
GitHub
(Jul 31, 2025, 2:46 PM +0200) 459efba6 bd2245e7

+118 -32
+4 -1
packages/browser/providers/playwright.d.ts
··· 12 12 import '../matchers.js' 13 13 import type {} from "vitest/node" 14 14 import type { 15 + Locator, 15 16 ScreenshotComparatorRegistry, 16 17 ScreenshotMatcherOptions, 17 18 } from "@vitest/browser/context" ··· 71 72 export interface UserEventDragAndDropOptions extends PWDragAndDropOptions {} 72 73 export interface UserEventUploadOptions extends PWSetInputFiles {} 73 74 74 - export interface ScreenshotOptions extends PWScreenshotOptions {} 75 + export interface ScreenshotOptions extends Omit<PWScreenshotOptions, 'mask'> { 76 + mask?: ReadonlyArray<Element | Locator> | undefined 77 + } 75 78 76 79 export interface CDPSession { 77 80 send<T extends keyof Protocol.CommandParameters>(
+57 -3
test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts
··· 10 10 const renderTestCase = (colors: readonly [string, string, string]) => 11 11 render(` 12 12 <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> 16 16 </div> 17 17 `) 18 18 ··· 314 314 315 315 await expect(locator).toMatchScreenshot() 316 316 await expect(locator).toMatchScreenshot() 317 + }, 318 + ) 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 + ) 317 371 }, 318 372 ) 319 373 })
+9 -15
packages/browser/src/client/tester/context.ts
··· 14 14 import type { Locator as LocatorAPI } from './locators/index' 15 15 import { getElementLocatorSelectors } from '@vitest/browser/utils' 16 16 import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' 17 - import { convertElementToCssSelector, processTimeoutOptions } from './utils' 17 + import { convertToSelector, processTimeoutOptions } from './utils' 18 18 19 19 // this file should not import anything directly, only types and utils 20 20 ··· 292 292 const name 293 293 = options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png` 294 294 295 + const normalizedOptions = 'mask' in options 296 + ? { 297 + ...options, 298 + mask: (options.mask as Array<Element | Locator>).map(convertToSelector), 299 + } 300 + : options 301 + 295 302 return ensureAwaited(error => triggerCommand( 296 303 '__vitest_screenshot', 297 304 [ 298 305 name, 299 306 processTimeoutOptions({ 300 - ...options, 307 + ...normalizedOptions, 301 308 element: options.element 302 309 ? convertToSelector(options.element) 303 310 : undefined, ··· 346 353 return page.elementLocator(element) 347 354 } 348 355 return element 349 - } 350 - 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 356 } 363 357 364 358 function getTaskFullName(task: RunnerTask): string {
+14
packages/browser/src/client/tester/utils.ts
··· 1 + import type { Locator } from '@vitest/browser/context' 1 2 import type { BrowserRPC } from '../client' 2 3 import { getBrowserState, getWorkerState } from '../utils' 3 4 ··· 196 197 return escapeRegexForSelector(text) 197 198 } 198 199 return `${JSON.stringify(text)}${exact ? 's' : 'i'}` 200 + } 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.') 199 213 }
+7 -2
packages/browser/src/node/commands/screenshot.ts
··· 6 6 import { PlaywrightBrowserProvider } from '../providers/playwright' 7 7 import { WebdriverBrowserProvider } from '../providers/webdriver' 8 8 9 - interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element'> { 9 + interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> { 10 10 element?: string 11 + mask?: readonly string[] 11 12 } 12 13 13 14 export const screenshot: BrowserCommand<[string, ScreenshotCommandOptions]> = async ( ··· 53 54 await mkdir(dirname(path), { recursive: true }) 54 55 55 56 if (context.provider instanceof PlaywrightBrowserProvider) { 57 + const mask = options.mask?.map(selector => context.iframe.locator(selector)) 58 + 56 59 if (options.element) { 57 60 const { element: selector, ...config } = options 58 - const element = context.iframe.locator(`${selector}`) 61 + const element = context.iframe.locator(selector) 59 62 const buffer = await element.screenshot({ 60 63 ...config, 64 + mask, 61 65 path: options.save ? savePath : undefined, 62 66 }) 63 67 return { buffer, path } ··· 65 69 66 70 const buffer = await context.iframe.locator('body').screenshot({ 67 71 ...options, 72 + mask, 68 73 path: options.save ? savePath : undefined, 69 74 }) 70 75 return { buffer, path }
+5 -1
packages/browser/src/shared/screenshotMatcher/types.ts
··· 5 5 > = [ 6 6 name: string, 7 7 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 + }, 9 13 ] 10 14 11 15 export type ScreenshotMatcherOutput = Promise<
+18 -8
packages/browser/src/client/tester/expect/toMatchScreenshot.ts
··· 3 3 import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' 4 4 import type { Locator } from '../locators' 5 5 import { getBrowserState, getWorkerState } from '../../utils' 6 - import { convertElementToCssSelector } from '../utils' 7 - import { getElementFromUserInput } from './utils' 6 + import { convertToSelector } from '../utils' 8 7 9 8 const counters = new Map<string, { current: number }>([]) 10 9 ··· 41 40 ? nameOrOptions 42 41 : `${this.currentTestName} ${counter.current}` 43 42 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>( 46 58 '__vitest_screenshotMatcher', 47 59 [ 48 60 name, 49 61 this.currentTestName, 50 62 { 51 - element: convertElementToCssSelector( 52 - getElementFromUserInput(actual, toMatchScreenshot, this), 53 - ), 54 - ...options, 63 + element: convertToSelector(actual), 64 + ...normalizedOptions, 55 65 }, 56 66 ] satisfies ScreenshotMatcherArguments, 57 67 )
+1 -1
packages/browser/src/node/commands/screenshotMatcher/index.ts
··· 199 199 element: string 200 200 name: string 201 201 reference: ReturnType<AnyCodec['decode']> | null 202 - screenshotOptions: ScreenshotMatcherOptions['screenshotOptions'] 202 + screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] 203 203 signal: AbortSignal 204 204 }) { 205 205 const screenshotArgument = {
+3 -1
packages/browser/src/node/commands/screenshotMatcher/utils.ts
··· 1 1 import type { BrowserCommandContext, BrowserConfigOptions } from 'vitest/node' 2 2 import type { ScreenshotMatcherOptions } from '../../../../context' 3 + import type { ScreenshotMatcherArguments } from '../../../shared/screenshotMatcher/types' 3 4 import type { AnyCodec } from './codecs' 4 5 import { platform } from 'node:os' 5 6 import { deepMerge } from '@vitest/utils' ··· 11 12 type GlobalOptions = Required< 12 13 NonNullable< 13 14 NonNullable<BrowserConfigOptions['expect']>['toMatchScreenshot'] 15 + & NonNullable<Pick<ScreenshotMatcherArguments[2], 'screenshotOptions'>> 14 16 > 15 17 > 16 18 ··· 234 236 context: BrowserCommandContext 235 237 element: string 236 238 name: string 237 - screenshotOptions: ScreenshotMatcherOptions['screenshotOptions'] 239 + screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] 238 240 }): ReturnType<AnyCodec['decode']> { 239 241 return takeScreenshot( 240 242 context,