From 291766d75cf7fb149ab6dcfc5127f51815fedbac Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 22 Jul 2024 12:40:06 +0200 Subject: [PATCH] fix: show a difference between string characters if both values are strings (#6191) --- packages/expect/src/jest-matcher-utils.ts | 154 +++++++++-------- packages/utils/src/diff/index.ts | 160 ++++++++++++++++++ packages/utils/src/error.ts | 72 +------- .../__snapshots__/jest-expect.test.ts.snap | 14 +- test/core/test/diff.test.ts | 15 +- test/core/test/environments/jsdom.spec.ts | 14 +- test/core/test/jest-expect.test.ts | 14 +- test/core/test/replace-matcher.test.ts | 2 +- 8 files changed, 269 insertions(+), 176 deletions(-) diff --git a/packages/expect/src/jest-matcher-utils.ts b/packages/expect/src/jest-matcher-utils.ts index b206802f3223..918b3be1ca14 100644 --- a/packages/expect/src/jest-matcher-utils.ts +++ b/packages/expect/src/jest-matcher-utils.ts @@ -1,97 +1,101 @@ 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, @@ -99,9 +103,11 @@ export function getMatcherUtils() { BOLD_WEIGHT, DIM_COLOR, + diff, matcherHint, printReceived, printExpected, + printDiffOrStringify, } } diff --git a/packages/utils/src/diff/index.ts b/packages/utils/src/diff/index.ts index 77a0da76ec67..82777b6c4de0 100644 --- a/packages/utils/src/diff/index.ts +++ b/packages/utils/src/diff/index.ts @@ -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' @@ -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 = new WeakSet(), + expectedReplaced: WeakSet = 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): 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] + : ''), + '', + ) +} diff --git a/packages/utils/src/error.ts b/packages/utils/src/error.ts index 040aef67dcc8..2e0cf0c57152 100644 --- a/packages/utils/src/error.ts +++ b/packages/utils/src/error.ts @@ -1,6 +1,5 @@ -import { type DiffOptions, diff } from './diff' +import { type DiffOptions, printDiffOrStringify } from './diff' import { format, stringify } from './display' -import { deepClone, getOwnProperties, getType } from './helpers' // utils is bundled for any environment and might not support `Element` declare class Element { @@ -132,14 +131,7 @@ export function processError( && err.expected !== undefined && err.actual !== undefined) ) { - const clonedActual = deepClone(err.actual, { forceWritable: true }) - const clonedExpected = deepClone(err.expected, { forceWritable: true }) - - const { replacedActual, replacedExpected } = replaceAsymmetricMatcher( - clonedActual, - clonedExpected, - ) - err.diff = diff(replacedExpected, replacedActual, { + err.diff = printDiffOrStringify(err.actual, err.expected, { ...diffOptions, ...err.diffOptions, }) @@ -181,63 +173,3 @@ export function processError( ) } } - -function isAsymmetricMatcher(data: any) { - const type = getType(data) - return type === 'Object' && typeof data.asymmetricMatch === 'function' -} - -function isReplaceable(obj1: any, obj2: any) { - const obj1Type = getType(obj1) - const obj2Type = getType(obj2) - return ( - obj1Type === obj2Type && (obj1Type === 'Object' || obj1Type === 'Array') - ) -} - -export function replaceAsymmetricMatcher( - actual: any, - expected: any, - actualReplaced: WeakSet = new WeakSet(), - expectedReplaced: WeakSet = 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, - } -} diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index 3e92bdac56d4..a8aff8bc9627 100644 --- a/test/core/test/__snapshots__/jest-expect.test.ts.snap +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -344,11 +344,8 @@ exports[`toHaveBeenNthCalledWith error 2`] = ` exports[`toMatch/toContain diff 1`] = ` { "actual": "hellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohello", - "diff": "- Expected -+ Received - -- world -+ hellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohello", + "diff": "Expected: "world" +Received: "hellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohello"", "expected": "world", "message": "expected 'hellohellohellohellohellohellohellohe…' to contain 'world'", } @@ -357,11 +354,8 @@ exports[`toMatch/toContain diff 1`] = ` exports[`toMatch/toContain diff 2`] = ` { "actual": "hellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohello", - "diff": "- Expected -+ Received - -- world -+ hellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohello", + "diff": "Expected: "world" +Received: "hellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohellohello"", "expected": "world", "message": "expected 'hellohellohellohellohellohellohellohe…' to match 'world'", } diff --git a/test/core/test/diff.test.ts b/test/core/test/diff.test.ts index 1cc368fb95f6..5e027808499b 100644 --- a/test/core/test/diff.test.ts +++ b/test/core/test/diff.test.ts @@ -1,10 +1,23 @@ import { expect, test, vi } from 'vitest' import stripAnsi from 'strip-ansi' import type { DiffOptions } from '@vitest/utils/diff' -import { diff, diffStringsUnified } from '@vitest/utils/diff' +import { diff, diffStringsUnified, printDiffOrStringify } from '@vitest/utils/diff' import { processError } from '@vitest/runner' import { displayDiff } from '../../../packages/vitest/src/node/error' +test('displays string diff', () => { + const stringA = 'Hello AWorld' + const stringB = 'Hello BWorld' + const console = { log: vi.fn(), error: vi.fn() } + displayDiff(printDiffOrStringify(stringA, stringB), console as any) + expect(stripAnsi(console.error.mock.calls[0][0])).toMatchInlineSnapshot(` + " + Expected: "Hello BWorld" + Received: "Hello AWorld" + " + `) +}) + test('displays object diff', () => { const objectA = { a: 1, b: 2 } const objectB = { a: 1, b: 3 } diff --git a/test/core/test/environments/jsdom.spec.ts b/test/core/test/environments/jsdom.spec.ts index c0a32408a880..95f196fa0d0a 100644 --- a/test/core/test/environments/jsdom.spec.ts +++ b/test/core/test/environments/jsdom.spec.ts @@ -71,11 +71,8 @@ test('toContain correctly handles DOM nodes', () => { } catch (err: any) { expect(stripAnsi(processError(err).diff)).toMatchInlineSnapshot(` - "- Expected - + Received - - - flex flex-col flex-row - + flex flex-col" + "Expected: "flex flex-col flex-row" + Received: "flex flex-col"" `) } @@ -85,11 +82,8 @@ test('toContain correctly handles DOM nodes', () => { } catch (err: any) { expect(stripAnsi(processError(err).diff)).toMatchInlineSnapshot(` - "- Expected - + Received - - - flex-col - + flex flex-col" + "Expected: "flex-col" + Received: "flex flex-col"" `) } }) diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 67ed5dbe2f18..c057bb825c43 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -1066,11 +1066,8 @@ it('toHaveProperty error diff', () => { expect(getError(() => expect({ name: 'foo' }).toHaveProperty('name', 'bar'))).toMatchInlineSnapshot(` [ "expected { name: 'foo' } to have property "name" with value 'bar'", - "- Expected - + Received - - - bar - + foo", + "Expected: "bar" + Received: "foo"", ] `) @@ -1114,11 +1111,8 @@ it('toHaveProperty error diff', () => { expect(getError(() => expect({ parent: { name: 'foo' } }).toHaveProperty('parent.name', 'bar'))).toMatchInlineSnapshot(` [ "expected { parent: { name: 'foo' } } to have property "parent.name" with value 'bar'", - "- Expected - + Received - - - bar - + foo", + "Expected: "bar" + Received: "foo"", ] `) diff --git a/test/core/test/replace-matcher.test.ts b/test/core/test/replace-matcher.test.ts index 3c77007650b0..42a95d0f8d4d 100644 --- a/test/core/test/replace-matcher.test.ts +++ b/test/core/test/replace-matcher.test.ts @@ -1,4 +1,4 @@ -import { replaceAsymmetricMatcher } from '@vitest/utils/error' +import { replaceAsymmetricMatcher } from '@vitest/utils/diff' import { describe, expect, it } from 'vitest' describe('replace asymmetric matcher', () => {