[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): run `toMatchScreenshot` only once when used with `expect.element` (#9132)

authored by

Raul Macarie and committed by
GitHub
(Dec 2, 2025, 12:57 PM +0100) 0d2e7e3e d57d8bf0

+192 -38
+18
packages/utils/src/timers.ts
··· 72 72 73 73 (globalThis as any)[SAFE_TIMERS_SYMBOL] = timers 74 74 } 75 + 76 + /** 77 + * Returns a promise that resolves after the specified duration. 78 + * 79 + * @param timeout - Delay in milliseconds 80 + * @param scheduler - Timer function to use, defaults to `setTimeout`. Useful for mocked timers. 81 + * 82 + * @example 83 + * await delay(100) 84 + * 85 + * @example 86 + * // With mocked timers 87 + * const { setTimeout } = getSafeTimers() 88 + * await delay(100, setTimeout) 89 + */ 90 + export function delay(timeout: number, scheduler: typeof setTimeout = setTimeout): Promise<void> { 91 + return new Promise(resolve => scheduler(resolve, timeout)) 92 + }
+110
test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts
··· 421 421 422 422 expect(errorMessage).matches(/^Could not capture a stable screenshot within 100ms\.$/m) 423 423 }) 424 + 425 + // Only run this test if snapshots aren't being updated 426 + test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')( 427 + 'runs only once after resolving the element', 428 + async ({ onTestFinished }) => { 429 + const filename = `toMatchScreenshot-runs-only-once-after-resolving-the-element-1` 430 + const path = join( 431 + '__screenshots__', 432 + 'toMatchScreenshot.test.ts', 433 + ) 434 + 435 + const screenshotPath = join( 436 + path, 437 + `${filename}-${server.browser}-${server.platform}.png` 438 + ) 439 + 440 + // Create baseline screenshot with original colors 441 + renderTestCase([ 442 + 'oklch(39.6% 0.141 25.723)', 443 + 'oklch(40.5% 0.101 131.063)', 444 + 'oklch(37.9% 0.146 265.522)', 445 + ]) 446 + const locator = page.getByTestId(dataTestId) 447 + 448 + await locator.screenshot({ 449 + save: true, 450 + path: screenshotPath, 451 + }) 452 + 453 + onTestFinished(async () => { 454 + await server.commands.removeFile(screenshotPath) 455 + }) 456 + 457 + // Remove element, then re-render with inverted colors after a delay 458 + document.body.innerHTML = '' 459 + 460 + const renderDelay = 500 461 + setTimeout(() => { 462 + renderTestCase([ 463 + 'oklch(37.9% 0.146 265.522)', 464 + 'oklch(40.5% 0.101 131.063)', 465 + 'oklch(39.6% 0.141 25.723)', 466 + ]) 467 + }, renderDelay) 468 + 469 + const start = performance.now() 470 + 471 + // Expected behavior: 472 + // 1. `expect.element()` polls until element exists (~500ms) 473 + // 2. `toMatchScreenshot()` runs ONCE and fails (colors don't match baseline) 474 + // 475 + // If `toMatchScreenshot()` polled internally, it would retry for 30s. 476 + // By checking the elapsed time we verify it only ran once. 477 + 478 + let errorMessage: string 479 + 480 + try { 481 + await expect.element(locator).toMatchScreenshot() 482 + } catch (error) { 483 + errorMessage = error.message 484 + } 485 + 486 + expect(typeof errorMessage).toBe('string') 487 + 488 + const [referencePath, actualPath, diffPath] = extractToMatchScreenshotPaths( 489 + errorMessage, 490 + filename, 491 + ) 492 + 493 + expect(typeof referencePath).toBe('string') 494 + expect(typeof actualPath).toBe('string') 495 + expect(typeof diffPath).toBe('string') 496 + 497 + expect(referencePath).toMatch(new RegExp(`${screenshotPath}$`)) 498 + 499 + onTestFinished(async () => { 500 + await Promise.all([ 501 + server.commands.removeFile(actualPath), 502 + server.commands.removeFile(diffPath), 503 + ]) 504 + }) 505 + 506 + expect( 507 + errorMessage 508 + .replace(/(?:\d+)(.*?)(?:0\.\d{2})/, '<pixels>$1<ratio>') 509 + .replace(referencePath, '<reference>') 510 + .replace(actualPath, '<actual>') 511 + .replace(diffPath, '<diff>') 512 + ).toMatchInlineSnapshot(` 513 + expect(element).toMatchScreenshot() 514 + 515 + Screenshot does not match the stored reference. 516 + <pixels> pixels (ratio <ratio>) differ. 517 + 518 + Reference screenshot: 519 + <reference> 520 + 521 + Actual screenshot: 522 + <actual> 523 + 524 + Diff image: 525 + <diff> 526 + `) 527 + 528 + const elapsed = performance.now() - start 529 + 530 + // Elapsed time should be lower than the default `poll`/`element` timeout 531 + expect(elapsed).toBeLessThan(30_000) 532 + }, 533 + ) 424 534 })
+5
packages/browser/src/client/tester/expect-element.ts
··· 29 29 return elementOrLocator.elements() as unknown as HTMLElement 30 30 } 31 31 32 + if (name === 'toMatchScreenshot' && !chai.util.flag(this, '_poll.assert_once')) { 33 + // `toMatchScreenshot` should only run once after the element resolves 34 + chai.util.flag(this, '_poll.assert_once', true) 35 + } 36 + 32 37 // element selector uses prettyDOM under the hood, which is an expensive call 33 38 // that should not be called on each failed locator attempt to avoid memory leak: 34 39 // https://github.com/vitest-dev/vitest/issues/7139
+59 -38
packages/vitest/src/integrations/chai/poll.ts
··· 1 1 import type { Assertion, ExpectStatic } from '@vitest/expect' 2 2 import type { Test } from '@vitest/runner' 3 3 import { chai } from '@vitest/expect' 4 - import { getSafeTimers } from '@vitest/utils/timers' 4 + import { delay, getSafeTimers } from '@vitest/utils/timers' 5 5 import { getWorkerState } from '../../runtime/utils' 6 6 7 7 // these matchers are not supported because they don't make sense with poll ··· 25 25 // rejects, 26 26 // resolves 27 27 ] 28 + 29 + /** 30 + * Attaches a `cause` property to the error if missing, copies the stack trace from the source, and throws. 31 + * 32 + * @param error - The error to throw 33 + * @param source - Error to copy the stack trace from 34 + * 35 + * @throws Always throws the provided error with an amended stack trace 36 + */ 37 + function throwWithCause(error: any, source: Error) { 38 + if (error.cause == null) { 39 + error.cause = new Error('Matcher did not succeed in time.') 40 + } 41 + 42 + throw copyStackTrace( 43 + error, 44 + source, 45 + ) 46 + } 28 47 29 48 export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] { 30 49 return function poll(fn, options = {}) { ··· 64 83 65 84 return function (this: any, ...args: any[]) { 66 85 const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR') 67 - const promise = () => new Promise<void>((resolve, reject) => { 68 - let intervalId: any 69 - let timeoutId: any 70 - let lastError: any 86 + const promise = async () => { 71 87 const { setTimeout, clearTimeout } = getSafeTimers() 72 - const check = async () => { 73 - try { 74 - chai.util.flag(assertion, '_name', key) 75 - const obj = await fn() 76 - chai.util.flag(assertion, 'object', obj) 77 - resolve(await assertionFunction.call(assertion, ...args)) 78 - clearTimeout(intervalId) 79 - clearTimeout(timeoutId) 80 - } 81 - catch (err) { 82 - lastError = err 83 - if (!chai.util.flag(assertion, '_isLastPollAttempt')) { 84 - intervalId = setTimeout(check, interval) 88 + 89 + let executionPhase: 'fn' | 'assertion' = 'fn' 90 + let hasTimedOut = false 91 + 92 + const timerId = setTimeout(() => { 93 + hasTimedOut = true 94 + }, timeout) 95 + 96 + chai.util.flag(assertion, '_name', key) 97 + 98 + try { 99 + while (true) { 100 + const isLastAttempt = hasTimedOut 101 + 102 + if (isLastAttempt) { 103 + chai.util.flag(assertion, '_isLastPollAttempt', true) 104 + } 105 + 106 + try { 107 + executionPhase = 'fn' 108 + const obj = await fn() 109 + chai.util.flag(assertion, 'object', obj) 110 + 111 + executionPhase = 'assertion' 112 + const output = await assertionFunction.call(assertion, ...args) 113 + 114 + return output 115 + } 116 + catch (err) { 117 + if (isLastAttempt || (executionPhase === 'assertion' && chai.util.flag(assertion, '_poll.assert_once'))) { 118 + throwWithCause(err, STACK_TRACE_ERROR) 119 + } 120 + 121 + await delay(interval, setTimeout) 85 122 } 86 123 } 87 124 } 88 - timeoutId = setTimeout(() => { 89 - clearTimeout(intervalId) 90 - chai.util.flag(assertion, '_isLastPollAttempt', true) 91 - const rejectWithCause = (error: any) => { 92 - if (error.cause == null) { 93 - error.cause = new Error('Matcher did not succeed in time.') 94 - } 95 - reject( 96 - copyStackTrace( 97 - error, 98 - STACK_TRACE_ERROR, 99 - ), 100 - ) 101 - } 102 - check() 103 - .then(() => rejectWithCause(lastError)) 104 - .catch(e => rejectWithCause(e)) 105 - }, timeout) 106 - check() 107 - }) 125 + finally { 126 + clearTimeout(timerId) 127 + } 128 + } 108 129 let awaited = false 109 130 test.onFinished ??= [] 110 131 test.onFinished.push(() => {