Skip to content

Commit 9c7ea38

Browse files
authoredJun 16, 2023
fix: revert concordance diff, use jest's diff output (#3582)
1 parent d9e1419 commit 9c7ea38

28 files changed

+1933
-310
lines changed
 

‎packages/expect/src/jest-expect.ts

+19-6
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
186186
'expected #{this} to be truthy',
187187
'expected #{this} to not be truthy',
188188
obj,
189+
false,
189190
)
190191
})
191192
def('toBeFalsy', function () {
@@ -195,6 +196,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
195196
'expected #{this} to be falsy',
196197
'expected #{this} to not be falsy',
197198
obj,
199+
false,
198200
)
199201
})
200202
def('toBeGreaterThan', function (expected: number | bigint) {
@@ -207,6 +209,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
207209
`expected ${actual} to be not greater than ${expected}`,
208210
actual,
209211
expected,
212+
false,
210213
)
211214
})
212215
def('toBeGreaterThanOrEqual', function (expected: number | bigint) {
@@ -219,6 +222,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
219222
`expected ${actual} to be not greater than or equal to ${expected}`,
220223
actual,
221224
expected,
225+
false,
222226
)
223227
})
224228
def('toBeLessThan', function (expected: number | bigint) {
@@ -231,6 +235,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
231235
`expected ${actual} to be not less than ${expected}`,
232236
actual,
233237
expected,
238+
false,
234239
)
235240
})
236241
def('toBeLessThanOrEqual', function (expected: number | bigint) {
@@ -243,6 +248,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
243248
`expected ${actual} to be not less than or equal to ${expected}`,
244249
actual,
245250
expected,
251+
false,
246252
)
247253
})
248254
def('toBeNaN', function () {
@@ -328,6 +334,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
328334
`expected #{this} to not be close to #{exp}, received difference is ${receivedDiff}, but expected ${expectedDiff}`,
329335
received,
330336
expected,
337+
false,
331338
)
332339
})
333340

@@ -356,10 +363,10 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
356363
}
357364
const formatCalls = (spy: EnhancedSpy, msg: string, actualCall?: any) => {
358365
if (spy.mock.calls) {
359-
msg += c().gray(`\n\nReceived: \n${spy.mock.calls.map((callArg, i) => {
360-
let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call:\n\n`)
366+
msg += c().gray(`\n\nReceived: \n\n${spy.mock.calls.map((callArg, i) => {
367+
let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call:\n\n`)
361368
if (actualCall)
362-
methodCall += diff(actualCall, callArg, { showLegend: false })
369+
methodCall += diff(actualCall, callArg, { omitAnnotationLines: true })
363370
else
364371
methodCall += stringify(callArg).split('\n').map(line => ` ${line}`).join('\n')
365372
@@ -371,10 +378,10 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
371378
return msg
372379
}
373380
const formatReturns = (spy: EnhancedSpy, msg: string, actualReturn?: any) => {
374-
msg += c().gray(`\n\nReceived: \n${spy.mock.results.map((callReturn, i) => {
375-
let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call return:\n\n`)
381+
msg += c().gray(`\n\nReceived: \n\n${spy.mock.results.map((callReturn, i) => {
382+
let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call return:\n\n`)
376383
if (actualReturn)
377-
methodCall += diff(actualReturn, callReturn.value, { showLegend: false })
384+
methodCall += diff(actualReturn, callReturn.value, { omitAnnotationLines: true })
378385
else
379386
methodCall += stringify(callReturn).split('\n').map(line => ` ${line}`).join('\n')
380387
@@ -394,6 +401,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
394401
`expected "${spyName}" to not be called #{exp} times`,
395402
number,
396403
callCount,
404+
false,
397405
)
398406
})
399407
def('toHaveBeenCalledOnce', function () {
@@ -406,6 +414,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
406414
`expected "${spyName}" to not be called once`,
407415
1,
408416
callCount,
417+
false,
409418
)
410419
})
411420
def(['toHaveBeenCalled', 'toBeCalled'], function () {
@@ -525,6 +534,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
525534
`expected error not to be instance of ${name}`,
526535
expected,
527536
thrown,
537+
false,
528538
)
529539
}
530540

@@ -546,6 +556,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
546556
'expected error not to match asymmetric matcher',
547557
matcher.toString(),
548558
thrown,
559+
false,
549560
)
550561
}
551562

