Skip to content

Commit

Permalink
fix: show a difference between string characters if both values are s…
Browse files Browse the repository at this point in the history
…trings (#6191)
  • Loading branch information
sheremet-va committed Jul 22, 2024
1 parent 400481f commit 291766d
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 176 deletions.
154 changes: 80 additions & 74 deletions packages/expect/src/jest-matcher-utils.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,113 @@
import { getType, stringify } from '@vitest/utils'
import c from 'tinyrainbow'
import { diff, printDiffOrStringify } from '@vitest/utils/diff'
import type { MatcherHintOptions, Tester } from './types'
import { JEST_MATCHERS_OBJECT } from './constants'

export { diff } from '@vitest/utils/diff'
export { stringify }

export function getMatcherUtils() {
const EXPECTED_COLOR = c.green
const RECEIVED_COLOR = c.red
const INVERTED_COLOR = c.inverse
const BOLD_WEIGHT = c.bold
const DIM_COLOR = c.dim

function matcherHint(
matcherName: string,
received = 'received',
expected = 'expected',
options: MatcherHintOptions = {},
) {
const {
comment = '',
isDirectExpectCall = false, // seems redundant with received === ''
isNot = false,
promise = '',
secondArgument = '',
expectedColor = EXPECTED_COLOR,
receivedColor = RECEIVED_COLOR,
secondArgumentColor = EXPECTED_COLOR,
} = options
let hint = ''
let dimString = 'expect' // concatenate adjacent dim substrings

if (!isDirectExpectCall && received !== '') {
hint += DIM_COLOR(`${dimString}(`) + receivedColor(received)
dimString = ')'
}

if (promise !== '') {
hint += DIM_COLOR(`${dimString}.`) + promise
dimString = ''
}
const EXPECTED_COLOR = c.green
const RECEIVED_COLOR = c.red
const INVERTED_COLOR = c.inverse
const BOLD_WEIGHT = c.bold
const DIM_COLOR = c.dim

function matcherHint(
matcherName: string,
received = 'received',
expected = 'expected',
options: MatcherHintOptions = {},
) {
const {
comment = '',
isDirectExpectCall = false, // seems redundant with received === ''
isNot = false,
promise = '',
secondArgument = '',
expectedColor = EXPECTED_COLOR,
receivedColor = RECEIVED_COLOR,
secondArgumentColor = EXPECTED_COLOR,
} = options
let hint = ''
let dimString = 'expect' // concatenate adjacent dim substrings

if (!isDirectExpectCall && received !== '') {
hint += DIM_COLOR(`${dimString}(`) + receivedColor(received)
dimString = ')'
}

if (isNot) {
hint += `${DIM_COLOR(`${dimString}.`)}not`
dimString = ''
}
if (promise !== '') {
hint += DIM_COLOR(`${dimString}.`) + promise
dimString = ''
}

if (matcherName.includes('.')) {
// Old format: for backward compatibility,
// especially without promise or isNot options
dimString += matcherName
}
else {
// New format: omit period from matcherName arg
hint += DIM_COLOR(`${dimString}.`) + matcherName
dimString = ''
}
if (isNot) {
hint += `${DIM_COLOR(`${dimString}.`)}not`
dimString = ''
}

if (expected === '') {
dimString += '()'
}
else {
hint += DIM_COLOR(`${dimString}(`) + expectedColor(expected)
if (secondArgument) {
hint += DIM_COLOR(', ') + secondArgumentColor(secondArgument)
}
dimString = ')'
}
if (matcherName.includes('.')) {
// Old format: for backward compatibility,
// especially without promise or isNot options
dimString += matcherName
}
else {
// New format: omit period from matcherName arg
hint += DIM_COLOR(`${dimString}.`) + matcherName
dimString = ''
}

if (comment !== '') {
dimString += ` // ${comment}`
if (expected === '') {
dimString += '()'
}
else {
hint += DIM_COLOR(`${dimString}(`) + expectedColor(expected)
if (secondArgument) {
hint += DIM_COLOR(', ') + secondArgumentColor(secondArgument)
}
dimString = ')'
}

if (dimString !== '') {
hint += DIM_COLOR(dimString)
}
if (comment !== '') {
dimString += ` // ${comment}`
}

return hint
if (dimString !== '') {
hint += DIM_COLOR(dimString)
}

const SPACE_SYMBOL = '\u{00B7}' // middle dot
return hint
}

const SPACE_SYMBOL = '\u{00B7}' // middle dot

// Instead of inverse highlight which now implies a change,
// replace common spaces with middle dot at the end of any line.
const replaceTrailingSpaces = (text: string): string =>
text.replace(/\s+$/gm, spaces => SPACE_SYMBOL.repeat(spaces.length))
// Instead of inverse highlight which now implies a change,
// replace common spaces with middle dot at the end of any line.
function replaceTrailingSpaces(text: string): string {
return text.replace(/\s+$/gm, spaces => SPACE_SYMBOL.repeat(spaces.length))
}

const printReceived = (object: unknown): string =>
RECEIVED_COLOR(replaceTrailingSpaces(stringify(object)))
const printExpected = (value: unknown): string =>
EXPECTED_COLOR(replaceTrailingSpaces(stringify(value)))
function printReceived(object: unknown): string {
return RECEIVED_COLOR(replaceTrailingSpaces(stringify(object)))
}
function printExpected(value: unknown): string {
return EXPECTED_COLOR(replaceTrailingSpaces(stringify(value)))
}

export function getMatcherUtils() {
return {
EXPECTED_COLOR,
RECEIVED_COLOR,
INVERTED_COLOR,
BOLD_WEIGHT,
DIM_COLOR,

diff,
matcherHint,
printReceived,
printExpected,
printDiffOrStringify,
}
}

Expand Down
160 changes: 160 additions & 0 deletions packages/utils/src/diff/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ 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 { getType } from './getType'
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic'
import { NO_DIFF_MESSAGE, SIMILAR_MESSAGE } from './constants'
Expand Down Expand Up @@ -211,3 +214,160 @@ function getObjectsDifference(
)
}
}

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(
expected: unknown,
received: unknown,
options?: DiffOptions,
): string | null {
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(received, expected, options)
}

const [diffs] = diffStringsRaw(received, expected, 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(clonedExpected, clonedReceived)
const difference = diff(replacedExpected, replacedActual, options)

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<WeakKey> = new WeakSet(),
expectedReplaced: WeakSet<WeakKey> = new WeakSet(),
): {
replacedActual: any
replacedExpected: any
} {
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)) {
actual[key] = expectedValue
}
}
else if (isAsymmetricMatcher(actualValue)) {
if (actualValue.asymmetricMatch(expectedValue)) {
expected[key] = actualValue
}
}
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<string>): 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<Diff>, 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]
: ''),
'',
)
}
Loading

0 comments on commit 291766d

Please sign in to comment.