[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.

perf(browser): reduce matching screenshot overhead (#10278)

authored by

Kasper Peulen and committed by
GitHub
(May 8, 2026, 8:07 AM +0100) 511c0926 7a687e35

+239 -50
+1
packages/browser/jest-dom.d.ts
··· 692 692 * 693 693 * // basic usage, auto-generates screenshot name 694 694 * await expect.element(getByTestId('button')).toMatchScreenshot() 695 + * await expect(page).toMatchScreenshot() 695 696 * 696 697 * // with custom name 697 698 * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button')
+16 -7
packages/browser-playwright/src/commands/screenshot.ts
··· 9 9 interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> { 10 10 element?: SerializedLocator 11 11 mask?: readonly SerializedLocator[] 12 + target?: 'element' | 'page' 12 13 } 13 14 14 15 const SCREENSHOT_STYLES = /* css */` ··· 61 62 : options.style 62 63 63 64 if (options.element) { 64 - const { element: selector, ...config } = options 65 + const { element: selector, target: _target, ...config } = options 65 66 const element = getDescribedLocator(context, selector) 66 67 const buffer = await element.screenshot({ 67 68 ...config, ··· 72 73 return { buffer, path } 73 74 } 74 75 75 - const buffer = await getDescribedLocator(context, { selector: 'body', locator: 'locator(\'body\')' }).screenshot({ 76 - ...options, 77 - mask, 78 - path: savePath, 79 - style, 80 - }) 76 + const { target, ...config } = options 77 + const buffer = target === 'page' 78 + ? await context.page.screenshot({ 79 + ...config, 80 + mask, 81 + path: savePath, 82 + style, 83 + }) 84 + : await getDescribedLocator(context, { selector: 'body', locator: 'locator(\'body\')' }).screenshot({ 85 + ...config, 86 + mask, 87 + path: savePath, 88 + style, 89 + }) 81 90 return { buffer, path } 82 91 }
+9 -8
packages/browser-webdriverio/src/commands/screenshot.ts
··· 10 10 interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> { 11 11 element?: SerializedLocator 12 12 mask?: readonly SerializedLocator[] 13 + target?: 'element' | 'page' 13 14 } 14 15 15 16 /** ··· 52 53 await mkdir(context.project.tmpDir, { recursive: true }) 53 54 } 54 55 55 - const page = context.browser 56 - const element = !options.element 57 - ? await page.$('body') 58 - : await page.$(`${options.element.selector}`) 59 - 60 56 // webdriverio expects the path to contain the extension and only works with PNG files 61 57 const savePathWithExtension = savePath.endsWith('.png') ? savePath : `${savePath}.png` 62 58 63 59 // there seems to be a bug in webdriverio, `X:/` gets appended to cwd, so we convert to `X:\` 64 - const buffer = await element.saveScreenshot( 65 - platformNormalize(savePathWithExtension), 66 - ) 60 + const normalizedSavePath = platformNormalize(savePathWithExtension) 61 + // `browser.saveScreenshot` captures the top-level page for `expect(page)`; 62 + // element and plain `page.screenshot()` calls keep the existing body fallback. 63 + const buffer = options.target === 'page' 64 + ? await context.browser.saveScreenshot(normalizedSavePath) 65 + : await context.browser.$(options.element?.selector ?? 'body').saveScreenshot( 66 + normalizedSavePath, 67 + ) 67 68 68 69 if (!options.save) { 69 70 await rm(savePathWithExtension, { force: true })
+64 -10
test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts
··· 64 64 await expect(locator).toMatchScreenshot(filename) 65 65 }) 66 66 67 + test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')( 68 + 'supports page screenshots', 69 + async ({ onTestFinished }) => { 70 + const filename = globalThis.crypto.randomUUID() 71 + 72 + renderTestCase([ 73 + 'oklch(39.6% 0.141 25.723)', 74 + 'oklch(40.5% 0.101 131.063)', 75 + 'oklch(37.9% 0.146 265.522)', 76 + ]) 77 + 78 + let errorMessage: string 79 + 80 + try { 81 + await expect(page).toMatchScreenshot(filename) 82 + } 83 + catch (error) { 84 + errorMessage = error.message 85 + } 86 + 87 + const [referencePath] = extractToMatchScreenshotPaths(errorMessage, filename) 88 + 89 + expect(typeof referencePath).toBe('string') 90 + 91 + onTestFinished(async () => { 92 + await server.commands.removeFile(referencePath) 93 + }) 94 + 95 + expect(errorMessage).toMatchInlineSnapshot(` 96 + expect(page).toMatchScreenshot() 97 + 98 + No existing reference screenshot found${ 99 + server.config.snapshotOptions.updateSnapshot === 'none' 100 + ? '.' 101 + : '; a new one was created. Review it before running tests again.' 102 + } 103 + 104 + Reference screenshot: 105 + ${referencePath} 106 + `) 107 + }, 108 + ) 109 + 67 110 // Only run this test if snapshots aren't being updated 68 111 test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')( 69 112 "throws when screenshots don't match", ··· 377 420 }, 378 421 ) 379 422 380 - test('can use custom comparators', async ({ onTestFinished }) => { 423 + test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')('can use custom comparators', async ({ onTestFinished }) => { 381 424 const filename = globalThis.crypto.randomUUID() 382 425 const path = join( 383 426 '__screenshots__', ··· 397 440 398 441 const locator = page.getByTestId(dataTestId) 399 442 400 - // Create a reference screenshot by explicitly saving one 401 - await locator.screenshot({ 402 - save: true, 403 - path, 404 - }) 443 + // Test that `toMatchScreenshot()` correctly uses a custom comparator even 444 + // when the PNG bytes match. The byte fast path must not bypass custom 445 + // comparator semantics. 446 + let firstErrorMessage: string 447 + try { 448 + await expect(locator).toMatchScreenshot(filename) 449 + } catch (error) { 450 + firstErrorMessage = error.message 451 + } 405 452 406 - // Test that `toMatchScreenshot()` correctly uses a custom comparator; since 407 - // the element hasn't changed, it should match, but this custom comparator 408 - // will always fail 409 - await expect(locator).toMatchScreenshot(filename) 453 + const [createdReferencePath] = extractToMatchScreenshotPaths(firstErrorMessage, filename) 454 + if (!createdReferencePath.endsWith(path)) { 455 + await server.commands.writeFile( 456 + path, 457 + await server.commands.readFile(createdReferencePath, { encoding: 'base64' }), 458 + { encoding: 'base64' }, 459 + ) 460 + onTestFinished(async () => { 461 + await server.commands.removeFile(createdReferencePath) 462 + }) 463 + } 410 464 411 465 let errorMessage: string 412 466
+1
packages/browser/src/node/commands/screenshot.ts
··· 4 4 interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> { 5 5 element?: SerializedLocator 6 6 mask?: readonly SerializedLocator[] 7 + target?: 'element' | 'page' 7 8 } 8 9 9 10 declare module 'vitest/browser' {
+2 -1
packages/browser/src/shared/screenshotMatcher/types.ts
··· 7 7 testName: string, 8 8 options: ScreenshotMatcherOptions<ComparatorName> 9 9 & { 10 - element: SerializedLocator 10 + element?: SerializedLocator 11 + target?: 'element' | 'page' 11 12 screenshotOptions?: ScreenshotMatcherOptions<ComparatorName>['screenshotOptions'] & { mask?: readonly SerializedLocator[] } 12 13 }, 13 14 ]
+12 -4
packages/browser/src/client/tester/expect/toMatchScreenshot.ts
··· 1 1 import type { VisualRegressionArtifact } from '@vitest/runner' 2 2 import type { AsyncMatcherResult, MatcherState } from 'vitest' 3 - import type { ScreenshotMatcherOptions } from '../../../../context' 3 + import type { BrowserPage, ScreenshotMatcherOptions } from '../../../../context' 4 4 import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' 5 5 import type { Locator } from '../locators' 6 6 import { recordArtifact } from 'vitest' ··· 11 11 12 12 export default async function toMatchScreenshot( 13 13 this: MatcherState, 14 - actual: Element | Locator, 14 + actual: BrowserPage | Element | Locator, 15 15 nameOrOptions?: ScreenshotMatcherOptions | string, 16 16 options: ScreenshotMatcherOptions = typeof nameOrOptions === 'object' 17 17 ? nameOrOptions ··· 40 40 ? nameOrOptions 41 41 : `${this.currentTestName} ${counter.current}` 42 42 43 + const isPageTarget = isBrowserPage(actual) 44 + 43 45 const [element, ...mask] = await Promise.all([ 44 - serializeElement(actual, options), 46 + isPageTarget ? undefined : serializeElement(actual, options), 45 47 ...options.screenshotOptions && 'mask' in options.screenshotOptions 46 48 ? (options.screenshotOptions.mask as Array<Element | Locator>) 47 49 .map(m => serializeElement(m, options)) ··· 68 70 this.currentTestName, 69 71 { 70 72 element, 73 + target: isPageTarget ? 'page' : 'element', 71 74 ...normalizedOptions, 72 75 }, 73 76 ] satisfies ScreenshotMatcherArguments, ··· 104 107 result.pass 105 108 ? '' 106 109 : [ 107 - this.utils.matcherHint('toMatchScreenshot', 'element', ''), 110 + this.utils.matcherHint('toMatchScreenshot', isPageTarget ? 'page' : 'element', ''), 108 111 '', 109 112 result.message, 110 113 result.reference ··· 124 127 outcome: result.outcome, 125 128 }, 126 129 } 130 + } 131 + 132 + function isBrowserPage(value: unknown): value is BrowserPage { 133 + return !!value && typeof value === 'object' && 'viewport' in value 134 + && typeof value.viewport === 'function' 127 135 }
+111 -13
packages/browser/src/node/commands/screenshotMatcher/index.ts
··· 9 9 import type { ResolvedOptions } from './utils' 10 10 import { mkdir, readFile, writeFile } from 'node:fs/promises' 11 11 import { basename, dirname } from 'pathe' 12 - import { asyncTimeout, resolveOptions, takeDecodedScreenshot } from './utils' 12 + import { asyncTimeout, resolveOptions, takeDecodedScreenshot, takeScreenshotBuffer } from './utils' 13 13 14 14 /** Decoded image data with dimensions metadata. */ 15 15 type DecodedImage = Awaited<ReturnType<AnyCodec['decode']>> ··· 18 18 interface ScreenshotData { 19 19 image: DecodedImage 20 20 path: string 21 + buffer?: Buffer<ArrayBufferLike> 22 + } 23 + 24 + interface CapturedScreenshot { 25 + image: DecodedImage 26 + buffer: Buffer<ArrayBufferLike> 21 27 } 22 28 23 29 /** ··· 77 83 throw new Error('Cannot compare screenshots without a test path') 78 84 } 79 85 80 - const { element } = options 86 + const { element, target } = options 81 87 const { 82 88 codec, 83 89 comparator, 84 90 paths, 85 - resolvedOptions: { comparatorOptions, screenshotOptions, timeout }, 91 + resolvedOptions: { comparatorName, comparatorOptions, screenshotOptions, timeout }, 86 92 } = resolveOptions({ context, name, testName, options }) 87 93 94 + const screenshotName = `${Date.now()}-${basename(paths.reference)}` 95 + const screenshotCaptureOptions = { 96 + context, 97 + element, 98 + name: screenshotName, 99 + screenshotOptions, 100 + target, 101 + } satisfies Parameters<typeof takeScreenshotBuffer>[0] 102 + 88 103 const referenceFile = await readFile(paths.reference).catch(() => null) 89 - const reference = referenceFile && await codec.decode(referenceFile, {}) 104 + let reference: DecodedImage | null = null 105 + let initialScreenshot: CapturedScreenshot | null = null 106 + 107 + if (referenceFile) { 108 + // Reuse this capture in the stability loop so the byte fast path doesn't add another screenshot. 109 + const initialScreenshotBuffer = await takeScreenshotBuffer(screenshotCaptureOptions) 110 + 111 + // Keep custom comparator semantics intact: only the built-in pixelmatch 112 + // comparator is known to pass byte-identical PNGs without side effects. 113 + if (comparatorName === 'pixelmatch' && Buffer.compare(referenceFile, initialScreenshotBuffer) === 0) { 114 + return buildOutput({ type: 'matched-immediately' }, timeout) 115 + } 116 + 117 + [reference, initialScreenshot] = await Promise.all([ 118 + codec.decode(referenceFile, {}), 119 + takeScreenshotData({ 120 + ...screenshotCaptureOptions, 121 + buffer: initialScreenshotBuffer, 122 + codec, 123 + }), 124 + ]) 125 + } 90 126 91 127 const screenshotResult = await waitForStableScreenshot({ 92 128 codec, ··· 94 130 comparatorOptions, 95 131 context, 96 132 element, 97 - name: `${Date.now()}-${basename(paths.reference)}`, 133 + initialScreenshot, 134 + name: screenshotName, 98 135 reference, 99 136 screenshotOptions, 137 + target, 100 138 }, timeout) 101 139 102 140 const outcome = await determineOutcome({ 103 141 reference, 104 142 screenshot: screenshotResult && screenshotResult.actual, 143 + screenshotBuffer: screenshotResult?.buffer, 105 144 retries: screenshotResult?.retries ?? 0, 106 145 updateSnapshot: context.project.serializedConfig.snapshotOptions.updateSnapshot, 107 146 paths, ··· 129 168 reference, 130 169 retries, 131 170 screenshot, 171 + screenshotBuffer, 132 172 updateSnapshot, 133 173 }: Pick<ResolvedOptions, 'comparator' | 'paths'> & { 134 174 comparatorOptions: ResolvedOptions['resolvedOptions']['comparatorOptions'] 135 175 reference: DecodedImage | null 136 176 retries: number 137 177 screenshot: DecodedImage | null 178 + screenshotBuffer?: Buffer<ArrayBufferLike> 138 179 updateSnapshot: SnapshotUpdateState 139 180 }, 140 181 ): Promise<MatchOutcome> { ··· 156 197 reference: { 157 198 image: screenshot, 158 199 path: paths.reference, 200 + buffer: screenshotBuffer, 159 201 }, 160 202 } 161 203 } ··· 172 214 path: location === 'reference' 173 215 ? paths.reference 174 216 : paths.diffs.reference, 217 + buffer: screenshotBuffer, 175 218 }, 176 219 } 177 220 } ··· 197 240 reference: { 198 241 image: screenshot, 199 242 path: paths.reference, 243 + buffer: screenshotBuffer, 200 244 }, 201 245 } 202 246 } ··· 210 254 actual: { 211 255 image: screenshot, 212 256 path: paths.diffs.actual, 257 + buffer: screenshotBuffer, 213 258 }, 214 259 diff: comparisonResult.diff && { 215 260 image: { ··· 237 282 case 'update-reference': { 238 283 await writeScreenshot( 239 284 outcome.reference.path, 240 - await codec.encode(outcome.reference.image, {}), 285 + await encodeScreenshot(outcome.reference, codec), 241 286 ) 242 287 243 288 break ··· 246 291 case 'mismatch': { 247 292 await writeScreenshot( 248 293 outcome.actual.path, 249 - await codec.encode(outcome.actual.image, {}), 294 + await encodeScreenshot(outcome.actual, codec), 250 295 ) 251 296 252 297 if (outcome.diff) { ··· 259 304 break 260 305 } 261 306 } 307 + } 308 + 309 + function encodeScreenshot(screenshot: ScreenshotData, codec: AnyCodec) { 310 + return screenshot.buffer ?? codec.encode(screenshot.image, {}) 262 311 } 263 312 264 313 /** ··· 353 402 comparator: AnyComparator 354 403 comparatorOptions: ScreenshotMatcherOptions['comparatorOptions'] 355 404 context: BrowserCommandContext 356 - element: SerializedLocator 405 + element?: SerializedLocator 406 + initialScreenshot: CapturedScreenshot | null 357 407 name: string 358 408 reference: ReturnType<AnyCodec['decode']> | null 359 409 screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] 410 + target?: ScreenshotMatcherArguments[2]['target'] 360 411 } 361 412 362 413 /** ··· 365 416 * Wraps {@linkcode getStableScreenshot} with an abort controller that triggers when the timeout expires. Returns `null` if the page never stabilizes. 366 417 */ 367 418 async function waitForStableScreenshot(options: StableScreenshotOptions, timeout: number, 368 - ): Promise<{ actual: DecodedImage; retries: number } | null> { 419 + ): Promise<{ actual: DecodedImage; buffer: Buffer<ArrayBufferLike>; retries: number } | null> { 369 420 const abortController = new AbortController() 370 421 371 422 const stableScreenshot = getStableScreenshot( ··· 406 457 comparator, 407 458 comparatorOptions, 408 459 element, 460 + initialScreenshot, 409 461 name, 410 462 reference, 411 463 screenshotOptions, 464 + target, 412 465 }: StableScreenshotOptions, signal: AbortSignal): Promise<{ 413 466 retries: number 414 467 actual: DecodedImage 468 + buffer: Buffer<ArrayBufferLike> 415 469 }> { 416 470 const screenshotArgument = { 417 471 codec, ··· 419 473 element, 420 474 name, 421 475 screenshotOptions, 476 + target, 422 477 } satisfies Parameters<typeof takeDecodedScreenshot>[0] 423 478 424 479 let retries = 0 425 480 426 481 let decodedBaseline = reference 482 + let nextScreenshot = initialScreenshot 483 + let lastCapturedScreenshot: CapturedScreenshot | null = null 427 484 428 485 while (signal.aborted === false) { 429 486 if (decodedBaseline === null) { 430 487 decodedBaseline = takeDecodedScreenshot(screenshotArgument) 431 488 } 432 489 433 - const [image1, image2] = await Promise.all([ 490 + const [image1, capturedScreenshot] = await Promise.all([ 434 491 decodedBaseline, 435 - takeDecodedScreenshot(screenshotArgument), 492 + nextScreenshot ?? takeScreenshotData(screenshotArgument), 436 493 ]) 494 + const { image: image2 } = capturedScreenshot 495 + lastCapturedScreenshot = capturedScreenshot 437 496 438 497 const isStable = (await comparator( 439 498 image1, ··· 442 501 )).pass 443 502 444 503 decodedBaseline = image2 504 + nextScreenshot = null 445 505 446 506 if (isStable) { 447 - break 507 + return { 508 + retries, 509 + actual: image2, 510 + buffer: capturedScreenshot.buffer, 511 + } 448 512 } 449 513 450 514 retries += 1 451 515 } 452 516 517 + lastCapturedScreenshot ??= await takeScreenshotData(screenshotArgument) 518 + 453 519 return { 454 520 retries, 455 - actual: await decodedBaseline!, 521 + actual: lastCapturedScreenshot.image, 522 + buffer: lastCapturedScreenshot.buffer, 523 + } 524 + } 525 + 526 + async function takeScreenshotData({ 527 + buffer, 528 + codec, 529 + context, 530 + element, 531 + name, 532 + screenshotOptions, 533 + target, 534 + }: { 535 + buffer?: Buffer<ArrayBufferLike> 536 + codec: AnyCodec 537 + context: BrowserCommandContext 538 + element?: SerializedLocator 539 + name: string 540 + screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] 541 + target?: ScreenshotMatcherArguments[2]['target'] 542 + }): Promise<CapturedScreenshot> { 543 + const screenshot = buffer ?? await takeScreenshotBuffer({ 544 + context, 545 + element, 546 + name, 547 + screenshotOptions, 548 + target, 549 + }) 550 + 551 + return { 552 + buffer: screenshot, 553 + image: await codec.decode(screenshot, {}), 456 554 } 457 555 } 458 556
+23 -7
packages/browser/src/node/commands/screenshotMatcher/utils.ts
··· 233 233 * 234 234 * @returns `Promise` resolving to the decoded screenshot data 235 235 */ 236 - export function takeDecodedScreenshot({ 237 - codec, 236 + export function takeScreenshotBuffer({ 238 237 context, 239 238 element, 240 239 name, 241 240 screenshotOptions, 241 + target, 242 242 }: { 243 - codec: AnyCodec 244 243 context: BrowserCommandContext 245 - element: SerializedLocator 244 + element?: SerializedLocator 246 245 name: string 247 246 screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] 248 - }): ReturnType<AnyCodec['decode']> { 247 + target?: ScreenshotMatcherArguments[2]['target'] 248 + }): Promise<Buffer<ArrayBufferLike>> { 249 249 return context.triggerCommand( 250 250 '__vitest_takeScreenshot', 251 251 name, 252 - { ...screenshotOptions, save: false, element }, 252 + { ...screenshotOptions, save: false, element, target }, 253 253 ).then( 254 - ({ buffer }) => codec.decode(buffer, {}), 254 + ({ buffer }) => buffer, 255 + ) 256 + } 257 + 258 + export function takeDecodedScreenshot({ 259 + codec, 260 + ...options 261 + }: { 262 + codec: AnyCodec 263 + context: BrowserCommandContext 264 + element?: SerializedLocator 265 + name: string 266 + screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] 267 + target?: ScreenshotMatcherArguments[2]['target'] 268 + }): ReturnType<AnyCodec['decode']> { 269 + return takeScreenshotBuffer(options).then( 270 + buffer => codec.decode(buffer, {}), 255 271 ) 256 272 } 257 273