Skip to content

Commit 291766d

Browse files
authored
fix: show a difference between string characters if both values are strings (#6191)
1 parent 400481f commit 291766d

File tree

8 files changed

+269
-176
lines changed

8 files changed

+269
-176
lines changed

packages/expect/src/jest-matcher-utils.ts

Lines changed: 80 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,113 @@
11
import { getType, stringify } from '@vitest/utils'
22
import c from 'tinyrainbow'
3+
import { diff, printDiffOrStringify } from '@vitest/utils/diff'
34
import type { MatcherHintOptions, Tester } from './types'
45
import { JEST_MATCHERS_OBJECT } from './constants'
56

67
export { diff } from '@vitest/utils/diff'
78
export { stringify }
89

9-
export function getMatcherUtils() {
10-
const EXPECTED_COLOR = c.green
11-
const RECEIVED_COLOR = c.red
12-
const INVERTED_COLOR = c.inverse
13-
const BOLD_WEIGHT = c.bold
14-
const DIM_COLOR = c.dim
15-
16-
function matcherHint(
17-
matcherName: string,
18-
received = 'received',
19-
expected = 'expected',
20-
options: MatcherHintOptions = {},
21-
) {
22-
const {
23-
comment = '',
24-
isDirectExpectCall = false, // seems redundant with received === ''
25-
isNot = false,
26-
promise = '',
27-
secondArgument = '',
28-
expectedColor = EXPECTED_COLOR,
29-
receivedColor = RECEIVED_COLOR,
30-
secondArgumentColor = EXPECTED_COLOR,
31-
} = options
32-
let hint = ''
33-
let dimString = 'expect' // concatenate adjacent dim substrings
34-
35-
if (!isDirectExpectCall && received !== '') {
36-
hint += DIM_COLOR(`${dimString}(`) + receivedColor(received)
37-
dimString = ')'
38-
}
39-
40-
if (promise !== '') {
41-
hint += DIM_COLOR(`${dimString}.`) + promise
42-
dimString = ''
43-
}
10+
const EXPECTED_COLOR = c.green
11+
const RECEIVED_COLOR = c.red
12+
const INVERTED_COLOR = c.inverse
13+
const BOLD_WEIGHT = c.bold
14+
const DIM_COLOR = c.dim
15+
16+
function matcherHint(
17+
matcherName: string,
18+
received = 'received',
19+
expected = 'expected',
20+
options: MatcherHintOptions = {},
21+
) {
22+
const {
23+
comment = '',
24+
isDirectExpectCall = false, // seems redundant with received === ''
25+
isNot = false,
26+
promise = '',
27+
secondArgument = '',
28+
expectedColor = EXPECTED_COLOR,
29+
receivedColor = RECEIVED_COLOR,
30+
secondArgumentColor = EXPECTED_COLOR,
31+
} = options
32+
let hint = ''
33+
let dimString = 'expect' // concatenate adjacent dim substrings
34+
35+
if (!isDirectExpectCall && received !== '') {
36+
hint += DIM_COLOR(`${dimString}(`) + receivedColor(received)
37+
dimString = ')'
38+
}
4439

45-
if (isNot) {
46-
hint += `${DIM_COLOR(`${dimString}.`)}not`
47-
dimString = ''
48-
}
40+
if (promise !== '') {
41+
hint += DIM_COLOR(`${dimString}.`) + promise
42+
dimString = ''
43+
}
4944

50-
if (matcherName.includes('.')) {
51-
// Old format: for backward compatibility,
52-
// especially without promise or isNot options
53-
dimString += matcherName
54-
}
55-
else {
56-
// New format: omit period from matcherName arg
57-
hint += DIM_COLOR(`${dimString}.`) + matcherName
58-
dimString = ''
59-
}
45+
if (isNot) {
46+
hint += `${DIM_COLOR(`${dimString}.`)}not`
47+
dimString = ''
48+
}
6049

61-
if (expected === '') {
62-
dimString += '()'
63-
}
64-
else {
65-
hint += DIM_COLOR(`${dimString}(`) + expectedColor(expected)
66-
if (secondArgument) {
67-
hint += DIM_COLOR(', ') + secondArgumentColor(secondArgument)
68-
}
69-
dimString = ')'
70-
}
50+
if (matcherName.includes('.')) {
51+
// Old format: for backward compatibility,
52+
// especially without promise or isNot options
53+
dimString += matcherName
54+
}
55+
else {
56+
// New format: omit period from matcherName arg
57+
hint += DIM_COLOR(`${dimString}.`) + matcherName
58+
dimString = ''
59+
}
7160

72-
if (comment !== '') {
73-
dimString += ` // ${comment}`
61+
if (expected === '') {
62+
dimString += '()'
63+
}
64+
else {
65+
hint += DIM_COLOR(`${dimString}(`) + expectedColor(expected)
66+
if (secondArgument) {
67+
hint += DIM_COLOR(', ') + secondArgumentColor(secondArgument)
7468
}
69+
dimString = ')'
70+
}
7571

76-
if (dimString !== '') {
77-
hint += DIM_COLOR(dimString)
78-
}
72+
if (comment !== '') {
73+
dimString += ` // ${comment}`
74+
}
7975

80-
return hint
76+
if (dimString !== '') {
77+
hint += DIM_COLOR(dimString)
8178
}
8279

83-
const SPACE_SYMBOL = '\u{00B7}' // middle dot
80+
return hint
81+
}
82+
83+
const SPACE_SYMBOL = '\u{00B7}' // middle dot
8484

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

90-
const printReceived = (object: unknown): string =>
91-
RECEIVED_COLOR(replaceTrailingSpaces(stringify(object)))
92-
const printExpected = (value: unknown): string =>
93-
EXPECTED_COLOR(replaceTrailingSpaces(stringify(value)))
91+
function printReceived(object: unknown): string {
92+
return RECEIVED_COLOR(replaceTrailingSpaces(stringify(object)))
93+
}
94+
function printExpected(value: unknown): string {
95+
return EXPECTED_COLOR(replaceTrailingSpaces(stringify(value)))
96+
}
9497

98+
export function getMatcherUtils() {
9599
return {
96100
EXPECTED_COLOR,
97101
RECEIVED_COLOR,
98102
INVERTED_COLOR,
99103
BOLD_WEIGHT,
100104
DIM_COLOR,
101105

106+
diff,
102107
matcherHint,
103108
printReceived,
104109
printExpected,
110+
printDiffOrStringify,
105111
}
106112
}
107113

packages/utils/src/diff/index.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
format as prettyFormat,
1313
plugins as prettyFormatPlugins,
1414
} from '@vitest/pretty-format'
15+
import c from 'tinyrainbow'
16+
import { stringify } from '../display'
17+
import { deepClone, getOwnProperties, getType as getSimpleType } from '../helpers'
1518
import { getType } from './getType'
1619
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic'
1720
import { NO_DIFF_MESSAGE, SIMILAR_MESSAGE } from './constants'
@@ -211,3 +214,160 @@ function getObjectsDifference(
211214
)
212215
}
213216
}
217+
218+
const MAX_DIFF_STRING_LENGTH = 20_000
219+
220+
function isAsymmetricMatcher(data: any) {
221+
const type = getSimpleType(data)
222+
return type === 'Object' && typeof data.asymmetricMatch === 'function'
223+
}
224+
225+
function isReplaceable(obj1: any, obj2: any) {
226+
const obj1Type = getSimpleType(obj1)
227+
const obj2Type = getSimpleType(obj2)
228+
return (
229+
obj1Type === obj2Type && (obj1Type === 'Object' || obj1Type === 'Array')
230+
)
231+
}
232+
233+
export function printDiffOrStringify(
234+
expected: unknown,
235+
received: unknown,
236+
options?: DiffOptions,
237+
): string | null {
238+
const { aAnnotation, bAnnotation } = normalizeDiffOptions(options)
239+
240+
if (
241+
typeof expected === 'string'
242+
&& typeof received === 'string'
243+
&& expected.length > 0
244+
&& received.length > 0
245+
&& expected.length <= MAX_DIFF_STRING_LENGTH
246+
&& received.length <= MAX_DIFF_STRING_LENGTH
247+
&& expected !== received
248+
) {
249+
if (expected.includes('\n') || received.includes('\n')) {
250+
return diffStringsUnified(received, expected, options)
251+
}
252+
253+
const [diffs] = diffStringsRaw(received, expected, true)
254+
const hasCommonDiff = diffs.some(diff => diff[0] === DIFF_EQUAL)
255+
256+
const printLabel = getLabelPrinter(aAnnotation, bAnnotation)
257+
const expectedLine
258+
= printLabel(aAnnotation)
259+
+ printExpected(
260+
getCommonAndChangedSubstrings(diffs, DIFF_DELETE, hasCommonDiff),
261+
)
262+
const receivedLine
263+
= printLabel(bAnnotation)
264+
+ printReceived(
265+
getCommonAndChangedSubstrings(diffs, DIFF_INSERT, hasCommonDiff),
266+
)
267+
268+
return `${expectedLine}\n${receivedLine}`
269+
}
270+
271+
// if (isLineDiffable(expected, received)) {
272+
const clonedExpected = deepClone(expected, { forceWritable: true })
273+
const clonedReceived = deepClone(received, { forceWritable: true })
274+
const { replacedExpected, replacedActual } = replaceAsymmetricMatcher(clonedExpected, clonedReceived)
275+
const difference = diff(replacedExpected, replacedActual, options)
276+
277+
return difference
278+
// }
279+
280+
// const printLabel = getLabelPrinter(aAnnotation, bAnnotation)
281+
// const expectedLine = printLabel(aAnnotation) + printExpected(expected)
282+
// const receivedLine
283+
// = printLabel(bAnnotation)
284+
// + (stringify(expected) === stringify(received)
285+
// ? 'serializes to the same string'
286+
// : printReceived(received))
287+
288+
// return `${expectedLine}\n${receivedLine}`
289+
}
290+
291+
export function replaceAsymmetricMatcher(
292+
actual: any,
293+
expected: any,
294+
actualReplaced: WeakSet<WeakKey> = new WeakSet(),
295+
expectedReplaced: WeakSet<WeakKey> = new WeakSet(),
296+
): {
297+
replacedActual: any
298+
replacedExpected: any
299+
} {
300+
if (!isReplaceable(actual, expected)) {
301+
return { replacedActual: actual, replacedExpected: expected }
302+
}
303+
if (actualReplaced.has(actual) || expectedReplaced.has(expected)) {
304+
return { replacedActual: actual, replacedExpected: expected }
305+
}
306+
actualReplaced.add(actual)
307+
expectedReplaced.add(expected)
308+
getOwnProperties(expected).forEach((key) => {
309+
const expectedValue = expected[key]
310+
const actualValue = actual[key]
311+
if (isAsymmetricMatcher(expectedValue)) {
312+
if (expectedValue.asymmetricMatch(actualValue)) {
313+
actual[key] = expectedValue
314+
}
315+
}
316+
else if (isAsymmetricMatcher(actualValue)) {
317+
if (actualValue.asymmetricMatch(expectedValue)) {
318+
expected[key] = actualValue
319+
}
320+
}
321+
else if (isReplaceable(actualValue, expectedValue)) {
322+
const replaced = replaceAsymmetricMatcher(
323+
actualValue,
324+
expectedValue,
325+
actualReplaced,
326+
expectedReplaced,
327+
)
328+
actual[key] = replaced.replacedActual
329+
expected[key] = replaced.replacedExpected
330+
}
331+
})
332+
return {
333+
replacedActual: actual,
334+
replacedExpected: expected,
335+
}
336+
}
337+
338+
type PrintLabel = (string: string) => string
339+
export function getLabelPrinter(...strings: Array<string>): PrintLabel {
340+
const maxLength = strings.reduce(
341+
(max, string) => (string.length > max ? string.length : max),
342+
0,
343+
)
344+
return (string: string): string =>
345+
`${string}: ${' '.repeat(maxLength - string.length)}`
346+
}
347+
348+
const SPACE_SYMBOL = '\u{00B7}' // middle dot
349+
function replaceTrailingSpaces(text: string): string {
350+
return text.replace(/\s+$/gm, spaces => SPACE_SYMBOL.repeat(spaces.length))
351+
}
352+
353+
function printReceived(object: unknown): string {
354+
return c.red(replaceTrailingSpaces(stringify(object)))
355+
}
356+
function printExpected(value: unknown): string {
357+
return c.green(replaceTrailingSpaces(stringify(value)))
358+
}
359+
360+
function getCommonAndChangedSubstrings(diffs: Array<Diff>, op: number, hasCommonDiff: boolean): string {
361+
return diffs.reduce(
362+
(reduced: string, diff: Diff): string =>
363+
reduced
364+
+ (diff[0] === DIFF_EQUAL
365+
? diff[1]
366+
: diff[0] === op
367+
? hasCommonDiff
368+
? c.inverse(diff[1])
369+
: diff[1]
370+
: ''),
371+
'',
372+
)
373+
}

0 commit comments

Comments
 (0)