From 6797b0412ba41e27645c10e962e44ae111018ec7 Mon Sep 17 00:00:00 2001 From: Willie Ho Date: Fri, 5 Apr 2024 19:03:46 +0800 Subject: [PATCH] feat: add configuration for diff truncation (#5073) (#5333) --- docs/config/index.md | 22 ++ packages/utils/src/diff/diffLines.ts | 36 ++-- packages/utils/src/diff/diffStrings.ts | 33 ++- .../utils/src/diff/normalizeDiffOptions.ts | 4 + packages/utils/src/diff/printDiffs.ts | 11 +- packages/utils/src/diff/types.ts | 6 + packages/vitest/src/types/matcher-utils.ts | 3 + test/core/test/diff.test.ts | 199 +++++++++++++++++- 8 files changed, 285 insertions(+), 29 deletions(-) diff --git a/docs/config/index.md b/docs/config/index.md index 9b79bef818b7..7d1f011d2541 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -2077,6 +2077,28 @@ export default defineConfig({ ``` ::: +#### diff.truncateThreshold + +- **Type**: `number` +- **Default**: `0` + +The maximum length of diff result to be displayed. Diffs above this threshold will be truncated. +Truncation won't take effect with default value 0. + +#### diff.truncateAnnotation + +- **Type**: `string` +- **Default**: `'... Diff result is truncated'` + +Annotation that is output at the end of diff result if it's truncated. + +#### diff.truncateAnnotationColor + +- **Type**: `DiffOptionsColor = (arg: string) => string` +- **Default**: `noColor = (string: string): string => string` + +Color of truncate annotation, default is output with no color. + ### fakeTimers - **Type:** `FakeTimerInstallOpts` diff --git a/packages/utils/src/diff/diffLines.ts b/packages/utils/src/diff/diffLines.ts index 014b8d1d9d87..df514495dd3d 100644 --- a/packages/utils/src/diff/diffLines.ts +++ b/packages/utils/src/diff/diffLines.ts @@ -81,21 +81,24 @@ function printAnnotation({ return `${aColor(a)}\n${bColor(b)}\n\n` } -export function printDiffLines(diffs: Array, options: DiffOptionsNormalized): string { +export function printDiffLines(diffs: Array, truncated: boolean, options: DiffOptionsNormalized): string { return printAnnotation(options, countChanges(diffs)) - + (options.expand - ? joinAlignedDiffsExpand(diffs, options) - : joinAlignedDiffsNoExpand(diffs, options)) + + (options.expand ? joinAlignedDiffsExpand(diffs, options) : joinAlignedDiffsNoExpand(diffs, options)) + + (truncated ? options.truncateAnnotationColor(`\n${options.truncateAnnotation}`) : '') } // Compare two arrays of strings line-by-line. Format as comparison lines. export function diffLinesUnified(aLines: Array, bLines: Array, options?: DiffOptions): string { + const normalizedOptions = normalizeDiffOptions(options) + const [diffs, truncated] = diffLinesRaw( + isEmptyString(aLines) ? [] : aLines, + isEmptyString(bLines) ? [] : bLines, + normalizedOptions, + ) return printDiffLines( - diffLinesRaw( - isEmptyString(aLines) ? [] : aLines, - isEmptyString(bLines) ? [] : bLines, - ), - normalizeDiffOptions(options), + diffs, + truncated, + normalizedOptions, ) } @@ -120,7 +123,7 @@ export function diffLinesUnified2(aLinesDisplay: Array, bLinesDisplay: A return diffLinesUnified(aLinesDisplay, bLinesDisplay, options) } - const diffs = diffLinesRaw(aLinesCompare, bLinesCompare) + const [diffs, truncated] = diffLinesRaw(aLinesCompare, bLinesCompare, options) // Replace comparison lines with displayable lines. let aIndex = 0 @@ -144,13 +147,16 @@ export function diffLinesUnified2(aLinesDisplay: Array, bLinesDisplay: A } }) - return printDiffLines(diffs, normalizeDiffOptions(options)) + return printDiffLines(diffs, truncated, normalizeDiffOptions(options)) } // Compare two arrays of strings line-by-line. -export function diffLinesRaw(aLines: Array, bLines: Array): Array { - const aLength = aLines.length - const bLength = bLines.length +export function diffLinesRaw(aLines: Array, bLines: Array, options?: DiffOptions): [Array, boolean] { + const truncate = options?.truncateThreshold ?? false + const truncateThreshold = Math.max(Math.floor(options?.truncateThreshold ?? 0), 0) + const aLength = truncate ? Math.min(aLines.length, truncateThreshold) : aLines.length + const bLength = truncate ? Math.min(bLines.length, truncateThreshold) : bLines.length + const truncated = aLength !== aLines.length || bLength !== bLines.length const isCommon = (aIndex: number, bIndex: number) => aLines[aIndex] === bLines[bIndex] @@ -185,5 +191,5 @@ export function diffLinesRaw(aLines: Array, bLines: Array): Arra for (; bIndex !== bLength; bIndex += 1) diffs.push(new Diff(DIFF_INSERT, bLines[bIndex])) - return diffs + return [diffs, truncated] } diff --git a/packages/utils/src/diff/diffStrings.ts b/packages/utils/src/diff/diffStrings.ts index 706f001fee97..3346fc6e63f2 100644 --- a/packages/utils/src/diff/diffStrings.ts +++ b/packages/utils/src/diff/diffStrings.ts @@ -7,8 +7,31 @@ import * as diff from 'diff-sequences' import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic' +import type { DiffOptions } from './types' + +// platforms compatible +function getNewLineSymbol(string: string) { + return string.includes('\r\n') ? '\r\n' : '\n' +} + +function diffStrings(a: string, b: string, options?: DiffOptions): [Array, boolean] { + const truncate = options?.truncateThreshold ?? false + const truncateThreshold = Math.max(Math.floor(options?.truncateThreshold ?? 0), 0) + let aLength = a.length + let bLength = b.length + if (truncate) { + const aMultipleLines = a.includes('\n') + const bMultipleLines = b.includes('\n') + const aNewLineSymbol = getNewLineSymbol(a) + const bNewLineSymbol = getNewLineSymbol(b) + // multiple-lines string expects a newline to be appended at the end + const _a = aMultipleLines ? `${a.split(aNewLineSymbol, truncateThreshold).join(aNewLineSymbol)}\n` : a + const _b = bMultipleLines ? `${b.split(bNewLineSymbol, truncateThreshold).join(bNewLineSymbol)}\n` : b + aLength = _a.length + bLength = _b.length + } + const truncated = aLength !== a.length || bLength !== b.length -function diffStrings(a: string, b: string): Array { const isCommon = (aIndex: number, bIndex: number) => a[aIndex] === b[bIndex] let aIndex = 0 @@ -34,16 +57,16 @@ function diffStrings(a: string, b: string): Array { // @ts-expect-error wrong bundling const diffSequences = diff.default.default || diff.default - diffSequences(a.length, b.length, isCommon, foundSubsequence) + diffSequences(aLength, bLength, isCommon, foundSubsequence) // After the last common subsequence, push remaining change items. - if (aIndex !== a.length) + if (aIndex !== aLength) diffs.push(new Diff(DIFF_DELETE, a.slice(aIndex))) - if (bIndex !== b.length) + if (bIndex !== bLength) diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex))) - return diffs + return [diffs, truncated] } export default diffStrings diff --git a/packages/utils/src/diff/normalizeDiffOptions.ts b/packages/utils/src/diff/normalizeDiffOptions.ts index 8cf6d530294d..d06079f0daca 100644 --- a/packages/utils/src/diff/normalizeDiffOptions.ts +++ b/packages/utils/src/diff/normalizeDiffOptions.ts @@ -12,6 +12,7 @@ import type { DiffOptions, DiffOptionsNormalized } from './types' export const noColor = (string: string): string => string const DIFF_CONTEXT_DEFAULT = 5 +const DIFF_TRUNCATE_THRESHOLD_DEFAULT = 0 // not truncate function getDefaultOptions(): DiffOptionsNormalized { const c = getColors() @@ -35,6 +36,9 @@ function getDefaultOptions(): DiffOptionsNormalized { includeChangeCounts: false, omitAnnotationLines: false, patchColor: c.yellow, + truncateThreshold: DIFF_TRUNCATE_THRESHOLD_DEFAULT, + truncateAnnotation: '... Diff result is truncated', + truncateAnnotationColor: noColor, } } diff --git a/packages/utils/src/diff/printDiffs.ts b/packages/utils/src/diff/printDiffs.ts index f72e24eb18f5..2254267e9dc7 100644 --- a/packages/utils/src/diff/printDiffs.ts +++ b/packages/utils/src/diff/printDiffs.ts @@ -32,16 +32,17 @@ export function diffStringsUnified(a: string, b: string, options?: DiffOptions): const isMultiline = a.includes('\n') || b.includes('\n') // getAlignedDiffs assumes that a newline was appended to the strings. - const diffs = diffStringsRaw( + const [diffs, truncated] = diffStringsRaw( isMultiline ? `${a}\n` : a, isMultiline ? `${b}\n` : b, true, // cleanupSemantic + options, ) if (hasCommonDiff(diffs, isMultiline)) { const optionsNormalized = normalizeDiffOptions(options) const lines = getAlignedDiffs(diffs, optionsNormalized.changeColor) - return printDiffLines(lines, optionsNormalized) + return printDiffLines(lines, truncated, optionsNormalized) } } @@ -51,11 +52,11 @@ export function diffStringsUnified(a: string, b: string, options?: DiffOptions): // Compare two strings character-by-character. // Optionally clean up small common substrings, also known as chaff. -export function diffStringsRaw(a: string, b: string, cleanup: boolean): Array { - const diffs = diffStrings(a, b) +export function diffStringsRaw(a: string, b: string, cleanup: boolean, options?: DiffOptions): [Array, boolean] { + const [diffs, truncated] = diffStrings(a, b, options) if (cleanup) cleanupSemantic(diffs) // impure function - return diffs + return [diffs, truncated] } diff --git a/packages/utils/src/diff/types.ts b/packages/utils/src/diff/types.ts index c7b984fd9fad..d962c5eacc4f 100644 --- a/packages/utils/src/diff/types.ts +++ b/packages/utils/src/diff/types.ts @@ -27,6 +27,9 @@ export interface DiffOptions { omitAnnotationLines?: boolean patchColor?: DiffOptionsColor compareKeys?: CompareKeys + truncateThreshold?: number + truncateAnnotation?: string + truncateAnnotationColor?: DiffOptionsColor } export interface DiffOptionsNormalized { @@ -48,4 +51,7 @@ export interface DiffOptionsNormalized { includeChangeCounts: boolean omitAnnotationLines: boolean patchColor: DiffOptionsColor + truncateThreshold: number + truncateAnnotation: string + truncateAnnotationColor: DiffOptionsColor } diff --git a/packages/vitest/src/types/matcher-utils.ts b/packages/vitest/src/types/matcher-utils.ts index c746bd307ee0..cd5f85f2fe71 100644 --- a/packages/vitest/src/types/matcher-utils.ts +++ b/packages/vitest/src/types/matcher-utils.ts @@ -31,4 +31,7 @@ export interface DiffOptions { patchColor?: Formatter // pretty-format type compareKeys?: any + truncateThreshold?: number + truncateAnnotation?: string + truncateAnnotationColor?: Formatter } diff --git a/test/core/test/diff.test.ts b/test/core/test/diff.test.ts index b68e9dd9a0c4..99b20e123acf 100644 --- a/test/core/test/diff.test.ts +++ b/test/core/test/diff.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest' import { getDefaultColors, setupColors } from '@vitest/utils' -import { diff } from '@vitest/utils/diff' +import type { DiffOptions } from '@vitest/utils/diff' +import { diff, diffStringsUnified } from '@vitest/utils/diff' import { processError } from '@vitest/runner' import { displayDiff } from '../../../packages/vitest/src/node/error' @@ -24,6 +25,28 @@ test('displays object diff', () => { `) }) +test('display truncated object diff', () => { + const objectA = { a: 1, b: 2, c: 3, d: 4, e: 5 } + const objectB = { a: 1, b: 3, c: 4, d: 5, e: 6 } + const console = { log: vi.fn(), error: vi.fn() } + setupColors(getDefaultColors()) + displayDiff(diff(objectA, objectB, { truncateThreshold: 4 }), console as any) + expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(` + " + - Expected + + Received + + Object { + "a": 1, + - "b": 2, + - "c": 3, + + "b": 3, + + "c": 4, + ... Diff result is truncated + " + `) +}) + test('display one line string diff', () => { const string1 = 'string1' const string2 = 'string2' @@ -41,7 +64,24 @@ test('display one line string diff', () => { `) }) -test('display multiline line string diff', () => { +test('display one line string diff should not be affected by truncateThreshold', () => { + const string1 = 'string1' + const string2 = 'string2' + const console = { log: vi.fn(), error: vi.fn() } + setupColors(getDefaultColors()) + displayDiff(diff(string1, string2, { truncateThreshold: 3 }), console as any) + expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(` + " + - Expected + + Received + + - string1 + + string2 + " + `) +}) + +test('display multiline string diff', () => { const string1 = 'string1\nstring2\nstring3' const string2 = 'string2\nstring2\nstring1' const console = { log: vi.fn(), error: vi.fn() } @@ -61,6 +101,46 @@ test('display multiline line string diff', () => { `) }) +test('display truncated multiline string diff', () => { + const string1 = 'string1\nstring2\nstring3' + const string2 = 'string2\nstring2\nstring1' + const console = { log: vi.fn(), error: vi.fn() } + setupColors(getDefaultColors()) + displayDiff(diff(string1, string2, { truncateThreshold: 2 }), console as any) + expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(` + " + - Expected + + Received + + - string1 + + string2 + string2 + ... Diff result is truncated + " + `) +}) + +test('display truncated multiple items array diff', () => { + const array1 = Array(45000).fill('foo') + const array2 = Array(45000).fill('bar') + const console = { log: vi.fn(), error: vi.fn() } + setupColors(getDefaultColors()) + displayDiff(diff(array1, array2, { truncateThreshold: 3 }), console as any) + expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(` + " + - Expected + + Received + + Array [ + - "foo", + - "foo", + + "bar", + + "bar", + ... Diff result is truncated + " + `) +}) + test('asymmetric matcher in object', () => { setupColors(getDefaultColors()) expect(getErrorDiff({ x: 0, y: 'foo' }, { x: 1, y: expect.anything() })).toMatchInlineSnapshot(` @@ -75,6 +155,26 @@ test('asymmetric matcher in object', () => { `) }) +test('asymmetric matcher in object with truncated diff', () => { + setupColors(getDefaultColors()) + expect( + getErrorDiff( + { w: 'foo', x: 0, y: 'bar', z: 'baz' }, + { w: expect.anything(), x: 1, y: expect.anything(), z: 'bar' }, + { truncateThreshold: 3 }, + ), + ).toMatchInlineSnapshot(` + "- Expected + + Received + + Object { + "w": Anything, + - "x": 1, + + "x": 0, + ... Diff result is truncated" + `) +}) + test('asymmetric matcher in array', () => { setupColors(getDefaultColors()) expect(getErrorDiff([0, 'foo'], [1, expect.anything()])).toMatchInlineSnapshot(` @@ -89,6 +189,25 @@ test('asymmetric matcher in array', () => { `) }) +test('asymmetric matcher in array with truncated diff', () => { + setupColors(getDefaultColors()) + expect( + getErrorDiff( + [0, 'foo', 2], + [1, expect.anything(), 3], + { truncateThreshold: 2 }, + ), + ).toMatchInlineSnapshot(` + "- Expected + + Received + + Array [ + - 1, + + 0, + ... Diff result is truncated" + `) +}) + test('asymmetric matcher in nested', () => { setupColors(getDefaultColors()) expect( @@ -115,6 +234,78 @@ test('asymmetric matcher in nested', () => { `) }) +test('asymmetric matcher in nested with truncated diff', () => { + setupColors(getDefaultColors()) + expect( + getErrorDiff( + [{ x: 0, y: 'foo', z: 'bar' }, [0, 'bar', 'baz']], + [{ x: 1, y: expect.anything(), z: expect.anything() }, [1, expect.anything(), expect.anything()]], + { truncateThreshold: 5 }, + ), + ).toMatchInlineSnapshot(` + "- Expected + + Received + + Array [ + Object { + - "x": 1, + + "x": 0, + "y": Anything, + "z": Anything, + ... Diff result is truncated" + `) +}) + +test('diff for multi-line string compared by characters', () => { + const string1 = ` + foo, + bar, + ` + const string2 = ` + FOO, + bar, + ` + setupColors(getDefaultColors()) + expect( + diffStringsUnified(string1, string2), + ).toMatchInlineSnapshot(` + "- Expected + + Received + + + - foo, + + FOO, + bar, + " + `) +}) + +test('truncated diff for multi-line string compared by characters', () => { + const string1 = ` + foo, + bar, + baz, + ` + const string2 = ` + FOO, + bar, + BAZ, + ` + setupColors(getDefaultColors()) + expect( + diffStringsUnified(string1, string2, { truncateThreshold: 3 }), + ).toMatchInlineSnapshot(` + "- Expected + + Received + + + - foo, + + FOO, + bar, + ... Diff result is truncated" + `) +}) + test('getter only property', () => { setupColors(getDefaultColors()) const x = { normalProp: 1 } @@ -143,12 +334,12 @@ test('getter only property', () => { `) }) -function getErrorDiff(actual: unknown, expected: unknown) { +function getErrorDiff(actual: unknown, expected: unknown, options?: DiffOptions) { try { expect(actual).toEqual(expected) } catch (e) { - const error = processError(e) + const error = processError(e, options) return error.diff } expect.unreachable()