@@ -561,6 +572,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
561572
`expected "${spyName}" to not be successfully called`,
562573
calledAndNotThrew,
563574
!calledAndNotThrew,
575+
false,
564576
)
565577
})
566578
def(['toHaveReturnedTimes', 'toReturnTimes'], function (times: number) {
@@ -573,6 +585,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
573585
`expected "${spyName}" to not be successfully called ${times} times`,
574586
`expected number of returns: ${times}`,
575587
`received number of returns: ${successfulReturns}`,
588+
false,
576589
)
577590
})
578591
def(['toHaveReturnedWith', 'toReturnWith'], function (value: any) {

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

+2-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getColors, stringify } from '@vitest/utils'
2-
import { unifiedDiff } from '@vitest/utils/diff'
3-
import type { DiffOptions, MatcherHintOptions } from './types'
2+
import type { MatcherHintOptions } from './types'
43

4+
export { diff } from '@vitest/utils/diff'
55
export { stringify }
66

77
export function getMatcherUtils() {
@@ -101,10 +101,3 @@ export function getMatcherUtils() {
101101
printExpected,
102102
}
103103
}
104-
105-
// TODO: do something with options
106-
export function diff(a: any, b: any, options?: DiffOptions) {
107-
return unifiedDiff(b, a, {
108-
showLegend: options?.showLegend,
109-
})
110-
}

‎packages/expect/src/types.ts

+2-23
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export type ChaiPlugin = FirstFunctionArgument<typeof chaiUse>
1717

1818
export type Tester = (a: any, b: any) => boolean | undefined
1919

20+
export type { DiffOptions } from '@vitest/utils/diff'
21+
2022
export interface MatcherHintOptions {
2123
comment?: string
2224
expectedColor?: Formatter
@@ -28,29 +30,6 @@ export interface MatcherHintOptions {
2830
secondArgumentColor?: Formatter
2931
}
3032

31-
export interface DiffOptions {
32-
aAnnotation?: string
33-
aColor?: Formatter
34-
aIndicator?: string
35-
bAnnotation?: string
36-
bColor?: Formatter
37-
bIndicator?: string
38-
changeColor?: Formatter
39-
changeLineTrailingSpaceColor?: Formatter
40-
commonColor?: Formatter
41-
commonIndicator?: string
42-
commonLineTrailingSpaceColor?: Formatter
43-
contextLines?: number
44-
emptyFirstOrLastLinePlaceholder?: string
45-
expand?: boolean
46-
includeChangeCounts?: boolean
47-
omitAnnotationLines?: boolean
48-
patchColor?: Formatter
49-
// pretty-format type
50-
compareKeys?: any
51-
showLegend?: boolean
52-
}
53-
5433
export interface MatcherState {
5534
assertionCalls: number
5635
currentTestName?: string

‎packages/ui/client/components/views/ViewReportError.vue

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<script setup lang="ts">
22
import type { ErrorWithDiff } from '#types'
3-
import { unifiedDiff } from '~/composables/diff'
43
import { openInEditor, shouldOpenInEditor } from '~/composables/error'
54
65
const props = defineProps<{
@@ -20,7 +19,7 @@ const isDiffShowable = computed(() => {
2019
})
2120
2221
function diff() {
23-
return unifiedDiff(props.error.actual, props.error.expected)
22+
return props.error.diff
2423
}
2524
</script>
2625

‎packages/ui/client/composables/diff.ts

-1
This file was deleted.

‎packages/utils/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"prepublishOnly": "pnpm build"
4848
},
4949
"dependencies": {
50-
"concordance": "^5.0.4",
50+
"diff-sequences": "^29.4.3",
5151
"loupe": "^2.3.6",
5252
"pretty-format": "^27.5.1"
5353
}

‎packages/utils/rollup.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import pkg from './package.json' assert { type: 'json' }
99
const entries = {
1010
index: 'src/index.ts',
1111
helpers: 'src/helpers.ts',
12-
diff: 'src/diff.ts',
12+
diff: 'src/diff/index.ts',
1313
error: 'src/error.ts',
1414
types: 'src/types.ts',
1515
}

‎packages/utils/src/descriptors.ts

-98
This file was deleted.

‎packages/utils/src/diff.ts

-51
This file was deleted.

‎packages/utils/src/diff/cleanupSemantic.ts

+561
Large diffs are not rendered by default.

‎packages/utils/src/diff/constants.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
export const NO_DIFF_MESSAGE = 'Compared values have no visual difference.'
9+
10+
export const SIMILAR_MESSAGE
11+
= 'Compared values serialize to the same structure.\n'
12+
+ 'Printing internal object structure without calling `toJSON` instead.'

‎packages/utils/src/diff/diffLines.ts

+197
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import * as diff from 'diff-sequences'
9+
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic'
10+
import {
11+
joinAlignedDiffsExpand,
12+
joinAlignedDiffsNoExpand,
13+
} from './joinAlignedDiffs'
14+
import { normalizeDiffOptions } from './normalizeDiffOptions'
15+
import type { DiffOptions, DiffOptionsNormalized } from './types'
16+
17+
function isEmptyString(lines: Array<string>) {
18+
return lines.length === 1 && lines[0].length === 0
19+
}
20+
21+
interface ChangeCounts {
22+
a: number
23+
b: number
24+
}
25+
26+
function countChanges(diffs: Array<Diff>): ChangeCounts {
27+
let a = 0
28+
let b = 0
29+
30+
diffs.forEach((diff) => {
31+
switch (diff[0]) {
32+
case DIFF_DELETE:
33+
a += 1
34+
break
35+
36+
case DIFF_INSERT:
37+
b += 1
38+
break
39+
}
40+
})
41+
42+
return { a, b }
43+
}
44+
45+
function printAnnotation({
46+
aAnnotation,
47+
aColor,
48+
aIndicator,
49+
bAnnotation,
50+
bColor,
51+
bIndicator,
52+
includeChangeCounts,
53+
omitAnnotationLines,
54+
}: DiffOptionsNormalized,
55+
changeCounts: ChangeCounts): string {
56+
if (omitAnnotationLines)
57+
return ''
58+
59+
let aRest = ''
60+
let bRest = ''
61+
62+
if (includeChangeCounts) {
63+
const aCount = String(changeCounts.a)
64+
const bCount = String(changeCounts.b)
65+
66+
// Padding right aligns the ends of the annotations.
67+
const baAnnotationLengthDiff = bAnnotation.length - aAnnotation.length
68+
const aAnnotationPadding = ' '.repeat(Math.max(0, baAnnotationLengthDiff))
69+
const bAnnotationPadding = ' '.repeat(Math.max(0, -baAnnotationLengthDiff))
70+
71+
// Padding left aligns the ends of the counts.
72+
const baCountLengthDiff = bCount.length - aCount.length
73+
const aCountPadding = ' '.repeat(Math.max(0, baCountLengthDiff))
74+
const bCountPadding = ' '.repeat(Math.max(0, -baCountLengthDiff))
75+
76+
aRest = `${aAnnotationPadding} ${aIndicator} ${aCountPadding}${aCount}`
77+
bRest = `${bAnnotationPadding} ${bIndicator} ${bCountPadding}${bCount}`
78+
}
79+
80+
const a = `${aIndicator} ${aAnnotation}${aRest}`
81+
const b = `${bIndicator} ${bAnnotation}${bRest}`
82+
return `${aColor(a)}\n${bColor(b)}\n\n`
83+
}
84+
85+
export function printDiffLines(diffs: Array<Diff>,
86+
options: DiffOptionsNormalized): string {
87+
return printAnnotation(options, countChanges(diffs))
88+
+ (options.expand
89+
? joinAlignedDiffsExpand(diffs, options)
90+
: joinAlignedDiffsNoExpand(diffs, options))
91+
}
92+
93+
// Compare two arrays of strings line-by-line. Format as comparison lines.
94+
export function diffLinesUnified(aLines: Array<string>,
95+
bLines: Array<string>,
96+
options?: DiffOptions): string {
97+
return printDiffLines(
98+
diffLinesRaw(
99+
isEmptyString(aLines) ? [] : aLines,
100+
isEmptyString(bLines) ? [] : bLines,
101+
),
102+
normalizeDiffOptions(options),
103+
)
104+
}
105+
106+
// Given two pairs of arrays of strings:
107+
// Compare the pair of comparison arrays line-by-line.
108+
// Format the corresponding lines in the pair of displayable arrays.
109+
export function diffLinesUnified2(aLinesDisplay: Array<string>,
110+
bLinesDisplay: Array<string>,
111+
aLinesCompare: Array<string>,
112+
bLinesCompare: Array<string>,
113+
options?: DiffOptions): string {
114+
if (isEmptyString(aLinesDisplay) && isEmptyString(aLinesCompare)) {
115+
aLinesDisplay = []
116+
aLinesCompare = []
117+
}
118+
if (isEmptyString(bLinesDisplay) && isEmptyString(bLinesCompare)) {
119+
bLinesDisplay = []
120+
bLinesCompare = []
121+
}
122+
123+
if (
124+
aLinesDisplay.length !== aLinesCompare.length
125+
|| bLinesDisplay.length !== bLinesCompare.length
126+
) {
127+
// Fall back to diff of display lines.
128+
return diffLinesUnified(aLinesDisplay, bLinesDisplay, options)
129+
}
130+
131+
const diffs = diffLinesRaw(aLinesCompare, bLinesCompare)
132+
133+
// Replace comparison lines with displayable lines.
134+
let aIndex = 0
135+
let bIndex = 0
136+
diffs.forEach((diff: Diff) => {
137+
switch (diff[0]) {
138+
case DIFF_DELETE:
139+
diff[1] = aLinesDisplay[aIndex]
140+
aIndex += 1
141+
break
142+
143+
case DIFF_INSERT:
144+
diff[1] = bLinesDisplay[bIndex]
145+
bIndex += 1
146+
break
147+
148+
default:
149+
diff[1] = bLinesDisplay[bIndex]
150+
aIndex += 1
151+
bIndex += 1
152+
}
153+
})
154+
155+
return printDiffLines(diffs, normalizeDiffOptions(options))
156+
}
157+
158+
// Compare two arrays of strings line-by-line.
159+
export function diffLinesRaw(aLines: Array<string>,
160+
bLines: Array<string>): Array<Diff> {
161+
const aLength = aLines.length
162+
const bLength = bLines.length
163+
164+
const isCommon = (aIndex: number, bIndex: number) =>
165+
aLines[aIndex] === bLines[bIndex]
166+
167+
const diffs: Array<Diff> = []
168+
let aIndex = 0
169+
let bIndex = 0
170+
171+
const foundSubsequence = (
172+
nCommon: number,
173+
aCommon: number,
174+
bCommon: number,
175+
) => {
176+
for (; aIndex !== aCommon; aIndex += 1)
177+
diffs.push(new Diff(DIFF_DELETE, aLines[aIndex]))
178+
179+
for (; bIndex !== bCommon; bIndex += 1)
180+
diffs.push(new Diff(DIFF_INSERT, bLines[bIndex]))
181+
182+
for (; nCommon !== 0; nCommon -= 1, aIndex += 1, bIndex += 1)
183+
diffs.push(new Diff(DIFF_EQUAL, bLines[bIndex]))
184+
}
185+
186+
// @ts-expect-error wrong bundling
187+
diff.default.default(aLength, bLength, isCommon, foundSubsequence)
188+
189+
// After the last common subsequence, push remaining change items.
190+
for (; aIndex !== aLength; aIndex += 1)
191+
diffs.push(new Diff(DIFF_DELETE, aLines[aIndex]))
192+
193+
for (; bIndex !== bLength; bIndex += 1)
194+
diffs.push(new Diff(DIFF_INSERT, bLines[bIndex]))
195+
196+
return diffs
197+
}
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import * as diffSequences from 'diff-sequences'
9+
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic'
10+
11+
function diffStrings(a: string, b: string): Array<Diff> {
12+
const isCommon = (aIndex: number, bIndex: number) => a[aIndex] === b[bIndex]
13+
14+
let aIndex = 0
15+
let bIndex = 0
16+
const diffs: Array<Diff> = []
17+
18+
const foundSubsequence = (
19+
nCommon: number,
20+
aCommon: number,
21+
bCommon: number,
22+
) => {
23+
if (aIndex !== aCommon)
24+
diffs.push(new Diff(DIFF_DELETE, a.slice(aIndex, aCommon)))
25+
26+
if (bIndex !== bCommon)
27+
diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex, bCommon)))
28+
29+
aIndex = aCommon + nCommon // number of characters compared in a
30+
bIndex = bCommon + nCommon // number of characters compared in b
31+
diffs.push(new Diff(DIFF_EQUAL, b.slice(bCommon, bIndex)))
32+
}
33+
34+
// @ts-expect-error wrong bundling
35+
diffSequences.default.default(a.length, b.length, isCommon, foundSubsequence)
36+
37+
// After the last common subsequence, push remaining change items.
38+
if (aIndex !== a.length)
39+
diffs.push(new Diff(DIFF_DELETE, a.slice(aIndex)))
40+
41+
if (bIndex !== b.length)
42+
diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex)))
43+
44+
return diffs
45+
}
46+
47+
export default diffStrings
+235
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic'
9+
import type { DiffOptionsColor } from './types'
10+
11+
// Given change op and array of diffs, return concatenated string:
12+
// * include common strings
13+
// * include change strings which have argument op with changeColor
14+
// * exclude change strings which have opposite op
15+
function concatenateRelevantDiffs(op: number,
16+
diffs: Array<Diff>,
17+
changeColor: DiffOptionsColor): string {
18+
return diffs.reduce(
19+
(reduced: string, diff: Diff): string =>
20+
reduced
21+
+ (diff[0] === DIFF_EQUAL
22+
? diff[1]
23+
: (diff[0] === op && diff[1].length !== 0) // empty if change is newline
24+
? changeColor(diff[1])
25+
: ''),
26+
'',
27+
)
28+
}
29+
30+
// Encapsulate change lines until either a common newline or the end.
31+
class ChangeBuffer {
32+
private readonly op: number
33+
private line: Array<Diff> // incomplete line
34+
private lines: Array<Diff> // complete lines
35+
private readonly changeColor: DiffOptionsColor
36+
37+
constructor(op: number, changeColor: DiffOptionsColor) {
38+
this.op = op
39+
this.line = []
40+
this.lines = []
41+
this.changeColor = changeColor
42+
}
43+
44+
private pushSubstring(substring: string): void {
45+
this.pushDiff(new Diff(this.op, substring))
46+
}
47+
48+
private pushLine(): void {
49+
// Assume call only if line has at least one diff,
50+
// therefore an empty line must have a diff which has an empty string.
51+
52+
// If line has multiple diffs, then assume it has a common diff,
53+
// therefore change diffs have change color;
54+
// otherwise then it has line color only.
55+
this.lines.push(
56+
this.line.length !== 1
57+
? new Diff(
58+
this.op,
59+
concatenateRelevantDiffs(this.op, this.line, this.changeColor),
60+
)
61+
: this.line[0][0] === this.op
62+
? this.line[0] // can use instance
63+
: new Diff(this.op, this.line[0][1]), // was common diff
64+
)
65+
this.line.length = 0
66+
}
67+
68+
isLineEmpty() {
69+
return this.line.length === 0
70+
}
71+
72+
// Minor input to buffer.
73+
pushDiff(diff: Diff): void {
74+
this.line.push(diff)
75+
}
76+
77+
// Main input to buffer.
78+
align(diff: Diff): void {
79+
const string = diff[1]
80+
81+
if (string.includes('\n')) {
82+
const substrings = string.split('\n')
83+
const iLast = substrings.length - 1
84+
substrings.forEach((substring, i) => {
85+
if (i < iLast) {
86+
// The first substring completes the current change line.
87+
// A middle substring is a change line.
88+
this.pushSubstring(substring)
89+
this.pushLine()
90+
}
91+
else if (substring.length !== 0) {
92+
// The last substring starts a change line, if it is not empty.
93+
// Important: This non-empty condition also automatically omits
94+
// the newline appended to the end of expected and received strings.
95+
this.pushSubstring(substring)
96+
}
97+
})
98+
}
99+
else {
100+
// Append non-multiline string to current change line.
101+
this.pushDiff(diff)
102+
}
103+
}
104+
105+
// Output from buffer.
106+
moveLinesTo(lines: Array<Diff>): void {
107+
if (!this.isLineEmpty())
108+
this.pushLine()
109+
110+
lines.push(...this.lines)
111+
this.lines.length = 0
112+
}
113+
}
114+
115+
// Encapsulate common and change lines.
116+
class CommonBuffer {
117+
private readonly deleteBuffer: ChangeBuffer
118+
private readonly insertBuffer: ChangeBuffer
119+
private readonly lines: Array<Diff>
120+
121+
constructor(deleteBuffer: ChangeBuffer, insertBuffer: ChangeBuffer) {
122+
this.deleteBuffer = deleteBuffer
123+
this.insertBuffer = insertBuffer
124+
this.lines = []
125+
}
126+
127+
private pushDiffCommonLine(diff: Diff): void {
128+
this.lines.push(diff)
129+
}
130+
131+
private pushDiffChangeLines(diff: Diff): void {
132+
const isDiffEmpty = diff[1].length === 0
133+
134+
// An empty diff string is redundant, unless a change line is empty.
135+
if (!isDiffEmpty || this.deleteBuffer.isLineEmpty())
136+
this.deleteBuffer.pushDiff(diff)
137+
138+
if (!isDiffEmpty || this.insertBuffer.isLineEmpty())
139+
this.insertBuffer.pushDiff(diff)
140+
}
141+
142+
private flushChangeLines(): void {
143+
this.deleteBuffer.moveLinesTo(this.lines)
144+
this.insertBuffer.moveLinesTo(this.lines)
145+
}
146+
147+
// Input to buffer.
148+
align(diff: Diff): void {
149+
const op = diff[0]
150+
const string = diff[1]
151+
152+
if (string.includes('\n')) {
153+
const substrings = string.split('\n')
154+
const iLast = substrings.length - 1
155+
substrings.forEach((substring, i) => {
156+
if (i === 0) {
157+
const subdiff = new Diff(op, substring)
158+
if (
159+
this.deleteBuffer.isLineEmpty()
160+
&& this.insertBuffer.isLineEmpty()
161+
) {
162+
// If both current change lines are empty,
163+
// then the first substring is a common line.
164+
this.flushChangeLines()
165+
this.pushDiffCommonLine(subdiff)
166+
}
167+
else {
168+
// If either current change line is non-empty,
169+
// then the first substring completes the change lines.
170+
this.pushDiffChangeLines(subdiff)
171+
this.flushChangeLines()
172+
}
173+
}
174+
else if (i < iLast) {
175+
// A middle substring is a common line.
176+
this.pushDiffCommonLine(new Diff(op, substring))
177+
}
178+
else if (substring.length !== 0) {
179+
// The last substring starts a change line, if it is not empty.
180+
// Important: This non-empty condition also automatically omits
181+
// the newline appended to the end of expected and received strings.
182+
this.pushDiffChangeLines(new Diff(op, substring))
183+
}
184+
})
185+
}
186+
else {
187+
// Append non-multiline string to current change lines.
188+
// Important: It cannot be at the end following empty change lines,
189+
// because newline appended to the end of expected and received strings.
190+
this.pushDiffChangeLines(diff)
191+
}
192+
}
193+
194+
// Output from buffer.
195+
getLines(): Array<Diff> {
196+
this.flushChangeLines()
197+
return this.lines
198+
}
199+
}
200+
201+
// Given diffs from expected and received strings,
202+
// return new array of diffs split or joined into lines.
203+
//
204+
// To correctly align a change line at the end, the algorithm:
205+
// * assumes that a newline was appended to the strings
206+
// * omits the last newline from the output array
207+
//
208+
// Assume the function is not called:
209+
// * if either expected or received is empty string
210+
// * if neither expected nor received is multiline string
211+
function getAlignedDiffs(diffs: Array<Diff>,
212+
changeColor: DiffOptionsColor): Array<Diff> {
213+
const deleteBuffer = new ChangeBuffer(DIFF_DELETE, changeColor)
214+
const insertBuffer = new ChangeBuffer(DIFF_INSERT, changeColor)
215+
const commonBuffer = new CommonBuffer(deleteBuffer, insertBuffer)
216+
217+
diffs.forEach((diff) => {
218+
switch (diff[0]) {
219+
case DIFF_DELETE:
220+
deleteBuffer.align(diff)
221+
break
222+
223+
case DIFF_INSERT:
224+
insertBuffer.align(diff)
225+
break
226+
227+
default:
228+
commonBuffer.align(diff)
229+
}
230+
})
231+
232+
return commonBuffer.getLines()
233+
}
234+
235+
export default getAlignedDiffs

