[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(browser): take failure screenshot if `toMatchScreenshot` can't capture a stable screenshot (#9847)

authored by

Raul Macarie and committed by
GitHub
(Mar 24, 2026, 9:35 AM +0100) faace1fb 7c06598d

+142 -9
+3
packages/browser/src/client/tester/expect/toMatchScreenshot.ts
··· 120 120 ] 121 121 .filter(element => element !== null) 122 122 .join('\n'), 123 + meta: { 124 + outcome: result.outcome, 125 + }, 123 126 } 124 127 }
+5 -3
packages/browser/src/client/tester/runner.ts
··· 156 156 } 157 157 158 158 onTaskFinished = async (task: Task) => { 159 + const lastErrorContext = task.result?.errors?.at(-1)?.context 159 160 if ( 160 161 this.config.browser.screenshotFailures 161 162 && document.body.clientHeight > 0 162 163 && task.result?.state === 'fail' 163 164 && task.type === 'test' 164 - && task.artifacts.every( 165 - artifact => artifact.type !== 'internal:toMatchScreenshot', 166 - ) 165 + && !( 166 + lastErrorContext 167 + && Reflect.get(lastErrorContext, 'assertionName') === 'toMatchScreenshot' 168 + && Reflect.get(lastErrorContext, 'meta')?.outcome !== 'unstable-screenshot') 167 169 ) { 168 170 const screenshot = await page.screenshot({ 169 171 timeout: this.config.browser.providerOptions?.actionTimeout ?? 5_000,
+5 -1
packages/browser/src/node/commands/screenshotMatcher/index.ts
··· 273 273 case 'unstable-screenshot': 274 274 return { 275 275 pass: false, 276 + outcome: outcome.type, 276 277 reference: outcome.reference && { 277 278 path: outcome.reference.path, 278 279 width: outcome.reference.image.metadata.width, ··· 286 287 case 'missing-reference': { 287 288 return { 288 289 pass: false, 290 + outcome: outcome.type, 289 291 reference: { 290 292 path: outcome.reference.path, 291 293 width: outcome.reference.image.metadata.width, ··· 302 304 case 'update-reference': 303 305 case 'matched-immediately': 304 306 case 'matched-after-comparison': 305 - return { pass: true } 307 + return { pass: true, outcome: outcome.type } 306 308 307 309 case 'mismatch': 308 310 return { 309 311 pass: false, 312 + outcome: outcome.type, 310 313 reference: { 311 314 path: outcome.reference.path, 312 315 width: outcome.reference.image.metadata.width, ··· 333 336 334 337 return { 335 338 pass: false, 339 + outcome: null as never, 336 340 actual: null, 337 341 reference: null, 338 342 diff: null,
+8
packages/browser/src/shared/screenshotMatcher/types.ts
··· 17 17 export type ScreenshotMatcherOutput = Promise< 18 18 { 19 19 pass: false 20 + outcome: 21 + | 'unstable-screenshot' 22 + | 'missing-reference' 23 + | 'mismatch' 20 24 reference: ScreenshotData | null 21 25 actual: ScreenshotData | null 22 26 diff: ScreenshotData | null ··· 24 28 } 25 29 | { 26 30 pass: true 31 + outcome: 32 + | 'update-reference' 33 + | 'matched-immediately' 34 + | 'matched-after-comparison' 27 35 } 28 36 >
+20 -5
packages/expect/src/jest-extend.ts
··· 65 65 } 66 66 67 67 class JestExtendError extends Error { 68 - constructor(message: string, public actual?: any, public expected?: any) { 68 + constructor( 69 + message: string, 70 + public actual?: any, 71 + public expected?: any, 72 + public context?: { assertionName: string; meta?: object }, 73 + ) { 69 74 super(message) 70 75 } 71 76 } ··· 92 97 && typeof (result as any).then === 'function' 93 98 ) { 94 99 const thenable = result as PromiseLike<SyncExpectationResult> 95 - return thenable.then(({ pass, message, actual, expected }) => { 100 + return thenable.then(({ pass, message, actual, expected, meta }) => { 96 101 if ((pass && isNot) || (!pass && !isNot)) { 97 102 const errorMessage = customMessage != null 98 103 ? customMessage 99 104 : message() 100 - throw new JestExtendError(errorMessage, actual, expected) 105 + throw new JestExtendError( 106 + errorMessage, 107 + actual, 108 + expected, 109 + { assertionName: expectAssertionName, meta }, 110 + ) 101 111 } 102 112 }) 103 113 } 104 114 105 - const { pass, message, actual, expected } = result as SyncExpectationResult 115 + const { pass, message, actual, expected, meta } = result as SyncExpectationResult 106 116 107 117 if ((pass && isNot) || (!pass && !isNot)) { 108 118 const errorMessage = customMessage != null 109 119 ? customMessage 110 120 : message() 111 - throw new JestExtendError(errorMessage, actual, expected) 121 + throw new JestExtendError( 122 + errorMessage, 123 + actual, 124 + expected, 125 + { assertionName: expectAssertionName, meta }, 126 + ) 112 127 } 113 128 } 114 129
+1
packages/expect/src/types.ts
··· 91 91 message: () => string 92 92 actual?: any 93 93 expected?: any 94 + meta?: object 94 95 } 95 96 96 97 export type AsyncExpectationResult = Promise<SyncExpectationResult>
+100
test/browser/specs/failure-screenshot.test.ts
··· 1 + import type { TestFsStructure } from '../../test-utils' 2 + import { describe, expect, test } from 'vitest' 3 + import { runInlineTests } from '../../test-utils' 4 + import utilsContent from '../fixtures/expect-dom/utils?raw' 5 + import { instances, provider } from '../settings' 6 + 7 + const testFilename = 'basic.test.ts' 8 + 9 + async function runBrowserTests( 10 + structure: TestFsStructure, 11 + ) { 12 + return runInlineTests({ 13 + ...structure, 14 + 'vitest.config.js': ` 15 + import { ${provider.name} } from '@vitest/browser-${provider.name}' 16 + export default { 17 + test: { 18 + browser: { 19 + enabled: true, 20 + screenshotFailures: true, 21 + provider: ${provider.name}(), 22 + ui: false, 23 + headless: true, 24 + instances: ${JSON.stringify(instances.slice(0, 1) /* logic not bound to browser instance */)}, 25 + }, 26 + reporters: ['verbose'], 27 + update: 'new', 28 + }, 29 + }`, 30 + }) 31 + } 32 + 33 + describe('failure screenshots', () => { 34 + describe('`toMatchScreenshot`', () => { 35 + test('usually does NOT produce a failure screenshot', async () => { 36 + const { stderr } = await runBrowserTests( 37 + { 38 + [testFilename]: /* ts */` 39 + import { page } from 'vitest/browser' 40 + import { test } from 'vitest' 41 + import { render } from './utils' 42 + 43 + test('screenshot-initial', async ({ expect }) => { 44 + render('<div data-testid="el">Test</div>') 45 + await expect(page.getByTestId('el')).toMatchScreenshot() 46 + }) 47 + `, 48 + 'utils.ts': utilsContent, 49 + }, 50 + ) 51 + 52 + expect(stderr).toContain('No existing reference screenshot found; a new one was created.') 53 + expect(stderr).not.toContain('Failure screenshot:') 54 + }) 55 + 56 + test('unstable screenshot fails produces a failure screenshot', async () => { 57 + const { stderr } = await runBrowserTests( 58 + { 59 + [testFilename]: /* ts */` 60 + import { page } from 'vitest/browser' 61 + import { test } from 'vitest' 62 + import { render } from './utils' 63 + 64 + test('screenshot-unstable', async ({ expect }) => { 65 + render('<div data-testid="el">Test</div>') 66 + await expect(page.getByTestId('el')).toMatchScreenshot({ timeout: 1 }) 67 + }) 68 + `, 69 + 'utils.ts': utilsContent, 70 + }, 71 + ) 72 + 73 + expect(stderr).toContain('Could not capture a stable screenshot within 1ms.') 74 + expect(stderr).toContain('Failure screenshot:') 75 + }) 76 + 77 + test('`expect.soft` produces a failure screenshot', async () => { 78 + const { stderr } = await runBrowserTests( 79 + { 80 + [testFilename]: /* ts */` 81 + import { page } from 'vitest/browser' 82 + import { test } from 'vitest' 83 + import { render } from './utils' 84 + 85 + test('screenshot-soft-then-fail', async ({ expect }) => { 86 + render('<div data-testid="el">Test</div>') 87 + await expect.soft(page.getByTestId('el')).toMatchScreenshot() 88 + expect(1).toBe(2) 89 + }) 90 + `, 91 + 'utils.ts': utilsContent, 92 + }, 93 + ) 94 + 95 + expect(stderr).toContain('No existing reference screenshot found; a new one was created.') 96 + expect(stderr).toContain('expected 1 to be 2') 97 + expect(stderr).toContain('Failure screenshot:') 98 + }) 99 + }) 100 + })