diff --git a/CHANGELOG.md b/CHANGELOG.md index aff3ad9d7cf0..a95862c27536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - `[babel-plugin-jest-hoist]` Show codeframe on static hoisting issues ([#8865](https://github.com/facebook/jest/pull/8865)) - `[jest-config]` [**BREAKING**] Set default display name color based on runner ([#8689](https://github.com/facebook/jest/pull/8689)) +- `[jest-diff]` Add options for colors and symbols ([#8841](https://github.com/facebook/jest/pull/8841)) - `[@jest/test-result]` Create method to create empty `TestResult` ([#8867](https://github.com/facebook/jest/pull/8867)) ### Fixes diff --git a/packages/jest-diff/README.md b/packages/jest-diff/README.md new file mode 100644 index 000000000000..d60e630be761 --- /dev/null +++ b/packages/jest-diff/README.md @@ -0,0 +1,291 @@ +# jest-diff + +Display differences clearly so people can review changes confidently. + +The default export serializes JavaScript **values** and compares them line-by-line. + +Two named exports compare **strings** character-by-character: + +- `diffStringsUnified` returns a string which includes comparison lines. +- `diffStringsRaw` returns an array of `Diff` objects. + +## Installation + +To add this package as a dependency of a project, run either of the following commands: + +- `npm install jest-diff` +- `yarn add jest-diff` + +## Usage of default export + +Given values and optional options, `diffLinesUnified(a, b, options?)` does the following: + +- **serialize** the values as strings using the `pretty-format` package +- **compare** the strings line-by-line using the `diff-sequences` package +- **format** the changed or common lines using the `chalk` package + +To use this function, write either of the following: + +- `const diffLinesUnified = require('jest-diff');` in a CommonJS module +- `import diffLinesUnified from 'jest-diff';` in an ECMAScript module + +### Example of default export + +```js +const a = ['delete', 'change from', 'common']; +const b = ['change to', 'insert', 'common']; + +const difference = diffLinesUnified(a, b); +``` + +The returned **string** consists of: + +- annotation lines which describe the change symbols with labels +- blank line +- comparison lines: similar to “unified” view on GitHub, but `Expected` lines are green, `Received` lines are red, and common lines are dim (by default, see Options) + +```diff +- Expected ++ Received + + Array [ +- "delete", +- "change from", ++ "change to", ++ "insert", + "common", + ] +``` + +### Edge cases of default export + +Here are edge cases for the return value: + +- `' Comparing two different types of values. …'` if the arguments have **different types** according to the `jest-get-type` package (instances of different classes have the same `'object'` type) +- `'Compared values have no visual difference.'` if the arguments have either **referential identity** according to `Object.is` method or **same serialization** according to the `pretty-format` package +- `null` if either argument is a so-called **asymmetric matcher** in Jasmine or Jest + +## Usage of diffStringsUnified + +Given strings and optional options, `diffStringsUnified(a, b, options?)` does the following: + +- **compare** the strings character-by-character using the `diff-sequences` package +- **clean up** small (often coincidental) common substrings, also known as chaff +- **format** the changed or common lines using the `chalk` package + +Although the function is mainly for **multiline** strings, it compares any strings. + +Write either of the following: + +- `const {diffStringsUnified} = require('jest-diff');` in a CommonJS module +- `import {diffStringsUnified} from 'jest-diff';` in an ECMAScript module + +### Example of diffStringsUnified + +```js +const a = 'change from\ncommon'; +const b = 'change to\ncommon'; + +const difference = diffStringsUnified(a, b); +``` + +The returned **string** consists of: + +- annotation lines which describe the change symbols with labels +- blank line +- comparison lines: similar to “unified” view on GitHub, and **changed substrings** have **inverted** foreground and background colors + +```diff +- Expected ++ Received + +- change from ++ change to + common +``` + +### Edge cases of diffStringsUnified + +Here are edge cases for the return value: + +- both `a` and `b` are empty strings: no comparison lines +- only `a` is empty string: all comparison lines have `bColor` and `bSymbol` (see Options) +- only `b` is empty string: all comparison lines have `aColor` and `aSymbol` (see Options) +- `a` and `b` are equal non-empty strings: all comparison lines have `commonColor` and `commonSymbol` (see Options) + +### Performance of diffStringsUnified + +To get the benefit of **changed substrings** within the comparison lines, a character-by-character comparison has a higher computational cost (in time and space) than a line-by-line comparison. + +If the input strings can have **arbitrary length**, we recommend that the calling code set a limit, beyond which it calls the default export instead. For example, Jest falls back to line-by-line comparison if either string has length greater than 20K characters. + +## Usage of diffStringsRaw + +Given strings, `diffStringsRaw(a, b, cleanup)` does the following: + +- **compare** the strings character-by-character using the `diff-sequences` package +- optionally **clean up** small (often coincidental) common substrings, also known as chaff + +Write one of the following: + +- `const {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diffStringsRaw} = require('jest-diff');` in a CommonJS module +- `import {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diffStringsRaw} from 'jest-diff';` in an ECMAScript module +- `import {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff, diffStringsRaw} from 'jest-diff';` in a TypeScript module + +The returned **array** describes substrings as instances of the `Diff` class (which calling code can access like array tuples). + +| value | named export | description | +| ----: | :------------ | :-------------------- | +| `0` | `DIFF_EQUAL` | in `a` and in `b` | +| `-1` | `DIFF_DELETE` | in `a` but not in `b` | +| `1` | `DIFF_INSERT` | in `b` but not in `a` | + +Because `diffStringsRaw` returns the difference as **data** instead of a string, you are free to format it as your application requires (for example, enclosed in HTML markup for browser instead of escape sequences for console). + +### Example of diffStringsRaw with cleanup + +```js +const diffs = diffStringsRaw('change from', 'change to', true); + +// diffs[0][0] === DIFF_EQUAL +// diffs[0][1] === 'change ' + +// diffs[1][0] === DIFF_DELETE +// diffs[1][1] === 'from' + +// diffs[2][0] === DIFF_INSERT +// diffs[2][1] === 'to' +``` + +### Example of diffStringsRaw without cleanup + +```js +const diffs = diffStringsRaw('change from', 'change to', false); + +// diffs[0][0] === DIFF_EQUAL +// diffs[0][1] === 'change ' + +// diffs[1][0] === DIFF_DELETE +// diffs[1][1] === 'fr' + +// diffs[2][0] === DIFF_INSERT +// diffs[2][1] === 't' + +// Here is a small coincidental common substring: +// diffs[3][0] === DIFF_EQUAL +// diffs[3][1] === 'o' + +// diffs[4][0] === DIFF_DELETE +// diffs[4][1] === 'm' +``` + +## Options + +The default options are for the report when an assertion fails from the `expect` package used by Jest. + +For other applications, you can provide an options object as a third argument: + +- `diffLinesUnified(a, b, options)` +- `diffStringsUnified(a, b, options)` + +### Properties of options object + +| name | default | +| :-------------------- | :------------ | +| `aAnnotation` | `'Expected'` | +| `aColor` | `chalk.green` | +| `aSymbol` | `'-'` | +| `bAnnotation` | `'Received'` | +| `bColor` | `chalk.red` | +| `bSymbol` | `'+'` | +| `commonColor` | `chalk.dim` | +| `commonSymbol` | `' '` | +| `contextLines` | `5` | +| `expand` | `true` | +| `omitAnnotationLines` | `false` | + +### Example of options for labels + +If the application is code modification, you might replace the labels: + +```js +const options = { + aAnnotation: 'Original', + bAnnotation: 'Modified', +}; +``` + +The `jest-diff` package does not assume that the 2 labels have equal length. + +### Example of options for colors + +For consistency with most diff tools, you might exchange the colors: + +```js +import chalk from 'chalk'; + +const options = { + aColor: chalk.red, + bColor: chalk.green, +}; +``` + +### Example of option to keep the default color + +The value of a color option is a function, which given a string, returns a string. + +For common lines to keep the default (usually black) color, you might provide an identity function: + +```js +const options = { + commonColor: line => line, +}; +``` + +### Example of options for symbols + +For consistency with the `diff` command, you might replace the symbols: + +```js +const options = { + aSymbol: '<', + bSymbol: '>', +}; +``` + +The `jest-diff` package assumes (but does not enforce) that the 3 symbols have equal length. + +### Example of options to limit common lines + +By default, the output includes all common lines. + +To emphasize the changes, you might limit the number of common “context” lines: + +```js +const options = { + contextLines: 1, + expand: false, +}; +``` + +A patch mark like `@@ -12,7 +12,9 @@` accounts for omitted common lines. + +### Example of option to omit annotation lines + +To display only the comparison lines: + +```js +const a = 'change from\ncommon'; +const b = 'change to\ncommon'; +const options = { + omitAnnotationLines: true, +}; + +const difference = diffStringsUnified(a, b, options); +``` + +```diff +- change from ++ change to + common +``` diff --git a/packages/jest-diff/src/__tests__/__snapshots__/diff.test.ts.snap b/packages/jest-diff/src/__tests__/__snapshots__/diff.test.ts.snap index d02b263a2476..c7e1b19669fc 100644 --- a/packages/jest-diff/src/__tests__/__snapshots__/diff.test.ts.snap +++ b/packages/jest-diff/src/__tests__/__snapshots__/diff.test.ts.snap @@ -152,7 +152,7 @@ exports[`context number of lines: 2 1`] = ` }" `; -exports[`context number of lines: null (5 default) 1`] = ` +exports[`context number of lines: 3.1 (5 default) 1`] = ` "- Expected + Received @@ -169,6 +169,69 @@ exports[`context number of lines: null (5 default) 1`] = ` }" `; +exports[`context number of lines: undefined (5 default) 1`] = ` +"- Expected ++ Received + +@@ -6,9 +6,9 @@ + 4, + 5, + 6, + 7, + 8, +- 9, + 10, ++ 9, + ], + }" +`; + +exports[`diffStringsUnified edge cases empty both a and b 1`] = ` +"- Expected ++ Received + +" +`; + +exports[`diffStringsUnified edge cases empty only a 1`] = ` +"- Expected ++ Received + ++ one-line string" +`; + +exports[`diffStringsUnified edge cases empty only b 1`] = ` +"- Expected ++ Received + +- one-line string" +`; + +exports[`diffStringsUnified edge cases equal both non-empty 1`] = ` +"- Expected ++ Received + + one-line string" +`; + +exports[`diffStringsUnified edge cases multiline has no common after clean up chaff 1`] = ` +"- Expected ++ Received + +- delete +- two ++ insert ++ 2" +`; + +exports[`diffStringsUnified edge cases one-line has no common after clean up chaff 1`] = ` +"- Expected ++ Received + +- delete ++ insert" +`; + exports[`falls back to not call toJSON if it throws and then objects have differences 1`] = ` "- Expected + Received @@ -242,3 +305,57 @@ exports[`oneline strings 4`] = ` - line + oneline" `; + +exports[`options 7980 diff 1`] = ` +"- Original ++ Modified + +- \`\${Ti.App.name} \${Ti.App.version} \${Ti.Platform.name} \${Ti.Platform.version}\` ++ \`\${Ti.App.getName()} \${Ti.App.getVersion()} \${Ti.Platform.getName()} \${Ti.Platform.getVersion()}\`" +`; + +exports[`options 7980 diffStringsUnified 1`] = ` +"- Original ++ Modified + +- \`\${Ti.App.name} \${Ti.App.version} \${Ti.Platform.name} \${Ti.Platform.version}\` ++ \`\${Ti.App.getName()} \${Ti.App.getVersion()} \${Ti.Platform.getName()} \${Ti.Platform.getVersion()}\`" +`; + +exports[`options change symbols diff 1`] = ` +"< Expected +> Received + + Array [ +< \\"delete\\", +< \\"change from\\", +> \\"change to\\", +> \\"insert\\", + \\"common\\", + ]" +`; + +exports[`options common diff 1`] = ` +"- Expected ++ Received + += Array [ +- \\"delete\\", +- \\"change from\\", ++ \\"change to\\", ++ \\"insert\\", += \\"common\\", += ]" +`; + +exports[`options omitAnnotationLines diff 1`] = ` +" Array [ +- \\"delete\\", +- \\"change from\\", ++ \\"change to\\", ++ \\"insert\\", + \\"common\\", + ]" +`; + +exports[`options omitAnnotationLines diffStringsUnified empty strings 1`] = `""`; diff --git a/packages/jest-diff/src/__tests__/diff.test.ts b/packages/jest-diff/src/__tests__/diff.test.ts index 090813e61f70..32d1b4f6d714 100644 --- a/packages/jest-diff/src/__tests__/diff.test.ts +++ b/packages/jest-diff/src/__tests__/diff.test.ts @@ -5,9 +5,11 @@ * LICENSE file in the root directory of this source tree. */ +import chalk from 'chalk'; import stripAnsi from 'strip-ansi'; import diff from '../'; +import {diffStringsUnified} from '../printDiffs'; import {DiffOptions} from '../types'; const NO_DIFF_MESSAGE = 'Compared values have no visual difference.'; @@ -833,9 +835,13 @@ test('collapses big diffs to patch format', () => { describe('context', () => { const testDiffContextLines = (contextLines?: number) => { test(`number of lines: ${ - typeof contextLines === 'number' ? contextLines : 'null' + typeof contextLines === 'number' ? contextLines : 'undefined' } ${ - typeof contextLines !== 'number' || contextLines < 0 ? '(5 default)' : '' + typeof contextLines === 'number' && + Number.isSafeInteger(contextLines) && + contextLines >= 0 + ? '' + : '(5 default)' }`, () => { const result = diff( {test: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}, @@ -849,9 +855,117 @@ describe('context', () => { }); }; - testDiffContextLines(); // 5 by default - testDiffContextLines(2); - testDiffContextLines(1); + testDiffContextLines(-1); // (5 default) testDiffContextLines(0); - testDiffContextLines(-1); // Will use default + testDiffContextLines(1); + testDiffContextLines(2); + testDiffContextLines(3.1); // (5 default) + testDiffContextLines(); // (5 default) +}); + +describe('diffStringsUnified edge cases', () => { + test('empty both a and b', () => { + const a = ''; + const b = ''; + + expect(diffStringsUnified(a, b)).toMatchSnapshot(); + }); + + test('empty only a', () => { + const a = ''; + const b = 'one-line string'; + + expect(diffStringsUnified(a, b)).toMatchSnapshot(); + }); + + test('empty only b', () => { + const a = 'one-line string'; + const b = ''; + + expect(diffStringsUnified(a, b)).toMatchSnapshot(); + }); + + test('equal both non-empty', () => { + const a = 'one-line string'; + const b = 'one-line string'; + + expect(diffStringsUnified(a, b)).toMatchSnapshot(); + }); + + test('multiline has no common after clean up chaff', () => { + const a = 'delete\ntwo'; + const b = 'insert\n2'; + + expect(diffStringsUnified(a, b)).toMatchSnapshot(); + }); + + test('one-line has no common after clean up chaff', () => { + const a = 'delete'; + const b = 'insert'; + + expect(diffStringsUnified(a, b)).toMatchSnapshot(); + }); +}); + +describe('options 7980', () => { + const a = + '`${Ti.App.name} ${Ti.App.version} ${Ti.Platform.name} ${Ti.Platform.version}`'; + const b = + '`${Ti.App.getName()} ${Ti.App.getVersion()} ${Ti.Platform.getName()} ${Ti.Platform.getVersion()}`'; + + const options = { + aAnnotation: 'Original', + aColor: chalk.red, + bAnnotation: 'Modified', + bColor: chalk.green, + }; + + test('diff', () => { + expect(diff(a, b, options)).toMatchSnapshot(); + }); + + test('diffStringsUnified', () => { + expect(diffStringsUnified(a, b, options)).toMatchSnapshot(); + }); +}); + +describe('options', () => { + const a = ['delete', 'change from', 'common']; + const b = ['change to', 'insert', 'common']; + + describe('change symbols', () => { + const options = { + aSymbol: '<', + bSymbol: '>', + }; + + test('diff', () => { + expect(diff(a, b, options)).toMatchSnapshot(); + }); + }); + + describe('common', () => { + const options = { + commonColor: line => line, + commonSymbol: '=', + }; + + test('diff', () => { + expect(diff(a, b, options)).toMatchSnapshot(); + }); + }); + + describe('omitAnnotationLines', () => { + const options = { + omitAnnotationLines: true, + }; + + test('diff', () => { + expect(diff(a, b, options)).toMatchSnapshot(); + }); + + test('diffStringsUnified empty strings', () => { + expect(diffStringsUnified('', '', options)).toMatchSnapshot(); + }); + }); }); diff --git a/packages/jest-diff/src/__tests__/getAlignedDiffs.test.ts b/packages/jest-diff/src/__tests__/getAlignedDiffs.test.ts index 3eb197f45be3..df825a822c6a 100644 --- a/packages/jest-diff/src/__tests__/getAlignedDiffs.test.ts +++ b/packages/jest-diff/src/__tests__/getAlignedDiffs.test.ts @@ -5,12 +5,10 @@ * LICENSE file in the root directory of this source tree. */ -import {computeStringDiffs, printMultilineStringDiffs} from '../printDiffs'; +import {diffStringsUnified} from '../printDiffs'; -const testAlignedDiffs = (a: string, b: string): string => { - const {diffs} = computeStringDiffs(a, b); - return printMultilineStringDiffs(diffs, true); -}; +const testAlignedDiffs = (a: string, b: string): string => + diffStringsUnified(a, b, {omitAnnotationLines: true}); describe('getAlignedDiffs', () => { describe('lines', () => { diff --git a/packages/jest-diff/src/__tests__/joinAlignedDiffs.test.ts b/packages/jest-diff/src/__tests__/joinAlignedDiffs.test.ts index f763b5f9de3e..51ceeb874390 100644 --- a/packages/jest-diff/src/__tests__/joinAlignedDiffs.test.ts +++ b/packages/jest-diff/src/__tests__/joinAlignedDiffs.test.ts @@ -11,6 +11,7 @@ import { joinAlignedDiffsExpand, joinAlignedDiffsNoExpand, } from '../joinAlignedDiffs'; +import {normalizeDiffOptions} from '../normalizeDiffOptions'; const diffsCommonStartEnd = [ new Diff(DIFF_EQUAL, ''), @@ -53,28 +54,46 @@ const diffsChangeStartEnd = [ describe('joinAlignedDiffsExpand', () => { test('first line is empty common', () => { - expect(joinAlignedDiffsExpand(diffsCommonStartEnd)).toMatchSnapshot(); + const options = normalizeDiffOptions(); + expect( + joinAlignedDiffsExpand(diffsCommonStartEnd, options), + ).toMatchSnapshot(); }); }); describe('joinAlignedDiffsNoExpand', () => { test('patch 0 with context 1 and change at start and end', () => { - expect(joinAlignedDiffsNoExpand(diffsChangeStartEnd, 1)).toMatchSnapshot(); + const options = normalizeDiffOptions({contextLines: 1, expand: false}); + expect( + joinAlignedDiffsNoExpand(diffsChangeStartEnd, options), + ).toMatchSnapshot(); }); test('patch 0 with context 5 and first line is empty common', () => { - expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd)).toMatchSnapshot(); + const options = normalizeDiffOptions({expand: false}); + expect( + joinAlignedDiffsNoExpand(diffsCommonStartEnd, options), + ).toMatchSnapshot(); }); test('patch 1 with context 4 and last line is empty common', () => { - expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd, 4)).toMatchSnapshot(); + const options = normalizeDiffOptions({contextLines: 4, expand: false}); + expect( + joinAlignedDiffsNoExpand(diffsCommonStartEnd, options), + ).toMatchSnapshot(); }); test('patch 2 with context 3', () => { - expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd, 3)).toMatchSnapshot(); + const options = normalizeDiffOptions({contextLines: 3, expand: false}); + expect( + joinAlignedDiffsNoExpand(diffsCommonStartEnd, options), + ).toMatchSnapshot(); }); test('patch 3 with context 2 and omit excess common at start', () => { - expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd, 2)).toMatchSnapshot(); + const options = normalizeDiffOptions({contextLines: 2, expand: false}); + expect( + joinAlignedDiffsNoExpand(diffsCommonStartEnd, options), + ).toMatchSnapshot(); }); }); diff --git a/packages/jest-diff/src/diffLines.ts b/packages/jest-diff/src/diffLines.ts index 155fef041c19..a718470caf65 100644 --- a/packages/jest-diff/src/diffLines.ts +++ b/packages/jest-diff/src/diffLines.ts @@ -9,18 +9,13 @@ import chalk, {Chalk} from 'chalk'; import diff, {Callbacks} from 'diff-sequences'; import {NO_DIFF_MESSAGE} from './constants'; import {createPatchMark, printAnnotation} from './printDiffs'; -import {DiffOptions} from './types'; - -const DIFF_CONTEXT_DEFAULT = 5; +import {DiffOptionsNormalized} from './types'; type Original = { a: string; b: string; }; -const fgDelete = chalk.green; -const fgInsert = chalk.red; -const fgCommon = chalk.dim; // common lines (even indentation same) const fgIndent = chalk.cyan; // common lines (only indentation different) const bgCommon = chalk.bgYellow; // edge spaces in common line (even indentation same) const bgInverse = chalk.inverse; // edge spaces in any other lines @@ -51,6 +46,7 @@ const formatDelete = ( aEnd: number, aLinesUn: Array, aLinesIn: Array, + {aColor, aSymbol}: DiffOptionsNormalized, put: Put, ) => { const highlightSpaces = getHighlightSpaces(aLinesUn !== aLinesIn); @@ -59,7 +55,9 @@ const formatDelete = ( const aLineIn = aLinesIn[aIndex]; const indentation = aLineIn.slice(0, aLineIn.length - aLineUn.length); - put(fgDelete('- ' + indentation + highlightSpaces(aLineUn, bgInverse))); + put( + aColor(aSymbol + ' ' + indentation + highlightSpaces(aLineUn, bgInverse)), + ); } }; @@ -69,6 +67,7 @@ const formatInsert = ( bEnd: number, bLinesUn: Array, bLinesIn: Array, + {bColor, bSymbol}: DiffOptionsNormalized, put: Put, ) => { const highlightSpaces = getHighlightSpaces(bLinesUn !== bLinesIn); @@ -77,7 +76,9 @@ const formatInsert = ( const bLineIn = bLinesIn[bIndex]; const indentation = bLineIn.slice(0, bLineIn.length - bLineUn.length); - put(fgInsert('+ ' + indentation + highlightSpaces(bLineUn, bgInverse))); + put( + bColor(bSymbol + ' ' + indentation + highlightSpaces(bLineUn, bgInverse)), + ); } }; @@ -91,6 +92,7 @@ const formatCommon = ( aLinesIn: Array, bLinesUn: Array, bLinesIn: Array, + {commonColor, commonSymbol}: DiffOptionsNormalized, put: Put, ) => { const highlightSpaces = getHighlightSpaces(bLinesUn !== bLinesIn); @@ -104,10 +106,10 @@ const formatCommon = ( // Color shows whether expected and received line has same indentation. const hasSameIndentation = aLinesIn[aCommon].length === bLineInLength; - const fg = hasSameIndentation ? fgCommon : fgIndent; + const fg = hasSameIndentation ? commonColor : fgIndent; const bg = hasSameIndentation ? bgCommon : bgInverse; - put(fg(' ' + indentation + highlightSpaces(bLineUn, bg))); + put(fg(commonSymbol + ' ' + indentation + highlightSpaces(bLineUn, bg))); } }; @@ -118,6 +120,7 @@ const diffExpand = ( bLinesUn: Array, aLinesIn: Array, bLinesIn: Array, + options: DiffOptionsNormalized, ): string => { const isCommon: Callbacks['isCommon'] = (aIndex, bIndex) => aLinesUn[aIndex] === bLinesUn[bIndex]; @@ -135,9 +138,18 @@ const diffExpand = ( aCommon, bCommon, ) => { - formatDelete(aStart, aCommon, aLinesUn, aLinesIn, put); - formatInsert(bStart, bCommon, bLinesUn, bLinesIn, put); - formatCommon(nCommon, aCommon, bCommon, aLinesIn, bLinesUn, bLinesIn, put); + formatDelete(aStart, aCommon, aLinesUn, aLinesIn, options, put); + formatInsert(bStart, bCommon, bLinesUn, bLinesIn, options, put); + formatCommon( + nCommon, + aCommon, + bCommon, + aLinesIn, + bLinesUn, + bLinesIn, + options, + put, + ); aStart = aCommon + nCommon; bStart = bCommon + nCommon; }; @@ -148,19 +160,12 @@ const diffExpand = ( diff(aLength, bLength, isCommon, foundSubsequence); // After the last common subsequence, format remaining change lines. - formatDelete(aStart, aLength, aLinesUn, aLinesIn, put); - formatInsert(bStart, bLength, bLinesUn, bLinesIn, put); + formatDelete(aStart, aLength, aLinesUn, aLinesIn, options, put); + formatInsert(bStart, bLength, bLinesUn, bLinesIn, options, put); return array.join('\n'); }; -const getContextLines = (options?: DiffOptions): number => - options && - typeof options.contextLines === 'number' && - options.contextLines >= 0 - ? options.contextLines - : DIFF_CONTEXT_DEFAULT; - // jest --no-expand // Return joined string of formatted diff for all change lines, // but if some common lines are omitted because there are more than the context, @@ -170,7 +175,7 @@ const diffNoExpand = ( bLinesUn: Array, aLinesIn: Array, bLinesIn: Array, - nContextLines: number, + options: DiffOptionsNormalized, ): string => { const isCommon: Callbacks['isCommon'] = (aIndex, bIndex) => aLinesUn[aIndex] === bLinesUn[bIndex]; @@ -184,6 +189,7 @@ const diffNoExpand = ( let isAtEnd = false; const aLength = aLinesUn.length; const bLength = bLinesUn.length; + const nContextLines = options.contextLines; const nContextLines2 = nContextLines + nContextLines; // Initialize the first patch for changes at the start, @@ -210,15 +216,24 @@ const diffNoExpand = ( aStart = aEndCommon - nLines; bStart = bEndCommon - nLines; - formatCommon(nLines, aStart, bStart, aLinesIn, bLinesUn, bLinesIn, put); + formatCommon( + nLines, + aStart, + bStart, + aLinesIn, + bLinesUn, + bLinesIn, + options, + put, + ); aEnd = aEndCommon; bEnd = bEndCommon; return; } // Format preceding change lines. - formatDelete(aEnd, aStartCommon, aLinesUn, aLinesIn, put); - formatInsert(bEnd, bStartCommon, bLinesUn, bLinesIn, put); + formatDelete(aEnd, aStartCommon, aLinesUn, aLinesIn, options, put); + formatInsert(bEnd, bStartCommon, bLinesUn, bLinesIn, options, put); aEnd = aStartCommon; bEnd = bStartCommon; @@ -228,14 +243,32 @@ const diffNoExpand = ( if (nCommon <= maxContextLines) { // The patch includes all lines in the common subsequence. - formatCommon(nCommon, aEnd, bEnd, aLinesIn, bLinesUn, bLinesIn, put); + formatCommon( + nCommon, + aEnd, + bEnd, + aLinesIn, + bLinesUn, + bLinesIn, + options, + put, + ); aEnd += nCommon; bEnd += nCommon; return; } // The patch ends because context is less than number of common lines. - formatCommon(nContextLines, aEnd, bEnd, aLinesIn, bLinesUn, bLinesIn, put); + formatCommon( + nContextLines, + aEnd, + bEnd, + aLinesIn, + bLinesUn, + bLinesIn, + options, + put, + ); aEnd += nContextLines; bEnd += nContextLines; @@ -250,7 +283,16 @@ const diffNoExpand = ( aStart = aEndCommon - nLines; bStart = bEndCommon - nLines; - formatCommon(nLines, aStart, bStart, aLinesIn, bLinesUn, bLinesIn, put); + formatCommon( + nLines, + aStart, + bStart, + aLinesIn, + bLinesUn, + bLinesIn, + options, + put, + ); aEnd = aEndCommon; bEnd = bEndCommon; } @@ -260,8 +302,8 @@ const diffNoExpand = ( // If no common subsequence or last was not at end, format remaining change lines. if (!isAtEnd) { - formatDelete(aEnd, aLength, aLinesUn, aLinesIn, put); - formatInsert(bEnd, bLength, bLinesUn, bLinesIn, put); + formatDelete(aEnd, aLength, aLinesUn, aLinesIn, options, put); + formatInsert(bEnd, bLength, bLinesUn, bLinesIn, options, put); aEnd = aLength; bEnd = bLength; } @@ -278,7 +320,7 @@ const diffNoExpand = ( export default ( a: string, b: string, - options?: DiffOptions, + options: DiffOptionsNormalized, original?: Original, ): string => { if (a === b) { @@ -310,14 +352,8 @@ export default ( return ( printAnnotation(options) + - (options && options.expand === false - ? diffNoExpand( - aLinesUn, - bLinesUn, - aLinesIn, - bLinesIn, - getContextLines(options), - ) - : diffExpand(aLinesUn, bLinesUn, aLinesIn, bLinesIn)) + (options.expand + ? diffExpand(aLinesUn, bLinesUn, aLinesIn, bLinesIn, options) + : diffNoExpand(aLinesUn, bLinesUn, aLinesIn, bLinesIn, options)) ); }; diff --git a/packages/jest-diff/src/getAlignedDiffs.ts b/packages/jest-diff/src/getAlignedDiffs.ts index e653482e3880..19421ae5e06a 100644 --- a/packages/jest-diff/src/getAlignedDiffs.ts +++ b/packages/jest-diff/src/getAlignedDiffs.ts @@ -6,7 +6,7 @@ */ import {DIFF_DELETE, DIFF_INSERT, Diff} from './cleanupSemantic'; -import {MULTILINE_REGEXP, getHighlightedString} from './printDiffs'; +import {invertChangedSubstrings} from './printDiffs'; // Encapsulate change lines until either a common newline or the end. class ChangeBuffer { @@ -28,7 +28,7 @@ class ChangeBuffer { // Assume call only if line has at least one diff, // therefore an empty line must have a diff which has an empty string. this.lines.push( - new Diff(this.op, getHighlightedString(this.op, this.line)), + new Diff(this.op, invertChangedSubstrings(this.op, this.line)), ); this.line.length = 0; } @@ -46,7 +46,7 @@ class ChangeBuffer { align(diff: Diff): void { const string = diff[1]; - if (MULTILINE_REGEXP.test(string)) { + if (string.includes('\n')) { const substrings = string.split('\n'); const iLast = substrings.length - 1; substrings.forEach((substring, i) => { @@ -117,7 +117,7 @@ class CommonBuffer { const op = diff[0]; const string = diff[1]; - if (MULTILINE_REGEXP.test(string)) { + if (string.includes('\n')) { const substrings = string.split('\n'); const iLast = substrings.length - 1; substrings.forEach((substring, i) => { diff --git a/packages/jest-diff/src/index.ts b/packages/jest-diff/src/index.ts index 1391894ad135..df6cd5a140a9 100644 --- a/packages/jest-diff/src/index.ts +++ b/packages/jest-diff/src/index.ts @@ -8,10 +8,17 @@ import prettyFormat = require('pretty-format'); import chalk from 'chalk'; import getType = require('jest-get-type'); +import { + DIFF_DELETE, + DIFF_EQUAL, + DIFF_INSERT, + Diff as DiffClass, +} from './cleanupSemantic'; import diffLines from './diffLines'; -import {getStringDiff} from './printDiffs'; +import {normalizeDiffOptions} from './normalizeDiffOptions'; +import {diffStringsRaw, diffStringsUnified} from './printDiffs'; import {NO_DIFF_MESSAGE, SIMILAR_MESSAGE} from './constants'; -import {DiffOptions as JestDiffOptions} from './types'; +import {DiffOptionsNormalized, DiffOptions as JestDiffOptions} from './types'; const { AsymmetricMatcher, @@ -78,25 +85,26 @@ function diff(a: any, b: any, options?: JestDiffOptions): string | null { return null; } + const optionsNormalized = normalizeDiffOptions(options); switch (aType) { case 'string': - return diffLines(a, b, options); + return diffLines(a, b, optionsNormalized); case 'boolean': case 'number': - return comparePrimitive(a, b, options); + return comparePrimitive(a, b, optionsNormalized); case 'map': - return compareObjects(sortMap(a), sortMap(b), options); + return compareObjects(sortMap(a), sortMap(b), optionsNormalized); case 'set': - return compareObjects(sortSet(a), sortSet(b), options); + return compareObjects(sortSet(a), sortSet(b), optionsNormalized); default: - return compareObjects(a, b, options); + return compareObjects(a, b, optionsNormalized); } } function comparePrimitive( a: number | boolean, b: number | boolean, - options?: JestDiffOptions, + options: DiffOptionsNormalized, ) { return diffLines( prettyFormat(a, FORMAT_OPTIONS), @@ -116,7 +124,7 @@ function sortSet(set: Set) { function compareObjects( a: Record, b: Record, - options?: JestDiffOptions, + options: DiffOptionsNormalized, ) { let diffMessage; let hasThrown = false; @@ -157,9 +165,14 @@ function compareObjects( // eslint-disable-next-line no-redeclare namespace diff { + export type Diff = DiffClass; export type DiffOptions = JestDiffOptions; } -diff.getStringDiff = getStringDiff; +diff.diffStringsUnified = diffStringsUnified; +diff.diffStringsRaw = diffStringsRaw; +diff.DIFF_DELETE = DIFF_DELETE; +diff.DIFF_EQUAL = DIFF_EQUAL; +diff.DIFF_INSERT = DIFF_INSERT; export = diff; diff --git a/packages/jest-diff/src/joinAlignedDiffs.ts b/packages/jest-diff/src/joinAlignedDiffs.ts index 186f3c393e0b..db00e5720bc5 100644 --- a/packages/jest-diff/src/joinAlignedDiffs.ts +++ b/packages/jest-diff/src/joinAlignedDiffs.ts @@ -12,8 +12,7 @@ import { printDeleteLine, printInsertLine, } from './printDiffs'; - -const DIFF_CONTEXT_DEFAULT = 5; // same as diffLines +import {DiffOptionsNormalized} from './types'; // jest --no-expand // @@ -21,9 +20,10 @@ const DIFF_CONTEXT_DEFAULT = 5; // same as diffLines // return joined lines with diff formatting (and patch marks, if needed). export const joinAlignedDiffsNoExpand = ( diffs: Array, - nContextLines: number = DIFF_CONTEXT_DEFAULT, + options: DiffOptionsNormalized, ): string => { const iLength = diffs.length; + const nContextLines = options.contextLines; const nContextLines2 = nContextLines + nContextLines; // First pass: count output lines and see if it has patches. @@ -89,18 +89,18 @@ export const joinAlignedDiffsNoExpand = ( const pushCommonLine = (line: string): void => { const j = lines.length; - lines.push(printCommonLine(line, j === 0 || j === jLast)); + lines.push(printCommonLine(line, j === 0 || j === jLast, options)); aEnd += 1; bEnd += 1; }; const pushDeleteLine = (line: string): void => { - lines.push(printDeleteLine(line)); + lines.push(printDeleteLine(line, options)); aEnd += 1; }; const pushInsertLine = (line: string): void => { - lines.push(printInsertLine(line)); + lines.push(printInsertLine(line, options)); bEnd += 1; }; @@ -187,20 +187,27 @@ export const joinAlignedDiffsNoExpand = ( // // Given array of aligned strings with inverse highlight formatting, // return joined lines with diff formatting. -export const joinAlignedDiffsExpand = (diffs: Array) => +export const joinAlignedDiffsExpand = ( + diffs: Array, + options: DiffOptionsNormalized, +) => diffs .map((diff: Diff, i: number, diffs: Array): string => { const line = diff[1]; switch (diff[0]) { case DIFF_DELETE: - return printDeleteLine(line); + return printDeleteLine(line, options); case DIFF_INSERT: - return printInsertLine(line); + return printInsertLine(line, options); default: - return printCommonLine(line, i === 0 || i === diffs.length - 1); + return printCommonLine( + line, + i === 0 || i === diffs.length - 1, + options, + ); } }) .join('\n'); diff --git a/packages/jest-diff/src/normalizeDiffOptions.ts b/packages/jest-diff/src/normalizeDiffOptions.ts new file mode 100644 index 000000000000..1430d7041d73 --- /dev/null +++ b/packages/jest-diff/src/normalizeDiffOptions.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import chalk from 'chalk'; + +import {DiffOptions, DiffOptionsNormalized} from './types'; + +const DIFF_CONTEXT_DEFAULT = 5; + +const OPTIONS_DEFAULT: DiffOptionsNormalized = { + aAnnotation: 'Expected', + aColor: chalk.green, + aSymbol: '-', + bAnnotation: 'Received', + bColor: chalk.red, + bSymbol: '+', + commonColor: chalk.dim, + commonSymbol: ' ', + contextLines: DIFF_CONTEXT_DEFAULT, + expand: true, + omitAnnotationLines: false, +}; + +const getContextLines = (contextLines?: number): number => + typeof contextLines === 'number' && + Number.isSafeInteger(contextLines) && + contextLines >= 0 + ? contextLines + : DIFF_CONTEXT_DEFAULT; + +// Pure function returns options with all properties. +export const normalizeDiffOptions = ( + options: DiffOptions = {}, +): DiffOptionsNormalized => ({ + ...OPTIONS_DEFAULT, + ...options, + contextLines: getContextLines(options.contextLines), +}); diff --git a/packages/jest-diff/src/printDiffs.ts b/packages/jest-diff/src/printDiffs.ts index de210cf950a5..07d6c4d2ad4f 100644 --- a/packages/jest-diff/src/printDiffs.ts +++ b/packages/jest-diff/src/printDiffs.ts @@ -7,32 +7,28 @@ import chalk from 'chalk'; -import { - DIFF_DELETE, - DIFF_EQUAL, - DIFF_INSERT, - Diff, - cleanupSemantic, -} from './cleanupSemantic'; +import {DIFF_EQUAL, Diff, cleanupSemantic} from './cleanupSemantic'; +import diffLines from './diffLines'; import diffStrings from './diffStrings'; import getAlignedDiffs from './getAlignedDiffs'; import { joinAlignedDiffsExpand, joinAlignedDiffsNoExpand, } from './joinAlignedDiffs'; -import {DiffOptions} from './types'; +import {normalizeDiffOptions} from './normalizeDiffOptions'; +import {DiffOptions, DiffOptionsNormalized} from './types'; -export const DIM_COLOR = chalk.dim; -export const EXPECTED_COLOR = chalk.green; -export const INVERTED_COLOR = chalk.inverse; -export const RECEIVED_COLOR = chalk.red; +export const INVERTED_COLOR = chalk.inverse; // export for joinAlignedDiffs test const PATCH_COLOR = chalk.yellow; // Given change op and array of diffs, return concatenated string: // * include common strings // * include change strings which have argument op (inverse highlight) // * exclude change strings which have opposite op -export const getHighlightedString = (op: number, diffs: Array): string => +export const invertChangedSubstrings = ( + op: number, + diffs: Array, +): string => diffs.reduce( (reduced: string, diff: Diff): string => reduced + @@ -44,14 +40,6 @@ export const getHighlightedString = (op: number, diffs: Array): string => '', ); -export const getExpectedString = (diffs: Array): string => - getHighlightedString(DIFF_DELETE, diffs); - -export const getReceivedString = (diffs: Array): string => - getHighlightedString(DIFF_INSERT, diffs); - -export const MULTILINE_REGEXP = /\n/; - const NEWLINE_SYMBOL = '\u{21B5}'; // downwards arrow with corner leftwards const SPACE_SYMBOL = '\u{00B7}'; // middle dot @@ -60,36 +48,34 @@ const SPACE_SYMBOL = '\u{00B7}'; // middle dot const replaceSpacesAtEnd = (line: string): string => line.replace(/\s+$/, spaces => SPACE_SYMBOL.repeat(spaces.length)); -export const printDeleteLine = (line: string) => - EXPECTED_COLOR(line.length !== 0 ? '- ' + replaceSpacesAtEnd(line) : '-'); +export const printDeleteLine = ( + line: string, + {aColor, aSymbol}: DiffOptionsNormalized, +): string => + aColor( + line.length !== 0 ? aSymbol + ' ' + replaceSpacesAtEnd(line) : aSymbol, + ); -export const printInsertLine = (line: string) => - RECEIVED_COLOR(line.length !== 0 ? '+ ' + replaceSpacesAtEnd(line) : '+'); +export const printInsertLine = ( + line: string, + {bColor, bSymbol}: DiffOptionsNormalized, +): string => + bColor( + line.length !== 0 ? bSymbol + ' ' + replaceSpacesAtEnd(line) : bSymbol, + ); // Prevent visually ambiguous empty line as the first or the last. -export const printCommonLine = (line: string, isFirstOrLast: boolean = false) => +export const printCommonLine = ( + line: string, + isFirstOrLast: boolean, + {commonColor, commonSymbol}: DiffOptionsNormalized, +): string => line.length !== 0 - ? DIM_COLOR(' ' + replaceSpacesAtEnd(line)) + ? commonColor(commonSymbol + ' ' + replaceSpacesAtEnd(line)) : isFirstOrLast - ? DIM_COLOR(' ' + NEWLINE_SYMBOL) + ? commonColor(commonSymbol + ' ' + NEWLINE_SYMBOL) : ''; -export const computeStringDiffs = (expected: string, received: string) => { - const isMultiline = - MULTILINE_REGEXP.test(expected) || MULTILINE_REGEXP.test(received); - - // getAlignedDiffs assumes that a newline was appended to the strings. - if (isMultiline) { - expected += '\n'; - received += '\n'; - } - - const diffs = diffStrings(expected, received); - cleanupSemantic(diffs); // impure function - - return {diffs, isMultiline}; -}; - export const hasCommonDiff = (diffs: Array, isMultiline: boolean) => { if (isMultiline) { // Important: Ignore common newline that was appended to multiline strings! @@ -102,11 +88,21 @@ export const hasCommonDiff = (diffs: Array, isMultiline: boolean) => { return diffs.some(diff => diff[0] === DIFF_EQUAL); }; -export const printAnnotation = (options?: DiffOptions): string => - EXPECTED_COLOR('- ' + ((options && options.aAnnotation) || 'Expected')) + - '\n' + - RECEIVED_COLOR('+ ' + ((options && options.bAnnotation) || 'Received')) + - '\n\n'; +export const printAnnotation = ({ + aAnnotation, + aColor, + aSymbol, + bAnnotation, + bColor, + bSymbol, + omitAnnotationLines, +}: DiffOptionsNormalized): string => + omitAnnotationLines + ? '' + : aColor(aSymbol + ' ' + aAnnotation) + + '\n' + + bColor(bSymbol + ' ' + bAnnotation) + + '\n\n'; // In GNU diff format, indexes are one-based instead of zero-based. export const createPatchMark = ( @@ -119,63 +115,88 @@ export const createPatchMark = ( `@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`, ); -// Return formatted diff lines without labels. -export const printMultilineStringDiffs = ( - diffs: Array, - expand: boolean, +// Given two string arguments, compare them character-by-character. +// Format as comparison lines in which changed substrings have inverse colors. +export const diffStringsUnified = ( + a: string, + b: string, + options?: DiffOptions, ): string => { - const lines = getAlignedDiffs(diffs); - return expand - ? joinAlignedDiffsExpand(lines) - : joinAlignedDiffsNoExpand(lines); -}; + const optionsNormalized = normalizeDiffOptions(options); + + if (a.length === 0 || b.length === 0) { + const lines: Array = []; + + // All comparison lines have aColor and aSymbol. + if (a.length !== 0) { + a.split('\n').forEach(line => { + lines.push(printDeleteLine(line, optionsNormalized)); + }); + } + + // All comparison lines have bColor and bSymbol. + if (b.length !== 0) { + b.split('\n').forEach(line => { + lines.push(printInsertLine(line, optionsNormalized)); + }); + } + + // If both are empty strings, there are no comparison lines. + return printAnnotation(optionsNormalized) + lines.join('\n'); + } -const MAX_DIFF_STRING_LENGTH = 20000; - -type StringDiffResult = - | {isMultiline: true; annotatedDiff: string} - | {isMultiline: false; a: string; b: string} - | null; - -// Print specific substring diff for strings only: -// * if strings are not equal -// * if neither string is empty -// * if neither string is too long -// * if there is a common string after semantic cleanup -export const getStringDiff = ( - expected: string, - received: string, - options?: DiffOptions, -): StringDiffResult => { - if ( - expected === received || - expected.length === 0 || - received.length === 0 || - expected.length > MAX_DIFF_STRING_LENGTH || - received.length > MAX_DIFF_STRING_LENGTH - ) { - return null; + if (a === b) { + const lines = a.split('\n'); + const iLast = lines.length - 1; + + // All comparison lines have commonColor and commonSymbol. + return ( + printAnnotation(optionsNormalized) + + lines + .map((line, i) => + printCommonLine(line, i === 0 || i === iLast, optionsNormalized), + ) + .join('\n') + ); } - const {diffs, isMultiline} = computeStringDiffs(expected, received); + const isMultiline = a.includes('\n') || b.includes('\n'); + + // getAlignedDiffs assumes that a newline was appended to the strings. + const diffs = diffStringsRaw( + isMultiline ? a + '\n' : a, + isMultiline ? b + '\n' : b, + true, // cleanupSemantic + ); + + if (hasCommonDiff(diffs, isMultiline)) { + const lines = getAlignedDiffs(diffs); + return ( + printAnnotation(optionsNormalized) + + (optionsNormalized.expand + ? joinAlignedDiffsExpand(lines, optionsNormalized) + : joinAlignedDiffsNoExpand(lines, optionsNormalized)) + ); + } + + // Fall back to line-by-line diff. + // Given strings, it returns a string, not null. + return diffLines(a, b, optionsNormalized) as string; +}; - if (!hasCommonDiff(diffs, isMultiline)) { - return null; +// Given two string arguments, compare them character-by-character. +// Optionally clean up small common substrings, also known as chaff. +// Return an array of diff objects. +export const diffStringsRaw = ( + a: string, + b: string, + cleanup: boolean, +): Array => { + const diffs = diffStrings(a, b); + + if (cleanup) { + cleanupSemantic(diffs); // impure function } - return isMultiline - ? { - annotatedDiff: - printAnnotation(options) + - printMultilineStringDiffs( - diffs, - options === undefined || options.expand !== false, - ), - isMultiline, - } - : { - a: getExpectedString(diffs), - b: getReceivedString(diffs), - isMultiline, - }; + return diffs; }; diff --git a/packages/jest-diff/src/types.ts b/packages/jest-diff/src/types.ts index 66edf604d0c0..2ffd17280fdd 100644 --- a/packages/jest-diff/src/types.ts +++ b/packages/jest-diff/src/types.ts @@ -5,9 +5,32 @@ * LICENSE file in the root directory of this source tree. */ +type DiffOptionsColor = (arg: string) => string; // subset of Chalk type + export type DiffOptions = { aAnnotation?: string; + aColor?: DiffOptionsColor; + aSymbol?: string; bAnnotation?: string; - expand?: boolean; + bColor?: DiffOptionsColor; + bSymbol?: string; + commonColor?: DiffOptionsColor; + commonSymbol?: string; contextLines?: number; + expand?: boolean; + omitAnnotationLines?: boolean; +}; + +export type DiffOptionsNormalized = { + aAnnotation: string; + aColor: DiffOptionsColor; + aSymbol: string; + bAnnotation: string; + bColor: DiffOptionsColor; + bSymbol: string; + commonColor: DiffOptionsColor; + commonSymbol: string; + contextLines: number; + expand: boolean; + omitAnnotationLines: boolean; }; diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap index 4a197fa3dce2..ccb387fcd85c 100644 --- a/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap +++ b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap @@ -27,3 +27,18 @@ exports[`printDiffOrStringify expected is multi line and received is empty 1`] = line\\" Received: \\"\\"" `; + +exports[`printDiffOrStringify has no common after clean up chaff multiline 1`] = ` +"- Expected ++ Received + +- delete +- two ++ insert ++ 2" +`; + +exports[`printDiffOrStringify has no common after clean up chaff one-line 1`] = ` +"Expected: \\"delete\\" +Received: \\"insert\\"" +`; diff --git a/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts b/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts index a5c513405301..a8e985dd5329 100644 --- a/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts +++ b/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts @@ -35,6 +35,18 @@ describe('printDiffOrStringify', () => { expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); }); + test('has no common after clean up chaff multiline', () => { + const expected = 'delete\ntwo'; + const received = 'insert\n2'; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('has no common after clean up chaff one-line', () => { + const expected = 'delete'; + const received = 'insert'; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + test('received is multiline longer than max', () => { const expected = 'multi\nline'; const received = 'multi' + '\n123456789'.repeat(2000); // 5 + 20K chars diff --git a/packages/jest-matcher-utils/src/index.ts b/packages/jest-matcher-utils/src/index.ts index 81e4ef31cabb..aba7ff29812e 100644 --- a/packages/jest-matcher-utils/src/index.ts +++ b/packages/jest-matcher-utils/src/index.ts @@ -10,6 +10,14 @@ import jestDiff = require('jest-diff'); import getType = require('jest-get-type'); import prettyFormat = require('pretty-format'); +const { + DIFF_DELETE, + DIFF_EQUAL, + DIFF_INSERT, + diffStringsRaw, + diffStringsUnified, +} = jestDiff; + const { AsymmetricMatcher, DOMCollection, @@ -212,6 +220,29 @@ export const ensureExpectedIsNonNegativeInteger = ( } }; +// Given array of diffs, return concatenated string: +// * include common substrings +// * exclude change substrings which have opposite op +// * include change substrings which have argument op +// with inverse highlight only if there is a common substring +const getCommonAndChangedSubstrings = ( + diffs: Array, + op: number, + hasCommonDiff: boolean, +): string => + diffs.reduce( + (reduced: string, diff: jestDiff.Diff): string => + reduced + + (diff[0] === DIFF_EQUAL + ? diff[1] + : diff[0] !== op + ? '' + : hasCommonDiff + ? INVERTED_COLOR(diff[1]) + : diff[1]), + '', + ); + const isLineDiffable = (expected: unknown, received: unknown): boolean => { const expectedType = getType(expected); const receivedType = getType(received); @@ -262,6 +293,8 @@ const isLineDiffable = (expected: unknown, received: unknown): boolean => { return true; }; +const MAX_DIFF_STRING_LENGTH = 20000; + export const printDiffOrStringify = ( expected: unknown, received: unknown, @@ -269,24 +302,39 @@ export const printDiffOrStringify = ( receivedLabel: string, expand: boolean, // CLI options: true if `--expand` or false if `--no-expand` ): string => { - if (typeof expected === 'string' && typeof received === 'string') { - const result = jestDiff.getStringDiff(expected, received, { - aAnnotation: expectedLabel, - bAnnotation: receivedLabel, - expand, - }); - - if (result !== null) { - if (result.isMultiline) { - return result.annotatedDiff; - } - - const printLabel = getLabelPrinter(expectedLabel, receivedLabel); - const expectedLine = printLabel(expectedLabel) + printExpected(result.a); - const receivedLine = printLabel(receivedLabel) + printReceived(result.b); - - return expectedLine + '\n' + receivedLine; + 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(expected, received, { + aAnnotation: expectedLabel, + bAnnotation: receivedLabel, + expand, + }); } + + const diffs = diffStringsRaw(expected, received, true); + const hasCommonDiff = diffs.some(diff => diff[0] === DIFF_EQUAL); + + const printLabel = getLabelPrinter(expectedLabel, receivedLabel); + const expectedLine = + printLabel(expectedLabel) + + printExpected( + getCommonAndChangedSubstrings(diffs, DIFF_DELETE, hasCommonDiff), + ); + const receivedLine = + printLabel(receivedLabel) + + printReceived( + getCommonAndChangedSubstrings(diffs, DIFF_INSERT, hasCommonDiff), + ); + + return expectedLine + '\n' + receivedLine; } if (isLineDiffable(expected, received)) { diff --git a/packages/jest-snapshot/src/__tests__/__snapshots__/printDiffOrStringified.test.ts.snap b/packages/jest-snapshot/src/__tests__/__snapshots__/printDiffOrStringified.test.ts.snap index 8c7ff72d1c25..7c00b08fe37b 100644 --- a/packages/jest-snapshot/src/__tests__/__snapshots__/printDiffOrStringified.test.ts.snap +++ b/packages/jest-snapshot/src/__tests__/__snapshots__/printDiffOrStringified.test.ts.snap @@ -105,6 +105,23 @@ exports[`fallback to line diff 1`] = ` + ================================================================================ `; +exports[`has no common after clean up chaff array 1`] = ` +- Snapshot ++ Received + + Array [ +- "delete", +- "two", ++ "insert", ++ "2", + ] +`; + +exports[`has no common after clean up chaff string single line 1`] = ` +Snapshot: "delete" +Received: "insert" +`; + exports[`isLineDiffable false boolean 1`] = ` Snapshot: true Received: false @@ -209,8 +226,29 @@ exports[`without serialize backtick single line expected and multi line received `; exports[`without serialize backtick single line expected and received 1`] = ` -Snapshot: var foo = \`backtick\`; -Received: var foo = \`back\${x}tick\`; +- Snapshot ++ Received + +- var foo = \`backtick\`; ++ var foo = \`back\${x}tick\`; +`; + +exports[`without serialize has no common after clean up chaff multi line 1`] = ` +- Snapshot ++ Received + +- delete +- two ++ insert ++ 2 +`; + +exports[`without serialize has no common after clean up chaff single line 1`] = ` +- Snapshot ++ Received + +- delete ++ insert `; exports[`without serialize prettier/pull/5590 1`] = ` diff --git a/packages/jest-snapshot/src/__tests__/printDiffOrStringified.test.ts b/packages/jest-snapshot/src/__tests__/printDiffOrStringified.test.ts index b7c17d30990c..e9bcfc5caab9 100644 --- a/packages/jest-snapshot/src/__tests__/printDiffOrStringified.test.ts +++ b/packages/jest-snapshot/src/__tests__/printDiffOrStringified.test.ts @@ -231,6 +231,22 @@ test('fallback to line diff', () => { expect(testWithSerialize(expected, received, false)).toMatchSnapshot(); }); +describe('has no common after clean up chaff', () => { + test('array', () => { + const expected = ['delete', 'two']; + const received = ['insert', '2']; + + expect(testWithSerialize(expected, received, false)).toMatchSnapshot(); + }); + + test('string single line', () => { + const expected = 'delete'; + const received = 'insert'; + + expect(testWithSerialize(expected, received, false)).toMatchSnapshot(); + }); +}); + describe('isLineDiffable', () => { describe('false', () => { test('boolean', () => { @@ -361,6 +377,20 @@ describe('without serialize', () => { expect(testWithoutSerialize(expected, received, false)).toMatchSnapshot(); }); + test('has no common after clean up chaff multi line', () => { + const expected = 'delete\ntwo'; + const received = 'insert\n2'; + + expect(testWithoutSerialize(expected, received, false)).toMatchSnapshot(); + }); + + test('has no common after clean up chaff single line', () => { + const expected = 'delete'; + const received = 'insert'; + + expect(testWithoutSerialize(expected, received, false)).toMatchSnapshot(); + }); + test('prettier/pull/5590', () => { const expected = [ '====================================options=====================================', diff --git a/packages/jest-snapshot/src/print.ts b/packages/jest-snapshot/src/print.ts index fce9ad2c7f3a..4f0516f91bf2 100644 --- a/packages/jest-snapshot/src/print.ts +++ b/packages/jest-snapshot/src/print.ts @@ -45,6 +45,8 @@ const isLineDiffable = (received: any): boolean => { return true; }; +const MAX_DIFF_STRING_LENGTH = 20000; + export const printDiffOrStringified = ( expectedSerializedTrimmed: string, receivedSerializedTrimmed: string, @@ -77,32 +79,21 @@ export const printDiffOrStringified = ( } // Display substring highlight even when strings have custom serialization. - const result = diff.getStringDiff( - expectedSerializedTrimmed, - receivedSerializedTrimmed, - { - aAnnotation: expectedLabel, - bAnnotation: receivedLabel, - expand, - }, - ); - - if (result !== null) { - if (result.isMultiline) { - return result.annotatedDiff; - } - - // Because not default stringify, call EXPECTED_COLOR and RECEIVED_COLOR - // This is reason to call getStringDiff instead of printDiffOrStringify - // Because there is no closing double quote mark at end of single lines, - // future improvement is to call replaceSpacesAtEnd if it becomes public. - const printLabel = getLabelPrinter(expectedLabel, receivedLabel); - return ( - printLabel(expectedLabel) + - EXPECTED_COLOR(result.a) + - '\n' + - printLabel(receivedLabel) + - RECEIVED_COLOR(result.b) + if ( + expectedSerializedTrimmed.length !== 0 && + receivedSerializedTrimmed.length !== 0 && + expectedSerializedTrimmed.length <= MAX_DIFF_STRING_LENGTH && + receivedSerializedTrimmed.length <= MAX_DIFF_STRING_LENGTH && + expectedSerializedTrimmed !== receivedSerializedTrimmed + ) { + return diff.diffStringsUnified( + expectedSerializedTrimmed, + receivedSerializedTrimmed, + { + aAnnotation: expectedLabel, + bAnnotation: receivedLabel, + expand, + }, ); } }