‎packages/utils/src/diff/getType.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
type ValueType =
2+
| 'array'
3+
| 'bigint'
4+
| 'boolean'
5+
| 'function'
6+
| 'null'
7+
| 'number'
8+
| 'object'
9+
| 'regexp'
10+
| 'map'
11+
| 'set'
12+
| 'date'
13+
| 'string'
14+
| 'symbol'
15+
| 'undefined'
16+
17+
// get the type of a value with handling the edge cases like `typeof []`
18+
// and `typeof null`
19+
export function getType(value: unknown): ValueType {
20+
if (value === undefined) {
21+
return 'undefined'
22+
}
23+
else if (value === null) {
24+
return 'null'
25+
}
26+
else if (Array.isArray(value)) {
27+
return 'array'
28+
}
29+
else if (typeof value === 'boolean') {
30+
return 'boolean'
31+
}
32+
else if (typeof value === 'function') {
33+
return 'function'
34+
}
35+
else if (typeof value === 'number') {
36+
return 'number'
37+
}
38+
else if (typeof value === 'string') {
39+
return 'string'
40+
}
41+
else if (typeof value === 'bigint') {
42+
return 'bigint'
43+
}
44+
else if (typeof value === 'object') {
45+
if (value != null) {
46+
if (value.constructor === RegExp)
47+
return 'regexp'
48+
49+
else if (value.constructor === Map)
50+
return 'map'
51+
52+
else if (value.constructor === Set)
53+
return 'set'
54+
55+
else if (value.constructor === Date)
56+
return 'date'
57+
}
58+
return 'object'
59+
}
60+
else if (typeof value === 'symbol') {
61+
return 'symbol'
62+
}
63+
64+
throw new Error(`value of unknown type: ${value}`)
65+
}
66+
67+
export const isPrimitive = (value: unknown): boolean => Object(value) !== value

