/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ // This is a fork of Jest's jest-diff package, but it doesn't depend on Node environment (like chalk). import type { PrettyFormatOptions } from '@vitest/pretty-format' import type { DiffOptions } from './types' import { format as prettyFormat, plugins as prettyFormatPlugins } from '@vitest/pretty-format' import c from 'tinyrainbow' import { stringify } from '../display' import { deepClone, getOwnProperties, getType as getSimpleType } from '../helpers' import { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from './cleanupSemantic' import { NO_DIFF_MESSAGE, SIMILAR_MESSAGE } from './constants' import { diffLinesRaw, diffLinesUnified, diffLinesUnified2 } from './diffLines' import { getType } from './getType' import { normalizeDiffOptions } from './normalizeDiffOptions' import { diffStringsRaw, diffStringsUnified } from './printDiffs' export type { DiffOptions, DiffOptionsColor, SerializedDiffOptions } from './types' export { diffLinesRaw, diffLinesUnified, diffLinesUnified2 } export { diffStringsRaw, diffStringsUnified } export { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } function getCommonMessage(message: string, options?: DiffOptions) { const { commonColor } = normalizeDiffOptions(options) return commonColor(message) } const { AsymmetricMatcher, DOMCollection, DOMElement, Immutable, ReactElement, ReactTestComponent, } = prettyFormatPlugins const PLUGINS = [ ReactTestComponent, ReactElement, DOMElement, DOMCollection, Immutable, AsymmetricMatcher, prettyFormatPlugins.Error, ] const FORMAT_OPTIONS = { maxDepth: 20, plugins: PLUGINS, } satisfies PrettyFormatOptions const FALLBACK_FORMAT_OPTIONS = { callToJSON: false, maxDepth: 8, plugins: PLUGINS, } satisfies PrettyFormatOptions export interface StringifiedMemory { expected?: string actual?: string } interface Memorize { (pointer: 'expected' | 'actual', stringifiedValue: string): string } const DEFAULT_MEMORIZE: Memorize = (_, v) => v // Generate a string that will highlight the difference between two values // with green and red. (similar to how github does code diffing) /** * @param a Expected value * @param b Received value * @param options Diff options * @returns {string | null} a string diff */ export function diff( a: any, b: any, options?: DiffOptions, memorize: Memorize = DEFAULT_MEMORIZE, ): string | undefined { if (Object.is(a, b)) { return '' } const aType = getType(a) let expectedType = aType let omitDifference = false if (aType === 'object' && typeof a.asymmetricMatch === 'function') { if (a.$$typeof !== Symbol.for('jest.asymmetricMatcher')) { // Do not know expected type of user-defined asymmetric matcher. return undefined } if (typeof a.getExpectedType !== 'function') { // For example, expect.anything() matches either null or undefined return undefined } expectedType = a.getExpectedType() // Primitive types boolean and number omit difference below. // For example, omit difference for expect.stringMatching(regexp) omitDifference = expectedType === 'string' } if (expectedType !== getType(b)) { const { aAnnotation, aColor, aIndicator, bAnnotation, bColor, bIndicator } = normalizeDiffOptions(options) const formatOptions = getFormatOptions(FALLBACK_FORMAT_OPTIONS, options) let aDisplay = prettyFormat(a, formatOptions) let bDisplay = prettyFormat(b, formatOptions) // even if prettyFormat prints successfully big objects, // large string can choke later on (concatenation? RPC?), // so truncate it to a reasonable length here. // (For example, playwright's ElementHandle can become about 200_000_000 length string) const MAX_LENGTH = 100_000 function truncate(s: string) { return s.length <= MAX_LENGTH ? s : `${s.slice(0, MAX_LENGTH)}...` } aDisplay = memorize('expected', truncate(aDisplay)) bDisplay = memorize('actual', truncate(bDisplay)) const aDiff = `${aColor(`${aIndicator} ${aAnnotation}:`)}\n${aDisplay}` const bDiff = `${bColor(`${bIndicator} ${bAnnotation}:`)}\n${bDisplay}` return `${aDiff}\n\n${bDiff}` } if (omitDifference) { return undefined } switch (aType) { case 'string': return diffLinesUnified(a.split('\n'), b.split('\n'), options) case 'boolean': case 'number': return comparePrimitive(a, b, options, memorize) case 'map': return compareObjects(sortMap(a), sortMap(b), options, memorize) case 'set': return compareObjects(sortSet(a), sortSet(b), options, memorize) default: return compareObjects(a, b, options, memorize) } } function createMemorize(memory: StringifiedMemory) { return (pointer: 'expected' | 'actual', stringifiedValue: string) => { memory[pointer] = stringifiedValue return stringifiedValue } } function comparePrimitive( a: number | boolean, b: number | boolean, options?: DiffOptions, memorize: Memorize = DEFAULT_MEMORIZE, ) { const aFormat = memorize('expected', prettyFormat(a, FORMAT_OPTIONS)) const bFormat = memorize('actual', prettyFormat(b, FORMAT_OPTIONS)) return aFormat === bFormat ? '' : diffLinesUnified(aFormat.split('\n'), bFormat.split('\n'), options) } function sortMap(map: Map) { return new Map(Array.from(map.entries()).sort()) } function sortSet(set: Set) { return new Set(Array.from(set.values()).sort()) } function compareObjects( a: Record, b: Record, options?: DiffOptions, memorize: Memorize = DEFAULT_MEMORIZE, ) { let difference let hasThrown = false try { const formatOptions = getFormatOptions(FORMAT_OPTIONS, options) difference = getObjectsDifference(a, b, formatOptions, options, memorize) } catch { hasThrown = true } const noDiffMessage = getCommonMessage(NO_DIFF_MESSAGE, options) // If the comparison yields no results, compare again but this time // without calling `toJSON`. It's also possible that toJSON might throw. if (difference === undefined || difference === noDiffMessage) { const formatOptions = getFormatOptions(FALLBACK_FORMAT_OPTIONS, options) difference = getObjectsDifference(a, b, formatOptions, options, memorize) if (difference !== noDiffMessage && !hasThrown) { difference = `${getCommonMessage(SIMILAR_MESSAGE, options)}\n\n${difference}` } } return difference } export function getDefaultFormatOptions(options?: DiffOptions): PrettyFormatOptions { return getFormatOptions(FORMAT_OPTIONS, options) } function getFormatOptions( formatOptions: PrettyFormatOptions, options?: DiffOptions, ): PrettyFormatOptions { const { compareKeys, printBasicPrototype, maxDepth } = normalizeDiffOptions(options) return { ...formatOptions, compareKeys, printBasicPrototype, maxDepth: maxDepth ?? formatOptions.maxDepth, } } function getObjectsDifference( a: Record, b: Record, formatOptions: PrettyFormatOptions, options?: DiffOptions, memorize: Memorize = DEFAULT_MEMORIZE, ): string { const formatOptionsZeroIndent = { ...formatOptions, indent: 0 } const aCompare = prettyFormat(a, formatOptionsZeroIndent) const bCompare = prettyFormat(b, formatOptionsZeroIndent) if (aCompare === bCompare) { return getCommonMessage(NO_DIFF_MESSAGE, options) } else { const aDisplay = memorize('expected', prettyFormat(a, formatOptions)) const bDisplay = memorize('actual', prettyFormat(b, formatOptions)) return diffLinesUnified2( aDisplay.split('\n'), bDisplay.split('\n'), aCompare.split('\n'), bCompare.split('\n'), options, ) } } const MAX_DIFF_STRING_LENGTH = 20_000 function isAsymmetricMatcher(data: any) { const type = getSimpleType(data) return type === 'Object' && typeof data.asymmetricMatch === 'function' } function isReplaceable(obj1: any, obj2: any) { const obj1Type = getSimpleType(obj1) const obj2Type = getSimpleType(obj2) return obj1Type === obj2Type && (obj1Type === 'Object' || obj1Type === 'Array') } export function printDiffOrStringify( received: unknown, expected: unknown, options?: DiffOptions, memory?: StringifiedMemory, ): string | undefined { const { aAnnotation, bAnnotation } = normalizeDiffOptions(options) if ( typeof expected === 'string' && typeof received === 'string' && expected.length > 0 && received.length > 0 && expected.length <= MAX_DIFF_STRING_LENGTH && received.length <= MAX_DIFF_STRING_LENGTH && expected !== received ) { if (expected.includes('\n') || received.includes('\n')) { return diffStringsUnified(expected, received, options) } const [diffs] = diffStringsRaw(expected, received, true) const hasCommonDiff = diffs.some((diff) => diff[0] === DIFF_EQUAL) const printLabel = getLabelPrinter(aAnnotation, bAnnotation) const expectedLine = printLabel(aAnnotation) + printExpected(getCommonAndChangedSubstrings(diffs, DIFF_DELETE, hasCommonDiff)) const receivedLine = printLabel(bAnnotation) + printReceived(getCommonAndChangedSubstrings(diffs, DIFF_INSERT, hasCommonDiff)) return `${expectedLine}\n${receivedLine}` } // if (isLineDiffable(expected, received)) { const clonedExpected = deepClone(expected, { forceWritable: true }) const clonedReceived = deepClone(received, { forceWritable: true }) const { replacedExpected, replacedActual } = replaceAsymmetricMatcher( clonedReceived, clonedExpected, ) const memorize = memory ? createMemorize(memory) : DEFAULT_MEMORIZE const difference = diff(replacedExpected, replacedActual, options, memorize) return difference // } // const printLabel = getLabelPrinter(aAnnotation, bAnnotation) // const expectedLine = printLabel(aAnnotation) + printExpected(expected) // const receivedLine // = printLabel(bAnnotation) // + (stringify(expected) === stringify(received) // ? 'serializes to the same string' // : printReceived(received)) // return `${expectedLine}\n${receivedLine}` } export function replaceAsymmetricMatcher( actual: any, expected: any, actualReplaced: WeakSet = new WeakSet(), expectedReplaced: WeakSet = new WeakSet(), ): { replacedActual: any replacedExpected: any } { // handle asymmetric Error.cause diff if ( actual instanceof Error && expected instanceof Error && typeof actual.cause !== 'undefined' && typeof expected.cause === 'undefined' ) { delete actual.cause return { replacedActual: actual, replacedExpected: expected, } } if (!isReplaceable(actual, expected)) { return { replacedActual: actual, replacedExpected: expected } } if (actualReplaced.has(actual) || expectedReplaced.has(expected)) { return { replacedActual: actual, replacedExpected: expected } } actualReplaced.add(actual) expectedReplaced.add(expected) getOwnProperties(expected).forEach((key) => { const expectedValue = expected[key] const actualValue = actual[key] if (isAsymmetricMatcher(expectedValue)) { if (expectedValue.asymmetricMatch(actualValue)) { // When matcher matches, replace expected with actual value // so they appear the same in the diff expected[key] = actualValue } else if ( 'sample' in expectedValue && expectedValue.sample !== undefined && isReplaceable(actualValue, expectedValue.sample) ) { // For container matchers (ArrayContaining, ObjectContaining), unwrap and recursively process // Matcher doesn't match: unwrap but keep structure to show mismatch const replaced = replaceAsymmetricMatcher( actualValue, expectedValue.sample, actualReplaced, expectedReplaced, ) actual[key] = replaced.replacedActual expected[key] = replaced.replacedExpected } } else if (isAsymmetricMatcher(actualValue)) { if (actualValue.asymmetricMatch(expectedValue)) { actual[key] = expectedValue } else if ( 'sample' in actualValue && actualValue.sample !== undefined && isReplaceable(actualValue.sample, expectedValue) ) { const replaced = replaceAsymmetricMatcher( actualValue.sample, expectedValue, actualReplaced, expectedReplaced, ) actual[key] = replaced.replacedActual expected[key] = replaced.replacedExpected } } else if (isReplaceable(actualValue, expectedValue)) { const replaced = replaceAsymmetricMatcher( actualValue, expectedValue, actualReplaced, expectedReplaced, ) actual[key] = replaced.replacedActual expected[key] = replaced.replacedExpected } }) return { replacedActual: actual, replacedExpected: expected, } } type PrintLabel = (string: string) => string export function getLabelPrinter(...strings: Array): PrintLabel { const maxLength = strings.reduce((max, string) => (string.length > max ? string.length : max), 0) return (string: string): string => `${string}: ${' '.repeat(maxLength - string.length)}` } const SPACE_SYMBOL = '\u{00B7}' // middle dot function replaceTrailingSpaces(text: string): string { return text.replace(/\s+$/gm, (spaces) => SPACE_SYMBOL.repeat(spaces.length)) } function printReceived(object: unknown): string { return c.red(replaceTrailingSpaces(stringify(object))) } function printExpected(value: unknown): string { return c.green(replaceTrailingSpaces(stringify(value))) } function getCommonAndChangedSubstrings( diffs: Array, op: number, hasCommonDiff: boolean, ): string { return diffs.reduce( (reduced: string, diff: Diff): string => reduced + (diff[0] === DIFF_EQUAL ? diff[1] : diff[0] === op ? hasCommonDiff ? c.inverse(diff[1]) : diff[1] : ''), '', ) }