Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290import type { SerializedLocator } from '@vitest/browser'import type { BrowserCommandContext, BrowserConfigOptions } from 'vitest/node'// Note: this augments `screenshotOptions` typesimport type {} from '../../../../../browser-playwright'import type { ScreenshotMatcherOptions } from '../../../../context'import type { ScreenshotMatcherArguments } from '../../../shared/screenshotMatcher/types'import type { AnyCodec } from './codecs'import { platform } from 'node:os'import { deepMerge } from '@vitest/utils/helpers'import { basename, dirname, extname, join, relative, resolve } from 'pathe'import { getCodec } from './codecs'import { getComparator } from './comparators'
type GlobalOptions = Required< Omit< NonNullable< NonNullable<BrowserConfigOptions['expect']>['toMatchScreenshot'] & NonNullable<Pick<ScreenshotMatcherArguments[2], 'screenshotOptions'>> >, 'comparators' | 'screenshotDirectory' >>
const defaultOptions = { comparatorName: 'pixelmatch', // these are handled by each comparator on its own comparatorOptions: {}, screenshotOptions: { animations: 'disabled', caret: 'hide', fullPage: false, maskColor: '#ff00ff', omitBackground: false, scale: 'device', }, timeout: 5_000, strict: true, resolveDiffPath: ({ arg, ext, root, attachmentsDir, browserName, platform, testFileDirectory, testFileName, }) => resolve( root, attachmentsDir, testFileDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`, ), resolveScreenshotPath: ({ arg, ext, root, screenshotDirectory, testFileDirectory, testFileName, browserName, }) => resolve( root, testFileDirectory, screenshotDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`, ),} satisfies GlobalOptions
type SupportedCodecs = Parameters<typeof getCodec>[0]
const supportedExtensions = ['png'] satisfies SupportedCodecs[]
export interface ResolvedOptions { codec: ReturnType<typeof getCodec> comparator: ReturnType<typeof getComparator> resolvedOptions: GlobalOptions paths: { reference: string diffs: { reference: string actual: string diff: string } }}
export function resolveOptions({ context, name, options, testName,}: { context: BrowserCommandContext name: string testName: string options: ScreenshotMatcherOptions}): ResolvedOptions { if (context.testPath === undefined) { throw new Error('`resolveOptions` has to be used in a test file') }
const resolvedOptions = deepMerge<GlobalOptions>( Object.create(null), defaultOptions, context.project.config.browser.expect?.toMatchScreenshot ?? {}, options, )
const extensionFromName = extname(name)
// technically the type is a lie, but we check beneath and reassign otherwise let extension = extensionFromName.replace(/^\./, '') as SupportedCodecs
// when `type` will be supported in `screenshotOptions`: // - `'png'` should end up in `defaultOptions.screenshotOptions.type` // - this condition should be switched around // - the assignment should be `resolvedOptions.screenshotOptions.type = extension` // - everything using `extension` should use `resolvedOptions.screenshotOptions.type` if (supportedExtensions.includes(extension) === false) { extension = 'png' }
const { root } = context.project.serializedConfig
const resolvePathData = { arg: sanitizeArg( // remove the extension only if it ends up being used extensionFromName.endsWith(extension) ? basename(name, extensionFromName) : name, ), ext: `.${extension}`, platform: platform(), root, screenshotDirectory: context.project.config.browser.expect?.toMatchScreenshot?.screenshotDirectory ?? '__screenshots__', attachmentsDir: relative(root, context.project.config.attachmentsDir), testFileDirectory: relative(root, dirname(context.testPath)), testFileName: basename(context.testPath), testName: sanitize(testName, false), browserName: context.project.config.browser.name, project: context.project, } satisfies Parameters<GlobalOptions['resolveDiffPath']>[0]
return { codec: getCodec(extension), comparator: getComparator(resolvedOptions.comparatorName, context), resolvedOptions, paths: { reference: resolvedOptions.resolveScreenshotPath(resolvePathData), // lazily initialize this, as it might not be needed at all get diffs() { const diffs = { reference: resolvedOptions.resolveDiffPath({ ...resolvePathData, arg: `${resolvePathData.arg}-reference`, }), actual: resolvedOptions.resolveDiffPath({ ...resolvePathData, arg: `${resolvePathData.arg}-actual`, }), diff: resolvedOptions.resolveDiffPath({ ...resolvePathData, arg: `${resolvePathData.arg}-diff`, }), }
Object.defineProperty(this, 'diffs', { value: diffs })
return diffs }, }, }}
/** * Sanitizes a string by removing or transforming characters to ensure it is * safe for use as a filename or path segment. It supports two modes: * * 1. Non-path mode (`keepPaths === false`): * - Replaces one or more whitespace characters (`\s+`) with a single hyphen (`-`). * - Removes any character that is not a word character (`\w`) or a hyphen (`-`). * - Collapses multiple consecutive hyphens (`-{2,}`) into a single hyphen. * * 2. Path-preserving mode (`keepPaths === true`): * - Splits the input string on the path separator. * - Sanitizes each path segment individually in non-path mode. * - Joins the sanitized segments back together. * * @param input - The raw string to sanitize. * @param keepPaths - If `false`, performs a flat sanitization (drops path segments). * If `true`, treats `input` as a path: each segment is sanitized independently, * preserving separators. */function sanitize(input: string, keepPaths: boolean): string { if (keepPaths === false) { return input .replace(/\s+/g, '-') .replace(/[^\w-]+/g, '') .replace(/-{2,}/g, '-') }
return input .split('/') .map((path) => sanitize(path, false)) .join('/')}
/** * Takes a string, treats it as a potential path or filename, and ensures it cannot * escape the root directory or contain invalid characters. Internally, it: * * 1. Prepends the path separator to the raw input to form a path-like string. * 2. Uses {@linkcode relative|relative('/', <that-path>)} to compute a relative * path from the root, which effectively strips any leading separators and prevents * traversal above the root. * 3. Passes the resulting relative path into {@linkcode sanitize|sanitize(..., true)}, * preserving any path separators but sanitizing each segment. * * @param input - The raw string to clean. */function sanitizeArg(input: string): string { return sanitize(relative('/', join('/', input)), true)}
/** * Takes a screenshot and decodes it using the provided codec. * * The screenshot is taken as a base64 string and then decoded into the format * expected by the comparator. * * @returns `Promise` resolving to the decoded screenshot data */export function takeScreenshotBuffer({ context, element, name, screenshotOptions, target,}: { context: BrowserCommandContext element?: SerializedLocator name: string screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] target?: ScreenshotMatcherArguments[2]['target']}): Promise<Buffer<ArrayBufferLike>> { return context .triggerCommand('__vitest_takeScreenshot', name, { ...screenshotOptions, save: false, element, target, }) .then(({ buffer }) => buffer)}
export function takeDecodedScreenshot({ codec, ...options}: { codec: AnyCodec context: BrowserCommandContext element?: SerializedLocator name: string screenshotOptions: ScreenshotMatcherArguments[2]['screenshotOptions'] target?: ScreenshotMatcherArguments[2]['target']}): ReturnType<AnyCodec['decode']> { return takeScreenshotBuffer(options).then((buffer) => codec.decode(buffer, {}))}
/** * Creates a promise that resolves to `null` after the specified timeout. * If the timeout is `0`, the promise resolves immediately. * * @param timeout - The delay in milliseconds before the promise resolves * @returns `Promise` that resolves to `null` after the timeout */export function asyncTimeout(timeout: number): Promise<null> { return new Promise((resolve) => { if (timeout === 0) { resolve(null) } else { setTimeout(resolve, timeout, null) } })}