diff --git a/packages/browser-playwright/src/commands/screenshot.ts b/packages/browser-playwright/src/commands/screenshot.ts index 75aa396ac..f852632ec 100644 --- a/packages/browser-playwright/src/commands/screenshot.ts +++ b/packages/browser-playwright/src/commands/screenshot.ts @@ -9,6 +9,7 @@ import { getDescribedLocator } from './utils' interface ScreenshotCommandOptions extends Omit { element?: SerializedLocator mask?: readonly SerializedLocator[] + target?: 'element' | 'page' } const SCREENSHOT_STYLES = /* css */` @@ -61,7 +62,7 @@ export async function takeScreenshot( : options.style if (options.element) { - const { element: selector, ...config } = options + const { element: selector, target: _target, ...config } = options const element = getDescribedLocator(context, selector) const buffer = await element.screenshot({ ...config, @@ -72,11 +73,19 @@ export async function takeScreenshot( return { buffer, path } } - const buffer = await getDescribedLocator(context, { selector: 'body', locator: 'locator(\'body\')' }).screenshot({ - ...options, - mask, - path: savePath, - style, - }) + const { target, ...config } = options + const buffer = target === 'page' + ? await context.page.screenshot({ + ...config, + mask, + path: savePath, + style, + }) + : await getDescribedLocator(context, { selector: 'body', locator: 'locator(\'body\')' }).screenshot({ + ...config, + mask, + path: savePath, + style, + }) return { buffer, path } } diff --git a/packages/browser-webdriverio/src/commands/screenshot.ts b/packages/browser-webdriverio/src/commands/screenshot.ts index 826000db5..2640b151e 100644 --- a/packages/browser-webdriverio/src/commands/screenshot.ts +++ b/packages/browser-webdriverio/src/commands/screenshot.ts @@ -10,6 +10,7 @@ import { dirname, normalize, resolve } from 'pathe' interface ScreenshotCommandOptions extends Omit { element?: SerializedLocator mask?: readonly SerializedLocator[] + target?: 'element' | 'page' } /** @@ -52,18 +53,18 @@ export async function takeScreenshot( await mkdir(context.project.tmpDir, { recursive: true }) } - const page = context.browser - const element = !options.element - ? await page.$('body') - : await page.$(`${options.element.selector}`) - // webdriverio expects the path to contain the extension and only works with PNG files const savePathWithExtension = savePath.endsWith('.png') ? savePath : `${savePath}.png` // there seems to be a bug in webdriverio, `X:/` gets appended to cwd, so we convert to `X:\` - const buffer = await element.saveScreenshot( - platformNormalize(savePathWithExtension), - ) + const normalizedSavePath = platformNormalize(savePathWithExtension) + // `browser.saveScreenshot` captures the top-level page for `expect(page)`; + // element and plain `page.screenshot()` calls keep the existing body fallback. + const buffer = options.target === 'page' + ? await context.browser.saveScreenshot(normalizedSavePath) + : await context.browser.$(options.element?.selector ?? 'body').saveScreenshot( + normalizedSavePath, + ) if (!options.save) { await rm(savePathWithExtension, { force: true }) diff --git a/packages/browser/jest-dom.d.ts b/packages/browser/jest-dom.d.ts index a09b622dc..0242faa43 100644 --- a/packages/browser/jest-dom.d.ts +++ b/packages/browser/jest-dom.d.ts @@ -692,6 +692,7 @@ export interface TestingLibraryMatchers { * * // basic usage, auto-generates screenshot name * await expect.element(getByTestId('button')).toMatchScreenshot() + * await expect(page).toMatchScreenshot() * * // with custom name * await expect.element(getByTestId('button')).toMatchScreenshot('fancy-button') diff --git a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts index c492fddcd..b74557a0b 100644 --- a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts +++ b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts @@ -1,6 +1,6 @@ import type { VisualRegressionArtifact } from '@vitest/runner' import type { AsyncMatcherResult, MatcherState } from 'vitest' -import type { ScreenshotMatcherOptions } from '../../../../context' +import type { BrowserPage, ScreenshotMatcherOptions } from '../../../../context' import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' import type { Locator } from '../locators' import { recordArtifact } from 'vitest' @@ -11,7 +11,7 @@ const counters = new Map([]) export default async function toMatchScreenshot( this: MatcherState, - actual: Element | Locator, + actual: BrowserPage | Element | Locator, nameOrOptions?: ScreenshotMatcherOptions | string, options: ScreenshotMatcherOptions = typeof nameOrOptions === 'object' ? nameOrOptions @@ -40,8 +40,10 @@ export default async function toMatchScreenshot( ? nameOrOptions : `${this.currentTestName} ${counter.current}` + const isPageTarget = isBrowserPage(actual) + const [element, ...mask] = await Promise.all([ - serializeElement(actual, options), + isPageTarget ? undefined : serializeElement(actual, options), ...options.screenshotOptions && 'mask' in options.screenshotOptions ? (options.screenshotOptions.mask as Array) .map(m => serializeElement(m, options)) @@ -68,6 +70,7 @@ export default async function toMatchScreenshot( this.currentTestName, { element, + target: isPageTarget ? 'page' : 'element', ...normalizedOptions, }, ] satisfies ScreenshotMatcherArguments, @@ -104,7 +107,7 @@ export default async function toMatchScreenshot( result.pass ? '' : [ - this.utils.matcherHint('toMatchScreenshot', 'element', ''), + this.utils.matcherHint('toMatchScreenshot', isPageTarget ? 'page' : 'element', ''), '', result.message, result.reference @@ -125,3 +128,8 @@ export default async function toMatchScreenshot( }, } } + +function isBrowserPage(value: unknown): value is BrowserPage { + return !!value && typeof value === 'object' && 'viewport' in value + && typeof value.viewport === 'function' +} diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index 911499eaa..2cbb11e8d 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -4,6 +4,7 @@ import type { ScreenshotOptions, SerializedLocator } from '../../../context' interface ScreenshotCommandOptions extends Omit { element?: SerializedLocator mask?: readonly SerializedLocator[] + target?: 'element' | 'page' } declare module 'vitest/browser' { diff --git a/packages/browser/src/node/commands/screenshotMatcher/index.ts b/packages/browser/src/node/commands/screenshotMatcher/index.ts index 6e151f75e..e94a688bc 100644 --- a/packages/browser/src/node/commands/screenshotMatcher/index.ts +++ b/packages/browser/src/node/commands/screenshotMatcher/index.ts @@ -9,7 +9,7 @@ import type { TypedArray } from './types' import type { ResolvedOptions } from './utils' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { basename, dirname } from 'pathe' -import { asyncTimeout, resolveOptions, takeDecodedScreenshot } from './utils' +import { asyncTimeout, resolveOptions, takeDecodedScreenshot, takeScreenshotBuffer } from './utils' /** Decoded image data with dimensions metadata. */ type DecodedImage = Awaited> @@ -18,6 +18,12 @@ type DecodedImage = Awaited> interface ScreenshotData { image: DecodedImage path: string + buffer?: Buffer +} + +interface CapturedScreenshot { + image: DecodedImage + buffer: Buffer } /** @@ -77,16 +83,46 @@ export const screenshotMatcher: BrowserCommand = asy throw new Error('Cannot compare screenshots without a test path') } - const { element } = options + const { element, target } = options const { codec, comparator, paths, - resolvedOptions: { comparatorOptions, screenshotOptions, timeout }, + resolvedOptions: { comparatorName, comparatorOptions, screenshotOptions, timeout }, } = resolveOptions({ context, name, testName, options }) + const screenshotName = `${Date.now()}-${basename(paths.reference)}` + const screenshotCaptureOptions = { + context, + element, + name: screenshotName, + screenshotOptions, + target, + } satisfies Parameters[0] + const referenceFile = await readFile(paths.reference).catch(() => null) - const reference = referenceFile && await codec.decode(referenceFile, {}) + let reference: DecodedImage | null = null + let initialScreenshot: CapturedScreenshot | null = null + + if (referenceFile) { + // Reuse this capture in the stability loop so the byte fast path doesn't add another screenshot. + const initialScreenshotBuffer = await takeScreenshotBuffer(screenshotCaptureOptions) + + // Keep custom comparator semantics intact: only the built-in pixelmatch + // comparator is known to pass byte-identical PNGs without side effects. + if (comparatorName === 'pixelmatch' && Buffer.compare(referenceFile, initialScreenshotBuffer) === 0) { + return buildOutput({ type: 'matched-immediately' }, timeout) + } + + [reference, initialScreenshot] = await Promise.all([ + codec.decode(referenceFile, {}), + takeScreenshotData({ + ...screenshotCaptureOptions, + buffer: initialScreenshotBuffer, + codec, + }), + ]) + } const screenshotResult = await waitForStableScreenshot({ codec, @@ -94,14 +130,17 @@ export const screenshotMatcher: BrowserCommand = asy comparatorOptions, context, element, - name: `${Date.now()}-${basename(paths.reference)}`, + initialScreenshot, + name: screenshotName, reference, screenshotOptions, + target, }, timeout) const outcome = await determineOutcome({ reference, screenshot: screenshotResult && screenshotResult.actual, + screenshotBuffer: screenshotResult?.buffer, retries: screenshotResult?.retries ?? 0, updateSnapshot: context.project.serializedConfig.snapshotOptions.updateSnapshot, paths, @@ -129,12 +168,14 @@ async function determineOutcome( reference, retries, screenshot, + screenshotBuffer, updateSnapshot, }: Pick & { comparatorOptions: ResolvedOptions['resolvedOptions']['comparatorOptions'] reference: DecodedImage | null retries: number screenshot: DecodedImage | null + screenshotBuffer?: Buffer updateSnapshot: SnapshotUpdateState }, ): Promise { @@ -156,6 +197,7 @@ async function determineOutcome( reference: { image: screenshot, path: paths.reference, + buffer: screenshotBuffer, }, } } @@ -172,6 +214,7 @@ async function determineOutcome( path: location === 'reference' ? paths.reference : paths.diffs.reference, + buffer: screenshotBuffer, }, } } @@ -197,6 +240,7 @@ async function determineOutcome( reference: { image: screenshot, path: paths.reference, + buffer: screenshotBuffer, }, } } @@ -210,6 +254,7 @@ async function determineOutcome( actual: { image: screenshot, path: paths.diffs.actual, + buffer: screenshotBuffer, }, diff: comparisonResult.diff && { image: { @@ -237,7 +282,7 @@ async function performSideEffects( case 'update-reference': { await writeScreenshot( outcome.reference.path, - await codec.encode(outcome.reference.image, {}), + await encodeScreenshot(outcome.reference, codec), ) break @@ -246,7 +291,7 @@ async function performSideEffects( case 'mismatch': { await writeScreenshot( outcome.actual.path, - await codec.encode(outcome.actual.image, {}), + await encodeScreenshot(outcome.actual, codec), ) if (outcome.diff) { @@ -261,6 +306,10 @@ async function performSideEffects( } } +function encodeScreenshot(screenshot: ScreenshotData, codec: AnyCodec) { + return screenshot.buffer ?? codec.encode(screenshot.image, {}) +} + /** * Transforms a {@linkcode MatchOutcome} into the output format expected by the test runner. * @@ -353,10 +402,12 @@ interface StableScreenshotOptions { comparator: AnyComparator comparatorOptions: ScreenshotMatcherOptions['comparatorOptions'] context: BrowserCommandContext - element: SerializedLocator + element?: SerializedLocator + initialScreenshot: CapturedScreenshot | null name: string reference: ReturnType | null screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] + target?: ScreenshotMatcherArguments[2]['target'] } /** @@ -365,7 +416,7 @@ interface StableScreenshotOptions { * Wraps {@linkcode getStableScreenshot} with an abort controller that triggers when the timeout expires. Returns `null` if the page never stabilizes. */ async function waitForStableScreenshot(options: StableScreenshotOptions, timeout: number, -): Promise<{ actual: DecodedImage; retries: number } | null> { +): Promise<{ actual: DecodedImage; buffer: Buffer; retries: number } | null> { const abortController = new AbortController() const stableScreenshot = getStableScreenshot( @@ -406,12 +457,15 @@ async function getStableScreenshot({ comparator, comparatorOptions, element, + initialScreenshot, name, reference, screenshotOptions, + target, }: StableScreenshotOptions, signal: AbortSignal): Promise<{ retries: number actual: DecodedImage + buffer: Buffer }> { const screenshotArgument = { codec, @@ -419,21 +473,26 @@ async function getStableScreenshot({ element, name, screenshotOptions, + target, } satisfies Parameters[0] let retries = 0 let decodedBaseline = reference + let nextScreenshot = initialScreenshot + let lastCapturedScreenshot: CapturedScreenshot | null = null while (signal.aborted === false) { if (decodedBaseline === null) { decodedBaseline = takeDecodedScreenshot(screenshotArgument) } - const [image1, image2] = await Promise.all([ + const [image1, capturedScreenshot] = await Promise.all([ decodedBaseline, - takeDecodedScreenshot(screenshotArgument), + nextScreenshot ?? takeScreenshotData(screenshotArgument), ]) + const { image: image2 } = capturedScreenshot + lastCapturedScreenshot = capturedScreenshot const isStable = (await comparator( image1, @@ -442,17 +501,56 @@ async function getStableScreenshot({ )).pass decodedBaseline = image2 + nextScreenshot = null if (isStable) { - break + return { + retries, + actual: image2, + buffer: capturedScreenshot.buffer, + } } retries += 1 } + lastCapturedScreenshot ??= await takeScreenshotData(screenshotArgument) + return { retries, - actual: await decodedBaseline!, + actual: lastCapturedScreenshot.image, + buffer: lastCapturedScreenshot.buffer, + } +} + +async function takeScreenshotData({ + buffer, + codec, + context, + element, + name, + screenshotOptions, + target, +}: { + buffer?: Buffer + codec: AnyCodec + context: BrowserCommandContext + element?: SerializedLocator + name: string + screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] + target?: ScreenshotMatcherArguments[2]['target'] +}): Promise { + const screenshot = buffer ?? await takeScreenshotBuffer({ + context, + element, + name, + screenshotOptions, + target, + }) + + return { + buffer: screenshot, + image: await codec.decode(screenshot, {}), } } diff --git a/packages/browser/src/node/commands/screenshotMatcher/utils.ts b/packages/browser/src/node/commands/screenshotMatcher/utils.ts index 1eb3cb7ef..db74482fc 100644 --- a/packages/browser/src/node/commands/screenshotMatcher/utils.ts +++ b/packages/browser/src/node/commands/screenshotMatcher/utils.ts @@ -233,25 +233,41 @@ function sanitizeArg(input: string): string { * * @returns `Promise` resolving to the decoded screenshot data */ -export function takeDecodedScreenshot({ - codec, +export function takeScreenshotBuffer({ context, element, name, screenshotOptions, + target, }: { - codec: AnyCodec context: BrowserCommandContext - element: SerializedLocator + element?: SerializedLocator name: string screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] -}): ReturnType { + target?: ScreenshotMatcherArguments[2]['target'] +}): Promise> { return context.triggerCommand( '__vitest_takeScreenshot', name, - { ...screenshotOptions, save: false, element }, + { ...screenshotOptions, save: false, element, target }, ).then( - ({ buffer }) => codec.decode(buffer, {}), + ({ buffer }) => buffer, + ) +} + +export function takeDecodedScreenshot({ + codec, + ...options +}: { + codec: AnyCodec + context: BrowserCommandContext + element?: SerializedLocator + name: string + screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] + target?: ScreenshotMatcherArguments[2]['target'] +}): ReturnType { + return takeScreenshotBuffer(options).then( + buffer => codec.decode(buffer, {}), ) } diff --git a/packages/browser/src/shared/screenshotMatcher/types.ts b/packages/browser/src/shared/screenshotMatcher/types.ts index f7ab57d84..8d0759cc3 100644 --- a/packages/browser/src/shared/screenshotMatcher/types.ts +++ b/packages/browser/src/shared/screenshotMatcher/types.ts @@ -7,7 +7,8 @@ export type ScreenshotMatcherArguments< testName: string, options: ScreenshotMatcherOptions & { - element: SerializedLocator + element?: SerializedLocator + target?: 'element' | 'page' screenshotOptions?: ScreenshotMatcherOptions['screenshotOptions'] & { mask?: readonly SerializedLocator[] } }, ] diff --git a/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts b/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts index a7ebcea7f..40a99d0c5 100644 --- a/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts +++ b/test/browser/fixtures/expect-dom/toMatchScreenshot.test.ts @@ -64,6 +64,49 @@ describe('.toMatchScreenshot', () => { await expect(locator).toMatchScreenshot(filename) }) + test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')( + 'supports page screenshots', + async ({ onTestFinished }) => { + const filename = globalThis.crypto.randomUUID() + + renderTestCase([ + 'oklch(39.6% 0.141 25.723)', + 'oklch(40.5% 0.101 131.063)', + 'oklch(37.9% 0.146 265.522)', + ]) + + let errorMessage: string + + try { + await expect(page).toMatchScreenshot(filename) + } + catch (error) { + errorMessage = error.message + } + + const [referencePath] = extractToMatchScreenshotPaths(errorMessage, filename) + + expect(typeof referencePath).toBe('string') + + onTestFinished(async () => { + await server.commands.removeFile(referencePath) + }) + + expect(errorMessage).toMatchInlineSnapshot(` + expect(page).toMatchScreenshot() + + No existing reference screenshot found${ + server.config.snapshotOptions.updateSnapshot === 'none' + ? '.' + : '; a new one was created. Review it before running tests again.' + } + + Reference screenshot: + ${referencePath} + `) + }, + ) + // Only run this test if snapshots aren't being updated test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')( "throws when screenshots don't match", @@ -377,7 +420,7 @@ describe('.toMatchScreenshot', () => { }, ) - test('can use custom comparators', async ({ onTestFinished }) => { + test.runIf(server.config.snapshotOptions.updateSnapshot !== 'all')('can use custom comparators', async ({ onTestFinished }) => { const filename = globalThis.crypto.randomUUID() const path = join( '__screenshots__', @@ -397,16 +440,27 @@ describe('.toMatchScreenshot', () => { const locator = page.getByTestId(dataTestId) - // Create a reference screenshot by explicitly saving one - await locator.screenshot({ - save: true, - path, - }) + // Test that `toMatchScreenshot()` correctly uses a custom comparator even + // when the PNG bytes match. The byte fast path must not bypass custom + // comparator semantics. + let firstErrorMessage: string + try { + await expect(locator).toMatchScreenshot(filename) + } catch (error) { + firstErrorMessage = error.message + } - // Test that `toMatchScreenshot()` correctly uses a custom comparator; since - // the element hasn't changed, it should match, but this custom comparator - // will always fail - await expect(locator).toMatchScreenshot(filename) + const [createdReferencePath] = extractToMatchScreenshotPaths(firstErrorMessage, filename) + if (!createdReferencePath.endsWith(path)) { + await server.commands.writeFile( + path, + await server.commands.readFile(createdReferencePath, { encoding: 'base64' }), + { encoding: 'base64' }, + ) + onTestFinished(async () => { + await server.commands.removeFile(createdReferencePath) + }) + } let errorMessage: string