‎packages/utils/src/diff/index.ts

+204
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
// This is a fork of Jest's jest-diff package, but it doesn't depend on Node environment (like chalk).
9+
10+
import type { PrettyFormatOptions } from 'pretty-format'
11+
import {
12+
format as prettyFormat,
13+
plugins as prettyFormatPlugins,
14+
} from 'pretty-format'
15+
import { getType } from './getType'
16+
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic'
17+
import { NO_DIFF_MESSAGE, SIMILAR_MESSAGE } from './constants'
18+
import { diffLinesRaw, diffLinesUnified, diffLinesUnified2 } from './diffLines'
19+
import { normalizeDiffOptions } from './normalizeDiffOptions'
20+
import { diffStringsRaw, diffStringsUnified } from './printDiffs'
21+
import type { DiffOptions } from './types'
22+
23+
export type { DiffOptions, DiffOptionsColor } from './types'
24+
25+
export { diffLinesRaw, diffLinesUnified, diffLinesUnified2 }
26+
export { diffStringsRaw, diffStringsUnified }
27+
export { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff }
28+
29+
function getCommonMessage(message: string, options?: DiffOptions) {
30+
const { commonColor } = normalizeDiffOptions(options)
31+
return commonColor(message)
32+
}
33+
34+
const {
35+
AsymmetricMatcher,
36+
DOMCollection,
37+
DOMElement,
38+
Immutable,
39+
ReactElement,
40+
ReactTestComponent,
41+
} = prettyFormatPlugins
42+
43+
const PLUGINS = [
44+
ReactTestComponent,
45+
ReactElement,
46+
DOMElement,
47+
DOMCollection,
48+
Immutable,
49+
AsymmetricMatcher,
50+
]
51+
const FORMAT_OPTIONS = {
52+
plugins: PLUGINS,
53+
}
54+
const FALLBACK_FORMAT_OPTIONS = {
55+
callToJSON: false,
56+
maxDepth: 10,
57+
plugins: PLUGINS,
58+
}
59+
60+
// Generate a string that will highlight the difference between two values
61+
// with green and red. (similar to how github does code diffing)
62+
63+
export function diff(a: any, b: any, options?: DiffOptions): string | null {
64+
if (Object.is(a, b))
65+
return ''
66+
67+
const aType = getType(a)
68+
let expectedType = aType
69+
let omitDifference = false
70+
if (aType === 'object' && typeof a.asymmetricMatch === 'function') {
71+
if (a.$$typeof !== Symbol.for('jest.asymmetricMatcher')) {
72+
// Do not know expected type of user-defined asymmetric matcher.
73+
return null
74+
}
75+
if (typeof a.getExpectedType !== 'function') {
76+
// For example, expect.anything() matches either null or undefined
77+
return null
78+
}
79+
expectedType = a.getExpectedType()
80+
// Primitive types boolean and number omit difference below.
81+
// For example, omit difference for expect.stringMatching(regexp)
82+
omitDifference = expectedType === 'string'
83+
}
84+
85+
if (expectedType !== getType(b)) {
86+
const { aAnnotation, aColor, aIndicator, bAnnotation, bColor, bIndicator } = normalizeDiffOptions(options)
87+
const formatOptions = getFormatOptions(FALLBACK_FORMAT_OPTIONS, options)
88+
const aDisplay = prettyFormat(a, formatOptions)
89+
const bDisplay = prettyFormat(b, formatOptions)
90+
const aDiff = `${aColor(`${aIndicator} ${aAnnotation}:`)} \n${aDisplay}`
91+
const bDiff = `${bColor(`${bIndicator} ${bAnnotation}:`)} \n${bDisplay}`
92+
return `${aDiff}\n\n${bDiff}`
93+
}
94+
95+
if (omitDifference)
96+
return null
97+
98+
switch (aType) {
99+
case 'string':
100+
return diffLinesUnified(a.split('\n'), b.split('\n'), options)
101+
case 'boolean':
102+
case 'number':
103+
return comparePrimitive(a, b, options)
104+
case 'map':
105+
return compareObjects(sortMap(a), sortMap(b), options)
106+
case 'set':
107+
return compareObjects(sortSet(a), sortSet(b), options)
108+
default:
109+
return compareObjects(a, b, options)
110+
}
111+
}
112+
113+
function comparePrimitive(
114+
a: number | boolean,
115+
b: number | boolean,
116+
options?: DiffOptions,
117+
) {
118+
const aFormat = prettyFormat(a, FORMAT_OPTIONS)
119+
const bFormat = prettyFormat(b, FORMAT_OPTIONS)
120+
return aFormat === bFormat
121+
? ''
122+
: diffLinesUnified(aFormat.split('\n'), bFormat.split('\n'), options)
123+
}
124+
125+
function sortMap(map: Map<unknown, unknown>) {
126+
return new Map(Array.from(map.entries()).sort())
127+
}
128+
129+
function sortSet(set: Set<unknown>) {
130+
return new Set(Array.from(set.values()).sort())
131+
}
132+
133+
function compareObjects(
134+
a: Record<string, any>,
135+
b: Record<string, any>,
136+
options?: DiffOptions,
137+
) {
138+
let difference
139+
let hasThrown = false
140+
141+
try {
142+
const formatOptions = getFormatOptions(FORMAT_OPTIONS, options)
143+
difference = getObjectsDifference(a, b, formatOptions, options)
144+
}
145+
catch {
146+
hasThrown = true
147+
}
148+
149+
const noDiffMessage = getCommonMessage(NO_DIFF_MESSAGE, options)
150+
// If the comparison yields no results, compare again but this time
151+
// without calling `toJSON`. It's also possible that toJSON might throw.
152+
if (difference === undefined || difference === noDiffMessage) {
153+
const formatOptions = getFormatOptions(FALLBACK_FORMAT_OPTIONS, options)
154+
difference = getObjectsDifference(a, b, formatOptions, options)
155+
156+
if (difference !== noDiffMessage && !hasThrown) {
157+
difference = `${getCommonMessage(
158+
SIMILAR_MESSAGE,
159+
options,
160+
)}\n\n${difference}`
161+
}
162+
}
163+
164+
return difference
165+
}
166+
167+
function getFormatOptions(
168+
formatOptions: PrettyFormatOptions,
169+
options?: DiffOptions,
170+
): PrettyFormatOptions {
171+
const { compareKeys } = normalizeDiffOptions(options)
172+
173+
return {
174+
...formatOptions,
175+
compareKeys,
176+
}
177+
}
178+
179+
function getObjectsDifference(
180+
a: Record<string, any>,
181+
b: Record<string, any>,
182+
formatOptions: PrettyFormatOptions,
183+
options?: DiffOptions,
184+
): string {
185+
const formatOptionsZeroIndent = { ...formatOptions, indent: 0 }
186+
const aCompare = prettyFormat(a, formatOptionsZeroIndent)
187+
const bCompare = prettyFormat(b, formatOptionsZeroIndent)
188+
189+
if (aCompare === bCompare) {
190+
return getCommonMessage(NO_DIFF_MESSAGE, options)
191+
}
192+
else {
193+
const aDisplay = prettyFormat(a, formatOptions)
194+
const bDisplay = prettyFormat(b, formatOptions)
195+
196+
return diffLinesUnified2(
197+
aDisplay.split('\n'),
198+
bDisplay.split('\n'),
199+
aCompare.split('\n'),
200+
bCompare.split('\n'),
201+
options,
202+
)
203+
}
204+
}
+292
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import type { Diff } from './cleanupSemantic'
9+
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from './cleanupSemantic'
10+
import type { DiffOptionsColor, DiffOptionsNormalized } from './types'
11+
12+
function formatTrailingSpaces(line: string,
13+
trailingSpaceFormatter: DiffOptionsColor): string {
14+
return line.replace(/\s+$/, match => trailingSpaceFormatter(match))
15+
}
16+
17+
function printDiffLine(line: string,
18+
isFirstOrLast: boolean,
19+
color: DiffOptionsColor,
20+
indicator: string,
21+
trailingSpaceFormatter: DiffOptionsColor,
22+
emptyFirstOrLastLinePlaceholder: string): string {
23+
return line.length !== 0
24+
? color(
25+
`${indicator} ${formatTrailingSpaces(line, trailingSpaceFormatter)}`,
26+
)
27+
: indicator !== ' '
28+
? color(indicator)
29+
: (isFirstOrLast && emptyFirstOrLastLinePlaceholder.length !== 0)
30+
? color(`${indicator} ${emptyFirstOrLastLinePlaceholder}`)
31+
: ''
32+
}
33+
34+
function printDeleteLine(line: string,
35+
isFirstOrLast: boolean,
36+
{
37+
aColor,
38+
aIndicator,
39+
changeLineTrailingSpaceColor,
40+
emptyFirstOrLastLinePlaceholder,
41+
}: DiffOptionsNormalized): string {
42+
return printDiffLine(
43+
line,
44+
isFirstOrLast,
45+
aColor,
46+
aIndicator,
47+
changeLineTrailingSpaceColor,
48+
emptyFirstOrLastLinePlaceholder,
49+
)
50+
}
51+
52+
function printInsertLine(line: string,
53+
isFirstOrLast: boolean,
54+
{
55+
bColor,
56+
bIndicator,
57+
changeLineTrailingSpaceColor,
58+
emptyFirstOrLastLinePlaceholder,
59+
}: DiffOptionsNormalized): string {
60+
return printDiffLine(
61+
line,
62+
isFirstOrLast,
63+
bColor,
64+
bIndicator,
65+
changeLineTrailingSpaceColor,
66+
emptyFirstOrLastLinePlaceholder,
67+
)
68+
}
69+
70+
function printCommonLine(line: string,
71+
isFirstOrLast: boolean,
72+
{
73+
commonColor,
74+
commonIndicator,
75+
commonLineTrailingSpaceColor,
76+
emptyFirstOrLastLinePlaceholder,
77+
}: DiffOptionsNormalized): string {
78+
return printDiffLine(
79+
line,
80+
isFirstOrLast,
81+
commonColor,
82+
commonIndicator,
83+
commonLineTrailingSpaceColor,
84+
emptyFirstOrLastLinePlaceholder,
85+
)
86+
}
87+
88+
// In GNU diff format, indexes are one-based instead of zero-based.
89+
function createPatchMark(aStart: number,
90+
aEnd: number,
91+
bStart: number,
92+
bEnd: number,
93+
{ patchColor }: DiffOptionsNormalized): string {
94+
return patchColor(
95+
`@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`,
96+
)
97+
}
98+
99+
// jest --no-expand
100+
//
101+
// Given array of aligned strings with inverse highlight formatting,
102+
// return joined lines with diff formatting (and patch marks, if needed).
103+
export function joinAlignedDiffsNoExpand(diffs: Array<Diff>,
104+
options: DiffOptionsNormalized): string {
105+
const iLength = diffs.length
106+
const nContextLines = options.contextLines
107+
const nContextLines2 = nContextLines + nContextLines
108+
109+
// First pass: count output lines and see if it has patches.
110+
let jLength = iLength
111+
let hasExcessAtStartOrEnd = false
112+
let nExcessesBetweenChanges = 0
113+
let i = 0
114+
while (i !== iLength) {
115+
const iStart = i
116+
while (i !== iLength && diffs[i][0] === DIFF_EQUAL)
117+
i += 1
118+
119+
if (iStart !== i) {
120+
if (iStart === 0) {
121+
// at start
122+
if (i > nContextLines) {
123+
jLength -= i - nContextLines // subtract excess common lines
124+
hasExcessAtStartOrEnd = true
125+
}
126+
}
127+
else if (i === iLength) {
128+
// at end
129+
const n = i - iStart
130+
if (n > nContextLines) {
131+
jLength -= n - nContextLines // subtract excess common lines
132+
hasExcessAtStartOrEnd = true
133+
}
134+
}
135+
else {
136+
// between changes
137+
const n = i - iStart
138+
if (n > nContextLines2) {
139+
jLength -= n - nContextLines2 // subtract excess common lines
140+
nExcessesBetweenChanges += 1
141+
}
142+
}
143+
}
144+
145+
while (i !== iLength && diffs[i][0] !== DIFF_EQUAL)
146+
i += 1
147+
}
148+
149+
const hasPatch = nExcessesBetweenChanges !== 0 || hasExcessAtStartOrEnd
150+
if (nExcessesBetweenChanges !== 0)
151+
jLength += nExcessesBetweenChanges + 1 // add patch lines
152+
else if (hasExcessAtStartOrEnd)
153+
jLength += 1 // add patch line
154+
155+
const jLast = jLength - 1
156+
157+
const lines: Array<string> = []
158+
159+
let jPatchMark = 0 // index of placeholder line for current patch mark
160+
if (hasPatch)
161+
lines.push('') // placeholder line for first patch mark
162+
163+
// Indexes of expected or received lines in current patch:
164+
let aStart = 0
165+
let bStart = 0
166+
let aEnd = 0
167+
let bEnd = 0
168+
169+
const pushCommonLine = (line: string): void => {
170+
const j = lines.length
171+
lines.push(printCommonLine(line, j === 0 || j === jLast, options))
172+
aEnd += 1
173+
bEnd += 1
174+
}
175+
176+
const pushDeleteLine = (line: string): void => {
177+
const j = lines.length
178+
lines.push(printDeleteLine(line, j === 0 || j === jLast, options))
179+
aEnd += 1
180+
}
181+
182+
const pushInsertLine = (line: string): void => {
183+
const j = lines.length
184+
lines.push(printInsertLine(line, j === 0 || j === jLast, options))
185+
bEnd += 1
186+
}
187+
188+
// Second pass: push lines with diff formatting (and patch marks, if needed).
189+
i = 0
190+
while (i !== iLength) {
191+
let iStart = i
192+
while (i !== iLength && diffs[i][0] === DIFF_EQUAL)
193+
i += 1
194+
195+
if (iStart !== i) {
196+
if (iStart === 0) {
197+
// at beginning
198+
if (i > nContextLines) {
199+
iStart = i - nContextLines
200+
aStart = iStart
201+
bStart = iStart
202+
aEnd = aStart
203+
bEnd = bStart
204+
}
205+
206+
for (let iCommon = iStart; iCommon !== i; iCommon += 1)
207+
pushCommonLine(diffs[iCommon][1])
208+
}
209+
else if (i === iLength) {
210+
// at end
211+
const iEnd = i - iStart > nContextLines ? iStart + nContextLines : i
212+
213+
for (let iCommon = iStart; iCommon !== iEnd; iCommon += 1)
214+
pushCommonLine(diffs[iCommon][1])
215+
}
216+
else {
217+
// between changes
218+
const nCommon = i - iStart
219+
220+
if (nCommon > nContextLines2) {
221+
const iEnd = iStart + nContextLines
222+
223+
for (let iCommon = iStart; iCommon !== iEnd; iCommon += 1)
224+
pushCommonLine(diffs[iCommon][1])
225+
226+
lines[jPatchMark] = createPatchMark(
227+
aStart,
228+
aEnd,
229+
bStart,
230+
bEnd,
231+
options,
232+
)
233+
jPatchMark = lines.length
234+
lines.push('') // placeholder line for next patch mark
235+
236+
const nOmit = nCommon - nContextLines2
237+
aStart = aEnd + nOmit
238+
bStart = bEnd + nOmit
239+
aEnd = aStart
240+
bEnd = bStart
241+
242+
for (let iCommon = i - nContextLines; iCommon !== i; iCommon += 1)
243+
pushCommonLine(diffs[iCommon][1])
244+
}
245+
else {
246+
for (let iCommon = iStart; iCommon !== i; iCommon += 1)
247+
pushCommonLine(diffs[iCommon][1])
248+
}
249+
}
250+
}
251+
252+
while (i !== iLength && diffs[i][0] === DIFF_DELETE) {
253+
pushDeleteLine(diffs[i][1])
254+
i += 1
255+
}
256+
257+
while (i !== iLength && diffs[i][0] === DIFF_INSERT) {
258+
pushInsertLine(diffs[i][1])
259+
i += 1
260+
}
261+
}
262+
263+
if (hasPatch)
264+
lines[jPatchMark] = createPatchMark(aStart, aEnd, bStart, bEnd, options)
265+
266+
return lines.join('\n')
267+
}
268+
269+
// jest --expand
270+
//
271+
// Given array of aligned strings with inverse highlight formatting,
272+
// return joined lines with diff formatting.
273+
export function joinAlignedDiffsExpand(diffs: Array<Diff>,
274+
options: DiffOptionsNormalized): string {
275+
return diffs
276+
.map((diff: Diff, i: number, diffs: Array<Diff>): string => {
277+
const line = diff[1]
278+
const isFirstOrLast = i === 0 || i === diffs.length - 1
279+
280+
switch (diff[0]) {
281+
case DIFF_DELETE:
282+
return printDeleteLine(line, isFirstOrLast, options)
283+
284+
case DIFF_INSERT:
285+
return printInsertLine(line, isFirstOrLast, options)
286+
287+
default:
288+
return printCommonLine(line, isFirstOrLast, options)
289+
}
290+
})
291+
.join('\n')
292+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import type { CompareKeys } from 'pretty-format'
9+
import { getColors } from '../colors'
10+
import type { DiffOptions, DiffOptionsNormalized } from './types'
11+
12+
export const noColor = (string: string): string => string
13+
14+
const DIFF_CONTEXT_DEFAULT = 5
15+
16+
function getDefaultOptions(): DiffOptionsNormalized {
17+
const c = getColors()
18+
19+
return {
20+
aAnnotation: 'Expected',
21+
aColor: c.green,
22+
aIndicator: '-',
23+
bAnnotation: 'Received',
24+
bColor: c.red,
25+
bIndicator: '+',
26+
changeColor: c.inverse,
27+
changeLineTrailingSpaceColor: noColor,
28+
commonColor: c.dim,
29+
commonIndicator: ' ',
30+
commonLineTrailingSpaceColor: noColor,
31+
compareKeys: undefined,
32+
contextLines: DIFF_CONTEXT_DEFAULT,
33+
emptyFirstOrLastLinePlaceholder: '',
34+
expand: true,
35+
includeChangeCounts: false,
36+
omitAnnotationLines: false,
37+
patchColor: c.yellow,
38+
}
39+
}
40+
41+
function getCompareKeys(compareKeys?: CompareKeys): CompareKeys {
42+
return (compareKeys && typeof compareKeys === 'function')
43+
? compareKeys
44+
: undefined
45+
}
46+
47+
function getContextLines(contextLines?: number): number {
48+
return (typeof contextLines === 'number'
49+
&& Number.isSafeInteger(contextLines)
50+
&& contextLines >= 0)
51+
? contextLines
52+
: DIFF_CONTEXT_DEFAULT
53+
}
54+
55+
// Pure function returns options with all properties.
56+
export function normalizeDiffOptions(options: DiffOptions = {}): DiffOptionsNormalized {
57+
return {
58+
...getDefaultOptions(),
59+
...options,
60+
compareKeys: getCompareKeys(options.compareKeys),
61+
contextLines: getContextLines(options.contextLines),
62+
}
63+
}

‎packages/utils/src/diff/printDiffs.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import type { Diff } from './cleanupSemantic'
9+
import { DIFF_EQUAL, cleanupSemantic } from './cleanupSemantic'
10+
import { diffLinesUnified, printDiffLines } from './diffLines'
11+
import diffStrings from './diffStrings'
12+
import getAlignedDiffs from './getAlignedDiffs'
13+
import { normalizeDiffOptions } from './normalizeDiffOptions'
14+
import type { DiffOptions } from './types'
15+
16+
function hasCommonDiff(diffs: Array<Diff>, isMultiline: boolean): boolean {
17+
if (isMultiline) {
18+
// Important: Ignore common newline that was appended to multiline strings!
19+
const iLast = diffs.length - 1
20+
return diffs.some(
21+
(diff, i) => diff[0] === DIFF_EQUAL && (i !== iLast || diff[1] !== '\n'),
22+
)
23+
}
24+
25+
return diffs.some(diff => diff[0] === DIFF_EQUAL)
26+
}
27+
28+
// Compare two strings character-by-character.
29+
// Format as comparison lines in which changed substrings have inverse colors.
30+
export function diffStringsUnified(a: string,
31+
b: string,
32+
options?: DiffOptions): string {
33+
if (a !== b && a.length !== 0 && b.length !== 0) {
34+
const isMultiline = a.includes('\n') || b.includes('\n')
35+
36+
// getAlignedDiffs assumes that a newline was appended to the strings.
37+
const diffs = diffStringsRaw(
38+
isMultiline ? `${a}\n` : a,
39+
isMultiline ? `${b}\n` : b,
40+
true, // cleanupSemantic
41+
)
42+
43+
if (hasCommonDiff(diffs, isMultiline)) {
44+
const optionsNormalized = normalizeDiffOptions(options)
45+
const lines = getAlignedDiffs(diffs, optionsNormalized.changeColor)
46+
return printDiffLines(lines, optionsNormalized)
47+
}
48+
}
49+
50+
// Fall back to line-by-line diff.
51+
return diffLinesUnified(a.split('\n'), b.split('\n'), options)
52+
}
53+
54+
// Compare two strings character-by-character.
55+
// Optionally clean up small common substrings, also known as chaff.
56+
export function diffStringsRaw(a: string,
57+
b: string,
58+
cleanup: boolean): Array<Diff> {
59+
const diffs = diffStrings(a, b)
60+
61+
if (cleanup)
62+
cleanupSemantic(diffs) // impure function
63+
64+
return diffs
65+
}

‎packages/utils/src/diff/types.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
import type { CompareKeys } from 'pretty-format'
8+
9+
export type DiffOptionsColor = (arg: string) => string // subset of picocolors type
10+
11+
export interface DiffOptions {
12+
aAnnotation?: string
13+
aColor?: DiffOptionsColor
14+
aIndicator?: string
15+
bAnnotation?: string
16+
bColor?: DiffOptionsColor
17+
bIndicator?: string
18+
changeColor?: DiffOptionsColor
19+
changeLineTrailingSpaceColor?: DiffOptionsColor
20+
commonColor?: DiffOptionsColor
21+
commonIndicator?: string
22+
commonLineTrailingSpaceColor?: DiffOptionsColor
23+
contextLines?: number
24+
emptyFirstOrLastLinePlaceholder?: string
25+
expand?: boolean
26+
includeChangeCounts?: boolean
27+
omitAnnotationLines?: boolean
28+
patchColor?: DiffOptionsColor
29+
compareKeys?: CompareKeys
30+
}
31+
32+
export interface DiffOptionsNormalized {
33+
aAnnotation: string
34+
aColor: DiffOptionsColor
35+
aIndicator: string
36+
bAnnotation: string
37+
bColor: DiffOptionsColor
38+
bIndicator: string
39+
changeColor: DiffOptionsColor
40+
changeLineTrailingSpaceColor: DiffOptionsColor
41+
commonColor: DiffOptionsColor
42+
commonIndicator: string
43+
commonLineTrailingSpaceColor: DiffOptionsColor
44+
compareKeys: CompareKeys
45+
contextLines: number
46+
emptyFirstOrLastLinePlaceholder: string
47+
expand: boolean
48+
includeChangeCounts: boolean
49+
omitAnnotationLines: boolean
50+
patchColor: DiffOptionsColor
51+
}

‎packages/utils/src/error.ts

+4-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import type { DiffOptions } from './diff'
2-
import { unifiedDiff } from './diff'
1+
import { diff } from './diff'
32
import { format } from './display'
4-
import { deepClone, getOwnProperties, getType } from './helpers'
3+
import { getOwnProperties, getType } from './helpers'
54
import { stringify } from './stringify'
65

76
const IS_RECORD_SYMBOL = '@@__IMMUTABLE_RECORD__@@'
@@ -87,7 +86,7 @@ function normalizeErrorMessage(message: string) {
8786
return message.replace(/__vite_ssr_import_\d+__\./g, '')
8887
}
8988

90-
export function processError(err: any, options: DiffOptions = {}) {
89+
export function processError(err: any) {
9190
if (!err || typeof err !== 'object')
9291
return { message: err }
9392
// stack is not serialized in worker communication
@@ -97,13 +96,8 @@ export function processError(err: any, options: DiffOptions = {}) {
9796
if (err.name)
9897
err.nameStr = String(err.name)
9998

100-
const clonedActual = deepClone(err.actual, { forceWritable: true })
101-
const clonedExpected = deepClone(err.expected, { forceWritable: true })
102-
103-
const { replacedActual, replacedExpected } = replaceAsymmetricMatcher(clonedActual, clonedExpected)
104-
10599
if (err.showDiff || (err.showDiff === undefined && err.expected !== undefined && err.actual !== undefined))
106-
err.diff = unifiedDiff(replacedActual, replacedExpected, options)
100+
err.diff = diff(err.actual, err.expected)
107101

108102
if (typeof err.expected !== 'string')
109103
err.expected = stringify(err.expected, 10)

‎packages/utils/src/external.d.ts

-3
This file was deleted.

‎packages/vitest/src/node/error.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ function printModuleWarningForSourceCode(logger: Logger, path: string) {
184184
))
185185
}
186186

187-
export function displayDiff(diff: string, console: Console) {
188-
console.error(`\n${diff}\n`)
187+
export function displayDiff(diff: string | null, console: Console) {
188+
if (diff)
189+
console.error(`\n${diff}\n`)
189190
}
190191

191192
function printErrorMessage(error: ErrorWithDiff, logger: Logger) {

‎pnpm-lock.yaml

+43-44
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/core/test/__snapshots__/mocked.test.ts.snap

+31-27
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ exports[`mocked function which fails on toReturnWith > just one call 1`] = `
44
"expected \\"spy\\" to return with: 2 at least once
55
66
Received:
7-
1st spy call return:
87
9-
- 2
10-
+ 1
8+
1st spy call return:
9+
10+
- 2
11+
+ 1
1112
1213
1314
Number of calls: 1
@@ -18,20 +19,21 @@ exports[`mocked function which fails on toReturnWith > multi calls 1`] = `
1819
"expected \\"spy\\" to return with: 2 at least once
1920
2021
Received:
21-
1st spy call return:
2222
23-
- 2
24-
+ 1
23+
1st spy call return:
24+
25+
- 2
26+
+ 1
2527
26-
2nd spy call return:
28+
2nd spy call return:
2729
28-
- 2
29-
+ 1
30+
- 2
31+
+ 1
3032
31-
3rd spy call return:
33+
3rd spy call return:
3234
33-
- 2
34-
+ 1
35+
- 2
36+
+ 1
3537
3638
3739
Number of calls: 3
@@ -42,26 +44,27 @@ exports[`mocked function which fails on toReturnWith > oject type 1`] = `
4244
"expected \\"spy\\" to return with: { a: '4' } at least once
4345
4446
Received:
45-
1st spy call return:
4647
47-
{
48-
- a: '4',
49-
+ a: '1',
50-
}
48+
1st spy call return:
5149
52-
2nd spy call return:
50+
Object {
51+
- \\"a\\": \\"4\\",
52+
+ \\"a\\": \\"1\\",
53+
}
5354
54-
{
55-
- a: '4',
56-
+ a: '1',
57-
}
55+
2nd spy call return:
5856
59-
3rd spy call return:
57+
Object {
58+
- \\"a\\": \\"4\\",
59+
+ \\"a\\": \\"1\\",
60+
}
6061
61-
{
62-
- a: '4',
63-
+ a: '1',
64-
}
62+
3rd spy call return:
63+
64+
Object {
65+
- \\"a\\": \\"4\\",
66+
+ \\"a\\": \\"1\\",
67+
}
6568
6669
6770
Number of calls: 3
@@ -74,6 +77,7 @@ exports[`mocked function which fails on toReturnWith > zero call 1`] = `
7477
Received:
7578
7679
80+
7781
Number of calls: 0
7882
"
7983
`;

‎test/core/test/diff.test.ts

+22-22
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
import { expect, test, vi } from 'vitest'
22
import { getDefaultColors, setupColors } from '@vitest/utils'
33
import { displayDiff } from 'vitest/src/node/error'
4-
import { unifiedDiff } from '@vitest/utils/diff'
4+
import { diff } from '@vitest/utils/diff'
55

66
test('displays object diff', () => {
77
const objectA = { a: 1, b: 2 }
88
const objectB = { a: 1, b: 3 }
99
const console = { log: vi.fn(), error: vi.fn() }
1010
setupColors(getDefaultColors())
11-
displayDiff(unifiedDiff(objectA, objectB), console as any)
11+
displayDiff(diff(objectA, objectB), console as any)
1212
expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(`
1313
"
14-
- Expected - 1
15-
+ Received + 1
14+
- Expected
15+
+ Received
1616
17-
{
18-
a: 1,
19-
- b: 3,
20-
+ b: 2,
21-
}
17+
Object {
18+
\\"a\\": 1,
19+
- \\"b\\": 2,
20+
+ \\"b\\": 3,
21+
}
2222
"
2323
`)
2424
})
@@ -28,14 +28,14 @@ test('display one line string diff', () => {
2828
const string2 = 'string2'
2929
const console = { log: vi.fn(), error: vi.fn() }
3030
setupColors(getDefaultColors())
31-
displayDiff(unifiedDiff(string1, string2), console as any)
31+
displayDiff(diff(string1, string2), console as any)
3232
expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(`
3333
"
34-
- Expected - 1
35-
+ Received + 1
34+
- Expected
35+
+ Received
3636
37-
- 'string2'
38-
+ 'string1'
37+
- string1
38+
+ string2
3939
"
4040
`)
4141
})
@@ -45,17 +45,17 @@ test('display multiline line string diff', () => {
4545
const string2 = 'string2\nstring2\nstring1'
4646
const console = { log: vi.fn(), error: vi.fn() }
4747
setupColors(getDefaultColors())
48-
displayDiff(unifiedDiff(string1, string2), console as any)
48+
displayDiff(diff(string1, string2), console as any)
4949
expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(`
5050
"
51-
- Expected - 2
52-
+ Received + 2
51+
- Expected
52+
+ Received
5353
54-
+ string1
55-
\`string2
56-
- string2
57-
- string1\`
58-
+ string3\`
54+
- string1
55+
string2
56+
- string3
57+
+ string2
58+
+ string1
5959
"
6060
`)
6161
})

‎test/core/test/jest-matcher-utils.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ describe('jest-matcher-utils', () => {
55
expect.extend({
66
toBeJestEqual(received: any, expected: any) {
77
return {
8-
message: () => this.utils.diff(expected, received),
8+
message: () => this.utils.diff(expected, received) || '',
99
pass: received === expected,
1010
}
1111
},
@@ -17,6 +17,6 @@ describe('jest-matcher-utils', () => {
1717
expect(() => {
1818
// @ts-expect-error "toBeJestEqual" is a custom matcher we just created
1919
expect('a').toBeJestEqual('b')
20-
}).toThrowError(/- 'b'.*\+ 'a'/ms)
20+
}).toThrowError(/- b.*\+ a/ms)
2121
})
2222
})

‎test/reporters/tests/__snapshots__/html.test.ts.snap

+8-8
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
4545
"error": {
4646
"actual": "2",
4747
"constructor": "Function<AssertionError>",
48-
"diff": " - Expected - 1
49-
+ Received + 1
48+
"diff": "- Expected
49+
+ Received
5050
51-
- 1
52-
+ 2",
51+
- 2
52+
+ 1",
5353
"expected": "1",
5454
"message": "expected 2 to deeply equal 1",
5555
"name": "AssertionError",
@@ -65,11 +65,11 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
6565
{
6666
"actual": "2",
6767
"constructor": "Function<AssertionError>",
68-
"diff": " - Expected - 1
69-
+ Received + 1
68+
"diff": "- Expected
69+
+ Received
7070
71-
- 1
72-
+ 2",
71+
- 2
72+
+ 1",
7373
"expected": "1",
7474
"message": "expected 2 to deeply equal 1",
7575
"name": "AssertionError",

0 commit comments

Comments
 (0)
Please sign in to comment.