From c295d3fb44175306a4c49b51c30c9b6fabb5471b Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Mon, 3 Jun 2019 18:05:10 -0400 Subject: [PATCH 1/7] expect: Highlight substring differences when matcher fails, part 2 --- .../__snapshots__/matchers.test.js.snap | 72 +++++- .../toThrowMatchers.test.js.snap | 24 ++ .../expect/src/__tests__/matchers.test.js | 51 +++- .../src/__tests__/toThrowMatchers.test.js | 14 +- packages/expect/src/toThrowMatchers.ts | 18 +- .../getAlignedDiffs.test.ts.snap | 160 +++++++++++++ .../joinAlignedDiffs.test.ts.snap | 160 +++++++++++++ .../__snapshots__/printDiffs.test.ts.snap | 38 +++ .../src/__tests__/getAlignedDiffs.test.ts | 171 ++++++++++++++ .../src/__tests__/joinAlignedDiffs.test.ts | 80 +++++++ .../src/__tests__/printDiffs.test.ts | 66 ++++++ .../jest-matcher-utils/src/diffStrings.ts | 17 +- .../jest-matcher-utils/src/getAlignedDiffs.ts | 196 ++++++++++++++++ packages/jest-matcher-utils/src/index.ts | 91 +++++--- .../src/joinAlignedDiffs.ts | 217 ++++++++++++++++++ packages/jest-matcher-utils/src/printDiff.ts | 36 --- packages/jest-matcher-utils/src/printDiffs.ts | 99 ++++++++ 17 files changed, 1416 insertions(+), 94 deletions(-) create mode 100644 packages/jest-matcher-utils/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap create mode 100644 packages/jest-matcher-utils/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap create mode 100644 packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffs.test.ts.snap create mode 100644 packages/jest-matcher-utils/src/__tests__/getAlignedDiffs.test.ts create mode 100644 packages/jest-matcher-utils/src/__tests__/joinAlignedDiffs.test.ts create mode 100644 packages/jest-matcher-utils/src/__tests__/printDiffs.test.ts create mode 100644 packages/jest-matcher-utils/src/getAlignedDiffs.ts create mode 100644 packages/jest-matcher-utils/src/joinAlignedDiffs.ts delete mode 100644 packages/jest-matcher-utils/src/printDiff.ts create mode 100644 packages/jest-matcher-utils/src/printDiffs.ts diff --git a/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap b/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap index 2de490df82a3..38ef42eebc21 100644 --- a/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap +++ b/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap @@ -303,9 +303,9 @@ string" 1`] = ` - Expected + Received -- 3 -+ four -+ 4 +- 3 ++ four ++ 4 line string" `; @@ -321,9 +321,12 @@ exports[`.toBe() fails for: "with trailing space" and "without trailing space" 1`] = ` "expect(received).toBe(expected) // Object.is equality -Expected: \\"without trailing space\\" -Received: \\"with -trailing space\\"" +- Expected ++ Received + +- without trailing space ++ with· ++ trailing space" `; exports[`.toBe() fails for: /received/ and /expected/ 1`] = ` @@ -1971,6 +1974,20 @@ Expected: \\"apple\\" Received: \\"banana\\"" `; +exports[`.toEqual() {pass: false} expect("type TypeName = T extends Function ? \\"function\\" : \\"object\\";").toEqual("type TypeName = T extends Function +? \\"function\\" +: \\"object\\";") 1`] = ` +"expect(received).toEqual(expected) // deep equality + +- Expected ++ Received + +- type TypeName = T extends Function +- ? \\"function\\" +- : \\"object\\"; ++ type TypeName = T extends Function ? \\"function\\" : \\"object\\";" +`; + exports[`.toEqual() {pass: false} expect([1, 2]).toEqual([2, 1]) 1`] = ` "expect(received).toEqual(expected) // deep equality @@ -3080,6 +3097,26 @@ Expected value: \\"\\\\\\"That cat cartoon\\\\\\"\\" Received value: \\"\\\\\\"That cartoon\\\\\\"\\"" `; +exports[`.toHaveProperty() {pass: false} expect({"children": ["Roses are red. +Violets are blue. +Testing with Jest is good for you."], "props": null, "type": "pre"}).toHaveProperty('children,0', "Roses are red, violets are blue. +Testing with Jest +Is good for you.") 1`] = ` +"expect(received).toHaveProperty(path, value) + +Expected path: [\\"children\\", 0] + +- Expected value ++ Received value + +- Roses are red, violets are blue. ++ Roses are red. ++ Violets are blue. +- Testing with Jest +- Is good for you. ++ Testing with Jest is good for you." +`; + exports[`.toHaveProperty() {pass: false} expect({"key": 1}).toHaveProperty('not') 1`] = ` "expect(received).toHaveProperty(path) @@ -3527,6 +3564,29 @@ Expected: \\"Another caveat is that Jest will not typecheck y Received: \\"Because TypeScript support in Babel is just transpilation, Jest will not type-check your tests as they run.\\"" `; +exports[`.toStrictEqual() displays substring diff for multiple lines 1`] = ` +"expect(received).toStrictEqual(expected) // deep equality + +- Expected ++ Received + +- 69 |· ++ 68 |· +- 70 | test('assert.doesNotThrow', () => { ++ 69 | test('assert.doesNotThrow', () => { +- > 71 | assert.doesNotThrow(() => { ++ > 70 | assert.doesNotThrow(() => { + | ^ +- 72 | throw Error('err!'); ++ 71 | throw Error('err!'); +- 73 | }); ++ 72 | }); +- 74 | }); ++ 73 | }); +- at Object.doesNotThrow (__tests__/assertionError.test.js:71:10) ++ at Object.doesNotThrow (__tests__/assertionError.test.js:70:10)" +`; + exports[`.toStrictEqual() matches the expected snapshot when it fails 1`] = ` "expect(received).toStrictEqual(expected) // deep equality diff --git a/packages/expect/src/__tests__/__snapshots__/toThrowMatchers.test.js.snap b/packages/expect/src/__tests__/__snapshots__/toThrowMatchers.test.js.snap index f282a7b4e0df..578542be4fa1 100644 --- a/packages/expect/src/__tests__/__snapshots__/toThrowMatchers.test.js.snap +++ b/packages/expect/src/__tests__/__snapshots__/toThrowMatchers.test.js.snap @@ -161,6 +161,18 @@ Expected message: not \\"Invalid array length\\" " `; +exports[`toThrow error-message fail multiline diff highlight incorrect expected space 1`] = ` +"expect(received).toThrow(expected) + +- Expected message ++ Received message + +- There is no route defined for key Settings. ++ There is no route defined for key Settings. + Must be one of: 'Home' +" +`; + exports[`toThrow expected is undefined threw, but should not have (non-error falsey) 1`] = ` "expect(received).not.toThrow() @@ -457,6 +469,18 @@ Expected message: not \\"Invalid array length\\" " `; +exports[`toThrowError error-message fail multiline diff highlight incorrect expected space 1`] = ` +"expect(received).toThrowError(expected) + +- Expected message ++ Received message + +- There is no route defined for key Settings. ++ There is no route defined for key Settings. + Must be one of: 'Home' +" +`; + exports[`toThrowError expected is undefined threw, but should not have (non-error falsey) 1`] = ` "expect(received).not.toThrowError() diff --git a/packages/expect/src/__tests__/matchers.test.js b/packages/expect/src/__tests__/matchers.test.js index de742ae50bcf..9b5661339c0f 100644 --- a/packages/expect/src/__tests__/matchers.test.js +++ b/packages/expect/src/__tests__/matchers.test.js @@ -3,7 +3,6 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - * */ const {stringify} = require('jest-matcher-utils'); @@ -330,6 +329,32 @@ describe('.toStrictEqual()', () => { ).toThrowErrorMatchingSnapshot(); }); + it('displays substring diff for multiple lines', () => { + const expected = [ + ' 69 | ', + " 70 | test('assert.doesNotThrow', () => {", + ' > 71 | assert.doesNotThrow(() => {', + ' | ^', + " 72 | throw Error('err!');", + ' 73 | });', + ' 74 | });', + ' at Object.doesNotThrow (__tests__/assertionError.test.js:71:10)', + ].join('\n'); + const received = [ + ' 68 | ', + " 69 | test('assert.doesNotThrow', () => {", + ' > 70 | assert.doesNotThrow(() => {', + ' | ^', + " 71 | throw Error('err!');", + ' 72 | });', + ' 73 | });', + ' at Object.doesNotThrow (__tests__/assertionError.test.js:70:10)', + ].join('\n'); + expect(() => + jestExpect(received).toStrictEqual(expected), + ).toThrowErrorMatchingSnapshot(); + }); + it('does not pass for different types', () => { expect({ test: new TestClassA(1, 2), @@ -371,6 +396,10 @@ describe('.toEqual()', () => { [{a: 5}, {b: 6}], ['banana', 'apple'], ['1\u{00A0}234,57\u{00A0}$', '1 234,57 $'], // issues/6881 + [ + 'type TypeName = T extends Function ? "function" : "object";', + 'type TypeName = T extends Function\n? "function"\n: "object";', + ], [null, undefined], [[1], [2]], [[1, 2], [2, 1]], @@ -1361,13 +1390,24 @@ describe('.toHaveProperty()', () => { const memoized = function() {}; memoized.memo = []; - const receivedDiff = { + const pathDiff = ['children', 0]; + + const receivedDiffSingle = { children: ['"That cartoon"'], props: null, type: 'p', }; - const pathDiff = ['children', 0]; - const valueDiff = '"That cat cartoon"'; + const valueDiffSingle = '"That cat cartoon"'; + + const receivedDiffMultiple = { + children: [ + 'Roses are red.\nViolets are blue.\nTesting with Jest is good for you.', + ], + props: null, + type: 'pre', + }; + const valueDiffMultiple = + 'Roses are red, violets are blue.\nTesting with Jest\nIs good for you.'; [ [{a: {b: {c: {d: 1}}}}, 'a.b.c.d', 1], @@ -1405,7 +1445,8 @@ describe('.toHaveProperty()', () => { [{a: {b: {c: {d: 1}}}}, 'a.b.c.d', 2], [{'a.b.c.d': 1}, 'a.b.c.d', 2], [{'a.b.c.d': 1}, ['a.b.c.d'], 2], - [receivedDiff, pathDiff, valueDiff], + [receivedDiffSingle, pathDiff, valueDiffSingle], + [receivedDiffMultiple, pathDiff, valueDiffMultiple], [{a: {b: {c: {d: 1}}}}, ['a', 'b', 'c', 'd'], 2], [{a: {b: {c: {}}}}, 'a.b.c.d', 1], [{a: 1}, 'a.b.c.d', 5], diff --git a/packages/expect/src/__tests__/toThrowMatchers.test.js b/packages/expect/src/__tests__/toThrowMatchers.test.js index 77c80dc1f738..10fc0fb25a01 100644 --- a/packages/expect/src/__tests__/toThrowMatchers.test.js +++ b/packages/expect/src/__tests__/toThrowMatchers.test.js @@ -3,7 +3,6 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - * */ 'use strict'; @@ -266,6 +265,19 @@ class customError extends Error { }).not[toThrow]({message}), ).toThrowErrorMatchingSnapshot(); }); + + test('multiline diff highlight incorrect expected space', () => { + // jest/issues/2673 + const a = + "There is no route defined for key Settings. \nMust be one of: 'Home'"; + const b = + "There is no route defined for key Settings.\nMust be one of: 'Home'"; + expect(() => + jestExpect(() => { + throw new ErrorMessage(b); + })[toThrow]({message: a}), + ).toThrowErrorMatchingSnapshot(); + }); }); }); diff --git a/packages/expect/src/toThrowMatchers.ts b/packages/expect/src/toThrowMatchers.ts index f1e6c49f42bd..7d84c2e808af 100644 --- a/packages/expect/src/toThrowMatchers.ts +++ b/packages/expect/src/toThrowMatchers.ts @@ -12,6 +12,7 @@ import { RECEIVED_COLOR, matcherErrorMessage, matcherHint, + printDiffOrStringify, printExpected, printReceived, printWithType, @@ -231,13 +232,22 @@ const toThrowExpectedObject = ( : () => matcherHint(matcherName, undefined, undefined, options) + '\n\n' + - formatExpected('Expected message: ', expected.message) + (thrown === null - ? '\n' + DID_NOT_THROW + ? formatExpected('Expected message: ', expected.message) + + '\n' + + DID_NOT_THROW : thrown.hasMessage - ? formatReceived('Received message: ', thrown, 'message') + + ? printDiffOrStringify( + expected.message, + thrown.message, + 'Expected message', + 'Received message', + true, + ) + + '\n' + formatStack(thrown) - : formatReceived('Received value: ', thrown, 'value')); + : formatExpected('Expected message: ', expected.message) + + formatReceived('Received value: ', thrown, 'value')); return {message, pass}; }; diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap b/packages/jest-matcher-utils/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap new file mode 100644 index 000000000000..bc62b6fff2c1 --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap @@ -0,0 +1,160 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getAlignedDiffs lines change preceding and following common 1`] = ` +"- delete ++ insert + common between changes +- prev ++ next" +`; + +exports[`getAlignedDiffs lines common at end when both current change lines are empty 1`] = ` +"- delete + common at end" +`; + +exports[`getAlignedDiffs lines common between delete and insert 1`] = ` +"- delete + common between changes ++ insert" +`; + +exports[`getAlignedDiffs lines common between insert and delete 1`] = ` +"+ insert + common between changes +- delete" +`; + +exports[`getAlignedDiffs lines common preceding and following change 1`] = ` +" common preceding +- delete ++ insert + common following" +`; + +exports[`getAlignedDiffs newline change from space 1`] = ` +"- preceding following ++ preceding ++ following" +`; + +exports[`getAlignedDiffs newline change to space 1`] = ` +"- preceding +- following ++ preceding following" +`; + +exports[`getAlignedDiffs newline delete only 1`] = ` +"- preceding +- following ++ precedingfollowing" +`; + +exports[`getAlignedDiffs newline delete with adjacent change 1`] = ` +"- preceding +- following ++ precededfollowing" +`; + +exports[`getAlignedDiffs newline insert only 1`] = ` +"- precedingfollowing ++ preceding ++ following" +`; + +exports[`getAlignedDiffs newline insert with adjacent changes 1`] = ` +"- precededfollowing ++ preceding ++ Following" +`; + +exports[`getAlignedDiffs strings change at start and delete or insert at end 1`] = ` +"- prev change common delete ++ next change common + unchanged +- expected change common ++ received change common insert" +`; + +exports[`getAlignedDiffs strings delete or insert at start and change at end 1`] = ` +"- common change prev ++ insert common change next + unchanged +- delete common change this ++ common change that" +`; + +exports[`getAlignedDiffs substrings first common when both current change lines are empty 1`] = ` +"+ insert + first + middle +- last prev ++ last next" +`; + +exports[`getAlignedDiffs substrings first common when either current change line is non-empty 1`] = ` +"- expected first ++ first + + last" +`; + +exports[`getAlignedDiffs substrings first delete completes the current line 1`] = ` +"- common preceding first +- middle +- last and following ++ common preceding and following" +`; + +exports[`getAlignedDiffs substrings first insert completes the current line 1`] = ` +"- common preceding ++ common preceding first ++ middle ++" +`; + +exports[`getAlignedDiffs substrings last is empty in delete at end 1`] = ` +"- common string preceding prev +- ++ common string preceding next" +`; + +exports[`getAlignedDiffs substrings last is empty in insert at end 1`] = ` +"- common string preceding prev ++ common string preceding next ++" +`; + +exports[`getAlignedDiffs substrings last is non-empty in common not at end 1`] = ` +" common first +- last expected ++ last received" +`; + +exports[`getAlignedDiffs substrings middle is empty in delete between common 1`] = ` +"- common at start precedes delete +- +- expected common at end ++ common at start precedes received common at end" +`; + +exports[`getAlignedDiffs substrings middle is empty in insert at start 1`] = ` +"- expected common at end ++ insert line ++ ++ received common at end" +`; + +exports[`getAlignedDiffs substrings middle is non-empty in delete at end 1`] = ` +"- common at start precedes delete +- non-empty line +- next ++ common at start precedes prev" +`; + +exports[`getAlignedDiffs substrings middle is non-empty in insert between common 1`] = ` +"- common at start precedes delete expected ++ common at start precedes insert ++ non-empty ++ received" +`; diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap b/packages/jest-matcher-utils/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap new file mode 100644 index 000000000000..588beca3a445 --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap @@ -0,0 +1,160 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`joinAlignedDiffsExpand first line is empty common 1`] = ` +" ↵ + common 2 preceding A + common 1 preceding A +- delete line +- change expected A ++ change received A + common 1 following A + common 2 following A + common 3 following A + common 4 following A + common 4 preceding B + common 3 preceding B + common 2 preceding B + common 1 preceding B +- change expected B ++ change received B ++ insert line + common 1 following B + common 2 following B + common 3 between B and C + common 2 preceding C + common 1 preceding C +- change expected C ++ change received C + common 1 following C + common 2 following C + common 3 following C + + common 5 following C" +`; + +exports[`joinAlignedDiffsNoExpand patch 0 with context 1 and change at start and end 1`] = ` +"- delete + common following delete + common preceding insert ++ insert" +`; + +exports[`joinAlignedDiffsNoExpand patch 0 with context 5 and first line is empty common 1`] = ` +" ↵ + common 2 preceding A + common 1 preceding A +- delete line +- change expected A ++ change received A + common 1 following A + common 2 following A + common 3 following A + common 4 following A + common 4 preceding B + common 3 preceding B + common 2 preceding B + common 1 preceding B +- change expected B ++ change received B ++ insert line + common 1 following B + common 2 following B + common 3 between B and C + common 2 preceding C + common 1 preceding C +- change expected C ++ change received C + common 1 following C + common 2 following C + common 3 following C + + common 5 following C" +`; + +exports[`joinAlignedDiffsNoExpand patch 1 with context 4 and last line is empty common 1`] = ` +"@@ -1,24 +1,24 @@ + + common 2 preceding A + common 1 preceding A +- delete line +- change expected A ++ change received A + common 1 following A + common 2 following A + common 3 following A + common 4 following A + common 4 preceding B + common 3 preceding B + common 2 preceding B + common 1 preceding B +- change expected B ++ change received B ++ insert line + common 1 following B + common 2 following B + common 3 between B and C + common 2 preceding C + common 1 preceding C +- change expected C ++ change received C + common 1 following C + common 2 following C + common 3 following C + ↵" +`; + +exports[`joinAlignedDiffsNoExpand patch 2 with context 3 1`] = ` +"@@ -1,8 +1,7 @@ + + common 2 preceding A + common 1 preceding A +- delete line +- change expected A ++ change received A + common 1 following A + common 2 following A + common 3 following A +@@ -11,13 +10,14 @@ + common 3 preceding B + common 2 preceding B + common 1 preceding B +- change expected B ++ change received B ++ insert line + common 1 following B + common 2 following B + common 3 between B and C + common 2 preceding C + common 1 preceding C +- change expected C ++ change received C + common 1 following C + common 2 following C + common 3 following C" +`; + +exports[`joinAlignedDiffsNoExpand patch 3 with context 2 and omit excess common at start 1`] = ` +"@@ -2,6 +2,5 @@ + common 2 preceding A + common 1 preceding A +- delete line +- change expected A ++ change received A + common 1 following A + common 2 following A +@@ -12,5 +11,6 @@ + common 2 preceding B + common 1 preceding B +- change expected B ++ change received B ++ insert line + common 1 following B + common 2 following B +@@ -18,5 +18,5 @@ + common 2 preceding C + common 1 preceding C +- change expected C ++ change received C + common 1 following C + common 2 following C" +`; diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffs.test.ts.snap b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffs.test.ts.snap new file mode 100644 index 000000000000..df4464c9d065 --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffs.test.ts.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`printDiffOrStringify expected and received are multi line with trailing spaces 1`] = ` +"- Expected ++ Received + +- delete· ++ insert· +- common expected common ++ common received common +- prev· ++ next·" +`; + +exports[`printDiffOrStringify expected and received are single line with multiple changes 1`] = ` +"Expected: \\"delete common expected common prev\\" +Received: \\"insert common received common next\\"" +`; + +exports[`printDiffOrStringify expected is empty and received is single line 1`] = ` +"Expected: \\"\\" +Received: \\"single line\\"" +`; + +exports[`printDiffOrStringify expected is multi line and received is empty 1`] = ` +"Expected: \\"multi +line\\" +Received: \\"\\"" +`; + +exports[`trailing spaces printExpected ordinary space precedes quote mark 1`] = `"\\"only \\""`; + +exports[`trailing spaces printReceived middle dots at end of open lines 1`] = ` +"\\"1 yes· +2 no +3 yes··· +4 no \\"" +`; diff --git a/packages/jest-matcher-utils/src/__tests__/getAlignedDiffs.test.ts b/packages/jest-matcher-utils/src/__tests__/getAlignedDiffs.test.ts new file mode 100644 index 000000000000..3eb197f45be3 --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/getAlignedDiffs.test.ts @@ -0,0 +1,171 @@ +/** + * 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 {computeStringDiffs, printMultilineStringDiffs} from '../printDiffs'; + +const testAlignedDiffs = (a: string, b: string): string => { + const {diffs} = computeStringDiffs(a, b); + return printMultilineStringDiffs(diffs, true); +}; + +describe('getAlignedDiffs', () => { + describe('lines', () => { + test('change preceding and following common', () => { + const a = 'delete\ncommon between changes\nprev'; + const b = 'insert\ncommon between changes\nnext'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('common preceding and following change', () => { + const a = 'common preceding\ndelete\ncommon following'; + const b = 'common preceding\ninsert\ncommon following'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('common at end when both current change lines are empty', () => { + const a = 'delete\ncommon at end'; + const b = 'common at end'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('common between delete and insert', () => { + const a = 'delete\ncommon between changes'; + const b = 'common between changes\ninsert'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('common between insert and delete', () => { + const a = 'common between changes\ndelete'; + const b = 'insert\ncommon between changes'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + }); + + describe('newline', () => { + test('delete only', () => { + const a = 'preceding\nfollowing'; + const b = 'precedingfollowing'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('insert only', () => { + const a = 'precedingfollowing'; + const b = 'preceding\nfollowing'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('delete with adjacent change', () => { + const a = 'preceding\nfollowing'; + const b = 'precededfollowing'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('insert with adjacent changes', () => { + const a = 'precededfollowing'; + const b = 'preceding\nFollowing'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('change from space', () => { + const a = 'preceding following'; + const b = 'preceding\nfollowing'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('change to space', () => { + const a = 'preceding\nfollowing'; + const b = 'preceding following'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + }); + + describe('substrings first', () => { + test('common when both current change lines are empty', () => { + const a = 'first\nmiddle\nlast prev'; + const b = 'insert\nfirst\nmiddle\nlast next'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('common when either current change line is non-empty', () => { + const a = 'expected first\n\nlast'; + const b = 'first\n\nlast'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('delete completes the current line', () => { + const a = 'common preceding first\nmiddle\nlast and following'; + const b = 'common preceding and following'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('insert completes the current line', () => { + const a = 'common preceding'; + const b = 'common preceding first\nmiddle\n'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + }); + + describe('substrings middle', () => { + test('is empty in delete between common', () => { + const a = 'common at start precedes delete\n\nexpected common at end'; + const b = 'common at start precedes received common at end'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('is empty in insert at start', () => { + const a = 'expected common at end'; + const b = 'insert line\n\nreceived common at end'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('is non-empty in delete at end', () => { + const a = 'common at start precedes delete\nnon-empty line\nnext'; + const b = 'common at start precedes prev'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('is non-empty in insert between common', () => { + const a = 'common at start precedes delete expected'; + const b = 'common at start precedes insert\nnon-empty\nreceived'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + }); + + describe('substrings last', () => { + test('is empty in delete at end', () => { + const a = 'common string preceding prev\n'; + const b = 'common string preceding next'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('is empty in insert at end', () => { + const a = 'common string preceding prev'; + const b = 'common string preceding next\n'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('is non-empty in common not at end', () => { + const a = 'common first\nlast expected'; + const b = 'common first\nlast received'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + }); + + describe('strings', () => { + test('change at start and delete or insert at end', () => { + const a = 'prev change common delete\nunchanged\nexpected change common'; + const b = 'next change common\nunchanged\nreceived change common insert'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + + test('delete or insert at start and change at end', () => { + const a = 'common change prev\nunchanged\ndelete common change this'; + const b = 'insert common change next\nunchanged\ncommon change that'; + expect(testAlignedDiffs(a, b)).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/jest-matcher-utils/src/__tests__/joinAlignedDiffs.test.ts b/packages/jest-matcher-utils/src/__tests__/joinAlignedDiffs.test.ts new file mode 100644 index 000000000000..5d98115c0a57 --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/joinAlignedDiffs.test.ts @@ -0,0 +1,80 @@ +/** + * 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 {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff} from '../cleanupSemantic'; +import {INVERTED_COLOR} from '../index'; +import { + joinAlignedDiffsExpand, + joinAlignedDiffsNoExpand, +} from '../joinAlignedDiffs'; + +const diffsCommonStartEnd = [ + new Diff(DIFF_EQUAL, ''), + new Diff(DIFF_EQUAL, 'common 2 preceding A'), + new Diff(DIFF_EQUAL, 'common 1 preceding A'), + new Diff(DIFF_DELETE, 'delete line'), + new Diff(DIFF_DELETE, ['change ', INVERTED_COLOR('expect'), 'ed A'].join('')), + new Diff(DIFF_INSERT, ['change ', INVERTED_COLOR('receiv'), 'ed A'].join('')), + new Diff(DIFF_EQUAL, 'common 1 following A'), + new Diff(DIFF_EQUAL, 'common 2 following A'), + new Diff(DIFF_EQUAL, 'common 3 following A'), + new Diff(DIFF_EQUAL, 'common 4 following A'), + new Diff(DIFF_EQUAL, 'common 4 preceding B'), + new Diff(DIFF_EQUAL, 'common 3 preceding B'), + new Diff(DIFF_EQUAL, 'common 2 preceding B'), + new Diff(DIFF_EQUAL, 'common 1 preceding B'), + new Diff(DIFF_DELETE, ['change ', INVERTED_COLOR('expect'), 'ed B'].join('')), + new Diff(DIFF_INSERT, ['change ', INVERTED_COLOR('receiv'), 'ed B'].join('')), + new Diff(DIFF_INSERT, 'insert line'), + new Diff(DIFF_EQUAL, 'common 1 following B'), + new Diff(DIFF_EQUAL, 'common 2 following B'), + new Diff(DIFF_EQUAL, 'common 3 between B and C'), + new Diff(DIFF_EQUAL, 'common 2 preceding C'), + new Diff(DIFF_EQUAL, 'common 1 preceding C'), + new Diff(DIFF_DELETE, ['change ', INVERTED_COLOR('expect'), 'ed C'].join('')), + new Diff(DIFF_INSERT, ['change ', INVERTED_COLOR('receiv'), 'ed C'].join('')), + new Diff(DIFF_EQUAL, 'common 1 following C'), + new Diff(DIFF_EQUAL, 'common 2 following C'), + new Diff(DIFF_EQUAL, 'common 3 following C'), + new Diff(DIFF_EQUAL, ''), + new Diff(DIFF_EQUAL, 'common 5 following C'), +]; + +const diffsChangeStartEnd = [ + new Diff(DIFF_DELETE, 'delete'), + new Diff(DIFF_EQUAL, 'common following delete'), + new Diff(DIFF_EQUAL, 'common preceding insert'), + new Diff(DIFF_INSERT, 'insert'), +]; + +describe('joinAlignedDiffsExpand', () => { + test('first line is empty common', () => { + expect(joinAlignedDiffsExpand(diffsCommonStartEnd)).toMatchSnapshot(); + }); +}); + +describe('joinAlignedDiffsNoExpand', () => { + test('patch 0 with context 1 and change at start and end', () => { + expect(joinAlignedDiffsNoExpand(diffsChangeStartEnd, 1)).toMatchSnapshot(); + }); + + test('patch 0 with context 5 and first line is empty common', () => { + expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd)).toMatchSnapshot(); + }); + + test('patch 1 with context 4 and last line is empty common', () => { + expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd, 4)).toMatchSnapshot(); + }); + + test('patch 2 with context 3', () => { + expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd, 3)).toMatchSnapshot(); + }); + + test('patch 3 with context 2 and omit excess common at start', () => { + expect(joinAlignedDiffsNoExpand(diffsCommonStartEnd, 2)).toMatchSnapshot(); + }); +}); diff --git a/packages/jest-matcher-utils/src/__tests__/printDiffs.test.ts b/packages/jest-matcher-utils/src/__tests__/printDiffs.test.ts new file mode 100644 index 000000000000..81c5ffb363ad --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/printDiffs.test.ts @@ -0,0 +1,66 @@ +/** + * 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 { + EXPECTED_COLOR, + INVERTED_COLOR, + printDiffOrStringify, + printExpected, + printReceived, +} from '../index'; + +describe('printDiffOrStringify', () => { + const testDiffOrStringify = (expected: string, received: string): string => + printDiffOrStringify(expected, received, 'Expected', 'Received', true); + + test('expected is empty and received is single line', () => { + const expected = ''; + const received = 'single line'; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('expected is multi line and received is empty', () => { + const expected = 'multi\nline'; + const received = ''; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('expected and received are single line with multiple changes', () => { + const expected = 'delete common expected common prev'; + const received = 'insert common received common next'; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('expected and received are multi line with trailing spaces', () => { + const expected = 'delete \ncommon expected common\nprev '; + const received = 'insert \ncommon received common\nnext '; + 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 + + const test = testDiffOrStringify(expected, received); + + // It is a generic line diff: + expect(test).toContain(EXPECTED_COLOR('- line')); + + // It is not a specific substring diff + expect(test).not.toContain(EXPECTED_COLOR('- ' + INVERTED_COLOR('line'))); + }); +}); + +describe('trailing spaces', () => { + test('printExpected ordinary space precedes quote mark', () => { + expect(printExpected('only ')).toMatchSnapshot(); + }); + + test('printReceived middle dots at end of open lines', () => { + expect(printReceived('1 yes \n2 no\n3 yes \n4 no ')).toMatchSnapshot(); + }); +}); diff --git a/packages/jest-matcher-utils/src/diffStrings.ts b/packages/jest-matcher-utils/src/diffStrings.ts index 6df165de718a..ffbb6223377d 100644 --- a/packages/jest-matcher-utils/src/diffStrings.ts +++ b/packages/jest-matcher-utils/src/diffStrings.ts @@ -7,15 +7,9 @@ import diffSequences from 'diff-sequences'; -import { - cleanupSemantic, - Diff, - DIFF_EQUAL, - DIFF_DELETE, - DIFF_INSERT, -} from './cleanupSemantic'; - -const diffStrings = (a: string, b: string): Array | null => { +import {Diff, DIFF_EQUAL, DIFF_DELETE, DIFF_INSERT} from './cleanupSemantic'; + +const diffStrings = (a: string, b: string): Array => { const isCommon = (aIndex: number, bIndex: number) => a[aIndex] === b[bIndex]; let aIndex = 0; @@ -49,10 +43,7 @@ const diffStrings = (a: string, b: string): Array | null => { diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex))); } - cleanupSemantic(diffs); - - // Assume it has a change string, but does it have a common string? - return diffs.some(diff => diff[0] === DIFF_EQUAL) ? diffs : null; + return diffs; }; export default diffStrings; diff --git a/packages/jest-matcher-utils/src/getAlignedDiffs.ts b/packages/jest-matcher-utils/src/getAlignedDiffs.ts new file mode 100644 index 000000000000..b8162624dce1 --- /dev/null +++ b/packages/jest-matcher-utils/src/getAlignedDiffs.ts @@ -0,0 +1,196 @@ +/** + * 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 {Diff, DIFF_DELETE, DIFF_INSERT} from './cleanupSemantic'; +import {MULTILINE_REGEXP, getDiffString} from './printDiffs'; + +// Encapsulate change lines until either a common newline or the end. +class ChangeBuffer { + private op: number; + private line: Array; // incomplete line + private lines: Array; // complete lines + + constructor(op: number) { + this.op = op; + this.line = []; + this.lines = []; + } + + private pushSubstring(substring: string): void { + this.pushDiff(new Diff(this.op, substring)); + } + + private pushLine(): void { + // 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, getDiffString(this.op, this.line))); + this.line.length = 0; + } + + isLineEmpty() { + return this.line.length === 0; + } + + // Minor input to buffer. + pushDiff(diff: Diff): void { + this.line.push(diff); + } + + // Main input to buffer. + align(diff: Diff): void { + const string = diff[1]; + + if (MULTILINE_REGEXP.test(string)) { + const substrings = string.split('\n'); + const iLast = substrings.length - 1; + substrings.forEach((substring, i) => { + if (i < iLast) { + // The first substring completes the current change line. + // A middle substring is a change line. + this.pushSubstring(substring); + this.pushLine(); + } else if (substring.length !== 0) { + // The last substring starts a change line, if it is not empty. + // Important: This non-empty condition also automatically omits + // the newline appended to the end of expected and received strings. + this.pushSubstring(substring); + } + }); + } else { + // Append non-multiline string to current change line. + this.pushDiff(diff); + } + } + + // Output from buffer. + moveLinesTo(lines: Array): void { + if (!this.isLineEmpty()) { + this.pushLine(); + } + + lines.push(...this.lines); + this.lines.length = 0; + } +} + +// Encapsulate common and change lines. +class CommonBuffer { + private deleteBuffer: ChangeBuffer; + private insertBuffer: ChangeBuffer; + private lines: Array; + + constructor(deleteBuffer: ChangeBuffer, insertBuffer: ChangeBuffer) { + this.deleteBuffer = deleteBuffer; + this.insertBuffer = insertBuffer; + this.lines = []; + } + + private pushDiffCommonLine(diff: Diff): void { + this.lines.push(diff); + } + + private pushDiffChangeLines(diff: Diff): void { + const isDiffEmpty = diff[1].length === 0; + + // An empty diff string is redundant, unless a change line is empty. + if (!isDiffEmpty || this.deleteBuffer.isLineEmpty()) { + this.deleteBuffer.pushDiff(diff); + } + if (!isDiffEmpty || this.insertBuffer.isLineEmpty()) { + this.insertBuffer.pushDiff(diff); + } + } + + private flushChangeLines(): void { + this.deleteBuffer.moveLinesTo(this.lines); + this.insertBuffer.moveLinesTo(this.lines); + } + + // Input to buffer. + align(diff: Diff): void { + const op = diff[0]; + const string = diff[1]; + + if (MULTILINE_REGEXP.test(string)) { + const substrings = string.split('\n'); + const iLast = substrings.length - 1; + substrings.forEach((substring, i) => { + if (i === 0) { + const subdiff = new Diff(op, substring); + if ( + this.deleteBuffer.isLineEmpty() && + this.insertBuffer.isLineEmpty() + ) { + // If both current change lines are empty, + // then the first substring is a common line. + this.flushChangeLines(); + this.pushDiffCommonLine(subdiff); + } else { + // If either current change line is non-empty, + // then the first substring completes the change lines. + this.pushDiffChangeLines(subdiff); + this.flushChangeLines(); + } + } else if (i < iLast) { + // A middle substring is a common line. + this.pushDiffCommonLine(new Diff(op, substring)); + } else if (substring.length !== 0) { + // The last substring starts a change line, if it is not empty. + // Important: This non-empty condition also automatically omits + // the newline appended to the end of expected and received strings. + this.pushDiffChangeLines(new Diff(op, substring)); + } + }); + } else { + // Append non-multiline string to current change lines. + // Important: It cannot be at the end following empty change lines, + // because newline appended to the end of expected and received strings. + this.pushDiffChangeLines(diff); + } + } + + // Output from buffer. + getLines(): Array { + this.flushChangeLines(); + return this.lines; + } +} + +// Given diffs from expected and received strings, +// return new array of diffs split or joined into lines. +// +// To correctly align a change line at the end, the algorithm: +// * assumes that a newline was appended to the strings +// * omits the last newline from the output array +// +// Assume the function is not called: +// * if either expected or received is empty string +// * if neither expected nor received is multiline string +const getAlignedDiffs = (diffs: Array): Array => { + const deleteBuffer = new ChangeBuffer(DIFF_DELETE); + const insertBuffer = new ChangeBuffer(DIFF_INSERT); + const commonBuffer = new CommonBuffer(deleteBuffer, insertBuffer); + + diffs.forEach(diff => { + switch (diff[0]) { + case DIFF_DELETE: + deleteBuffer.align(diff); + break; + + case DIFF_INSERT: + insertBuffer.align(diff); + break; + + default: + commonBuffer.align(diff); + } + }); + + return commonBuffer.getLines(); +}; + +export default getAlignedDiffs; diff --git a/packages/jest-matcher-utils/src/index.ts b/packages/jest-matcher-utils/src/index.ts index ee0b59edc999..a647fc713416 100644 --- a/packages/jest-matcher-utils/src/index.ts +++ b/packages/jest-matcher-utils/src/index.ts @@ -10,12 +10,15 @@ import jestDiff, {DiffOptions} from 'jest-diff'; import getType, {isPrimitive} from 'jest-get-type'; import prettyFormat from 'pretty-format'; -import diffStrings from './diffStrings'; +import {DIFF_EQUAL} from './cleanupSemantic'; import { MULTILINE_REGEXP, + SPACE_SYMBOL, + computeStringDiffs, getExpectedString, getReceivedString, -} from './printDiff'; + printMultilineStringDiffs, +} from './printDiffs'; const { AsymmetricMatcher, @@ -99,10 +102,15 @@ export const stringify = (object: unknown, maxDepth: number = 10): string => { export const highlightTrailingWhitespace = (text: string): string => text.replace(/\s+$/gm, chalk.inverse('$&')); +// Instead of inverse highlight which now implies a change, +// replace common spaces with middle dot at the end of any line. +const replaceTrailingSpaces = (text: string): string => + text.replace(/\s+$/gm, spaces => SPACE_SYMBOL.repeat(spaces.length)); + export const printReceived = (object: unknown) => - RECEIVED_COLOR(highlightTrailingWhitespace(stringify(object))); + RECEIVED_COLOR(replaceTrailingSpaces(stringify(object))); export const printExpected = (value: unknown) => - EXPECTED_COLOR(highlightTrailingWhitespace(stringify(value))); + EXPECTED_COLOR(replaceTrailingSpaces(stringify(value))); export const printWithType = ( name: string, // 'Expected' or 'Received' @@ -215,12 +223,15 @@ const isDiffable = (expected: unknown, received: unknown): boolean => { } if (isPrimitive(expected)) { - // Print diff only if both strings have more than one line. + // Print generic line diff for strings only: + // * if neither string is empty + // * if either string has more than one line return ( typeof expected === 'string' && typeof received === 'string' && - MULTILINE_REGEXP.test(expected) && - MULTILINE_REGEXP.test(received) + expected.length !== 0 && + received.length !== 0 && + (MULTILINE_REGEXP.test(expected) || MULTILINE_REGEXP.test(received)) ); } @@ -253,6 +264,8 @@ const isDiffable = (expected: unknown, received: unknown): boolean => { return true; }; +const MAX_DIFF_STRING_LENGTH = 20000; + export const printDiffOrStringify = ( expected: unknown, received: unknown, @@ -260,19 +273,40 @@ export const printDiffOrStringify = ( receivedLabel: string, expand: boolean, // CLI options: true if `--expand` or false if `--no-expand` ): string => { - const printLabel = getLabelPrinter(expectedLabel, receivedLabel); - if ( typeof expected === 'string' && typeof received === 'string' && expected.length !== 0 && received.length !== 0 && - !MULTILINE_REGEXP.test(expected) && - !MULTILINE_REGEXP.test(received) + expected.length <= MAX_DIFF_STRING_LENGTH && + received.length <= MAX_DIFF_STRING_LENGTH ) { - const diffs = diffStrings(expected, received); - - if (Array.isArray(diffs)) { + // Print specific substring diff for strings only: + // * if neither string is empty + // * if neither string is too long + const {diffs, isMultiline} = computeStringDiffs(expected, received); + + // Assume it has a change string, but does it have a common string? + // Important: Ignore common newline that was appended to multiline strings! + const iLast = diffs.length - 1; + if ( + diffs.some( + (diff, i) => + diff[0] === DIFF_EQUAL && + (!isMultiline || i !== iLast || diff[1] !== '\n'), + ) + ) { + if (isMultiline) { + return ( + EXPECTED_COLOR('- ' + expectedLabel) + + '\n' + + RECEIVED_COLOR('+ ' + receivedLabel) + + '\n\n' + + printMultilineStringDiffs(diffs, expand) + ); + } + + const printLabel = getLabelPrinter(expectedLabel, receivedLabel); const expectedLine = printLabel(expectedLabel) + printExpected(getExpectedString(diffs)); const receivedLine = @@ -280,10 +314,11 @@ export const printDiffOrStringify = ( return expectedLine + '\n' + receivedLine; } - } else if (isDiffable(expected, received)) { - // Cannot use same serialization as shortcut to avoid diff, - // because stringify (that is, pretty-format with min option) - // omits constructor name for array or object, too bad so sad :( + // else the semantic cleanup removed all common strings, + // therefore fall through to generic line diff below + } + + if (isDiffable(expected, received)) { const difference = jestDiff(expected, received, { aAnnotation: expectedLabel, bAnnotation: receivedLabel, @@ -299,17 +334,15 @@ export const printDiffOrStringify = ( } } - // Cannot reuse value of stringify(received) in report string, - // because printReceived does inverse highlight space at end of line, - // but RECEIVED_COLOR does not (it refers to a plain chalk method). - return ( - `${printLabel(expectedLabel)}${printExpected(expected)}\n` + - `${printLabel(receivedLabel)}${ - stringify(expected) === stringify(received) - ? 'serializes to the same string' - : printReceived(received) - }` - ); + const printLabel = getLabelPrinter(expectedLabel, receivedLabel); + const expectedLine = printLabel(expectedLabel) + printExpected(expected); + const receivedLine = + printLabel(receivedLabel) + + (stringify(expected) === stringify(received) + ? 'serializes to the same string' + : printReceived(received)); + + return expectedLine + '\n' + receivedLine; }; // Sometimes, e.g. when comparing two numbers, the output from jest-diff diff --git a/packages/jest-matcher-utils/src/joinAlignedDiffs.ts b/packages/jest-matcher-utils/src/joinAlignedDiffs.ts new file mode 100644 index 000000000000..b2bdd2401f54 --- /dev/null +++ b/packages/jest-matcher-utils/src/joinAlignedDiffs.ts @@ -0,0 +1,217 @@ +/** + * 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 {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff} from './cleanupSemantic'; +import {printCommonLine, printDeleteLine, printInsertLine} from './printDiffs'; + +const PATCH_COLOR = chalk.yellow; + +// Copied from jest-diff +// In GNU diff format, indexes are one-based instead of zero-based. +const createPatchMark = ( + aStart: number, + aEnd: number, + bStart: number, + bEnd: number, +): string => + PATCH_COLOR( + `@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`, + ); + +// jest --no-expand +// +// Given array of aligned strings with inverse highlight formatting, +// return joined lines with diff formatting (and patch marks, if needed). +export const joinAlignedDiffsNoExpand = ( + diffs: Array, + nContextLines: number = 5, +): string => { + const iLength = diffs.length; + const nContextLines2 = nContextLines + nContextLines; + + // First pass: count output lines and see if it has patches. + let jLength = iLength; + let hasExcessAtStartOrEnd = false; + let nExcessesBetweenChanges = 0; + let i = 0; + while (i !== iLength) { + const iStart = i; + while (i !== iLength && diffs[i][0] === DIFF_EQUAL) { + i += 1; + } + + if (iStart !== i) { + if (iStart === 0) { + // at start + if (i > nContextLines) { + jLength -= i - nContextLines; // subtract excess common lines + hasExcessAtStartOrEnd = true; + } + } else if (i === iLength) { + // at end + const n = i - iStart; + if (n > nContextLines) { + jLength -= n - nContextLines; // subtract excess common lines + hasExcessAtStartOrEnd = true; + } + } else { + // between changes + const n = i - iStart; + if (n > nContextLines2) { + jLength -= n - nContextLines2; // subtract excess common lines + nExcessesBetweenChanges += 1; + } + } + } + + while (i !== iLength && diffs[i][0] !== DIFF_EQUAL) { + i += 1; + } + } + + const hasPatch = nExcessesBetweenChanges !== 0 || hasExcessAtStartOrEnd; + if (nExcessesBetweenChanges !== 0) { + jLength += nExcessesBetweenChanges + 1; // add patch lines + } else if (hasExcessAtStartOrEnd) { + jLength += 1; // add patch line + } + const jLast = jLength - 1; + + const lines: Array = []; + + let jPatchMark = 0; // index of placeholder line for current patch mark + if (hasPatch) { + lines.push(''); // placeholder line for first patch mark + } + + // Indexes of expected or received lines in current patch: + let aStart = 0; + let bStart = 0; + let aEnd = 0; + let bEnd = 0; + + const pushCommonLine = (line: string): void => { + const j = lines.length; + lines.push(printCommonLine(line, j === 0 || j === jLast)); + aEnd += 1; + bEnd += 1; + }; + + const pushDeleteLine = (line: string): void => { + lines.push(printDeleteLine(line)); + aEnd += 1; + }; + + const pushInsertLine = (line: string): void => { + lines.push(printInsertLine(line)); + bEnd += 1; + }; + + // Second pass: push lines with diff formatting (and patch marks, if needed). + i = 0; + while (i !== iLength) { + let iStart = i; + while (i !== iLength && diffs[i][0] === DIFF_EQUAL) { + i += 1; + } + + if (iStart !== i) { + if (iStart === 0) { + // at beginning + if (i > nContextLines) { + iStart = i - nContextLines; + aStart = iStart; + bStart = iStart; + aEnd = aStart; + bEnd = bStart; + } + + for (let iCommon = iStart; iCommon !== i; iCommon += 1) { + pushCommonLine(diffs[iCommon][1]); + } + } else if (i === iLength) { + // at end + const iEnd = i - iStart > nContextLines ? iStart + nContextLines : i; + + for (let iCommon = iStart; iCommon !== iEnd; iCommon += 1) { + pushCommonLine(diffs[iCommon][1]); + } + } else { + // between changes + const nCommon = i - iStart; + + if (nCommon > nContextLines2) { + const iEnd = iStart + nContextLines; + + for (let iCommon = iStart; iCommon !== iEnd; iCommon += 1) { + pushCommonLine(diffs[iCommon][1]); + } + + lines[jPatchMark] = createPatchMark(aStart, aEnd, bStart, bEnd); + jPatchMark = lines.length; + lines.push(''); // placeholder line for next patch mark + + const nOmit = nCommon - nContextLines2; + aStart = aEnd + nOmit; + bStart = bEnd + nOmit; + aEnd = aStart; + bEnd = bStart; + + for (let iCommon = i - nContextLines; iCommon !== i; iCommon += 1) { + pushCommonLine(diffs[iCommon][1]); + } + } else { + for (let iCommon = iStart; iCommon !== i; iCommon += 1) { + pushCommonLine(diffs[iCommon][1]); + } + } + } + } + + while (i !== iLength && diffs[i][0] === DIFF_DELETE) { + pushDeleteLine(diffs[i][1]); + i += 1; + } + + while (i !== iLength && diffs[i][0] === DIFF_INSERT) { + pushInsertLine(diffs[i][1]); + i += 1; + } + } + + if (hasPatch) { + lines[jPatchMark] = createPatchMark(aStart, aEnd, bStart, bEnd); + } + + return lines.join('\n'); +}; + +// jest --expand +// +// Given array of aligned strings with inverse highlight formatting, +// return joined lines with diff formatting. +export const joinAlignedDiffsExpand = (diffs: Array) => + diffs + .map( + (diff: Diff, i: number, diffs: Array): string => { + const line = diff[1]; + + switch (diff[0]) { + case DIFF_DELETE: + return printDeleteLine(line); + + case DIFF_INSERT: + return printInsertLine(line); + + default: + return printCommonLine(line, i === 0 || i === diffs.length - 1); + } + }, + ) + .join('\n'); diff --git a/packages/jest-matcher-utils/src/printDiff.ts b/packages/jest-matcher-utils/src/printDiff.ts deleted file mode 100644 index 6866d8513f80..000000000000 --- a/packages/jest-matcher-utils/src/printDiff.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 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 {Diff, DIFF_EQUAL, DIFF_DELETE, DIFF_INSERT} from './cleanupSemantic'; -import {INVERTED_COLOR} from './index'; - -export const getDiffString = (diffs: Array, op: number): string => { - const hasEqual = diffs.some( - diff => diff[0] === DIFF_EQUAL && diff[1].length !== 0, - ); - - return diffs.reduce( - (reduced: string, diff: Diff): string => - reduced + - (diff[0] === DIFF_EQUAL - ? diff[1] - : diff[0] !== op - ? '' - : hasEqual - ? INVERTED_COLOR(diff[1]) - : diff[1]), - '', - ); -}; - -export const getExpectedString = (diffs: Array): string => - getDiffString(diffs, DIFF_DELETE); - -export const getReceivedString = (diffs: Array): string => - getDiffString(diffs, DIFF_INSERT); - -export const MULTILINE_REGEXP = /\n/; diff --git a/packages/jest-matcher-utils/src/printDiffs.ts b/packages/jest-matcher-utils/src/printDiffs.ts new file mode 100644 index 000000000000..2412cee2a225 --- /dev/null +++ b/packages/jest-matcher-utils/src/printDiffs.ts @@ -0,0 +1,99 @@ +/** + * 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 { + cleanupSemantic, + DIFF_EQUAL, + DIFF_DELETE, + DIFF_INSERT, + Diff, +} from './cleanupSemantic'; +import diffStrings from './diffStrings'; +import getAlignedDiffs from './getAlignedDiffs'; +import { + joinAlignedDiffsExpand, + joinAlignedDiffsNoExpand, +} from './joinAlignedDiffs'; +import { + DIM_COLOR, + EXPECTED_COLOR, + INVERTED_COLOR, + RECEIVED_COLOR, +} from './index'; + +// 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 getDiffString = (op: number, diffs: Array): string => + diffs.reduce( + (reduced: string, diff: Diff): string => + reduced + + (diff[0] === DIFF_EQUAL + ? diff[1] + : diff[0] === op + ? INVERTED_COLOR(diff[1]) + : ''), + '', + ); + +export const getExpectedString = (diffs: Array): string => + getDiffString(DIFF_DELETE, diffs); + +export const getReceivedString = (diffs: Array): string => + getDiffString(DIFF_INSERT, diffs); + +export const MULTILINE_REGEXP = /\n/; + +const NEWLINE_SYMBOL = '\u{21B5}'; // downwards arrow with corner leftwards +export const SPACE_SYMBOL = '\u{00B7}'; // middle dot + +// Instead of inverse highlight which now implies a change, +// replace common spaces with middle dot at the end of the line. +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 printInsertLine = (line: string) => + RECEIVED_COLOR(line.length !== 0 ? '+ ' + replaceSpacesAtEnd(line) : '+'); + +// Prevent visually ambiguous empty line as the first or the last. +export const printCommonLine = (line: string, isFirstOrLast: boolean = false) => + line.length !== 0 + ? DIM_COLOR(' ' + replaceSpacesAtEnd(line)) + : isFirstOrLast + ? DIM_COLOR(' ' + 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}; +}; + +// Return formatted diff lines without labels. +export const printMultilineStringDiffs = ( + diffs: Array, + expand: boolean, +): string => { + const lines = getAlignedDiffs(diffs); + return expand + ? joinAlignedDiffsExpand(lines) + : joinAlignedDiffsNoExpand(lines); +}; From 45ac5049b14513d79a79007014a63152c19c4ec6 Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Tue, 4 Jun 2019 08:33:12 -0400 Subject: [PATCH 2/7] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13bd95c916d4..f23ca4044a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - `[expect]` Highlight substring differences when matcher fails, part 1 ([#8448](https://github.com/facebook/jest/pull/8448)) +- `[expect]` Highlight substring differences when matcher fails, part 2 ([#8528](https://github.com/facebook/jest/pull/8528)) - `[jest-cli]` Improve chai support (with detailed output, to match jest exceptions) ([#8454](https://github.com/facebook/jest/pull/8454)) ### Fixes From ab2fa3498796c89865cc7ee0c29be3bcaa13f6ee Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Tue, 4 Jun 2019 08:37:08 -0400 Subject: [PATCH 3/7] Contrast isLineDiffable with isStringDiffable --- packages/jest-matcher-utils/src/index.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/jest-matcher-utils/src/index.ts b/packages/jest-matcher-utils/src/index.ts index a647fc713416..3730bcc28452 100644 --- a/packages/jest-matcher-utils/src/index.ts +++ b/packages/jest-matcher-utils/src/index.ts @@ -214,7 +214,7 @@ export const ensureExpectedIsNonNegativeInteger = ( } }; -const isDiffable = (expected: unknown, received: unknown): boolean => { +const isLineDiffable = (expected: unknown, received: unknown): boolean => { const expectedType = getType(expected); const receivedType = getType(received); @@ -266,6 +266,14 @@ const isDiffable = (expected: unknown, received: unknown): boolean => { const MAX_DIFF_STRING_LENGTH = 20000; +const isStringDiffable = (expected: string, received: string): boolean => + typeof expected === 'string' && + typeof received === 'string' && + expected.length !== 0 && + received.length !== 0 && + expected.length <= MAX_DIFF_STRING_LENGTH && + received.length <= MAX_DIFF_STRING_LENGTH; + export const printDiffOrStringify = ( expected: unknown, received: unknown, @@ -273,14 +281,7 @@ 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' && - expected.length !== 0 && - received.length !== 0 && - expected.length <= MAX_DIFF_STRING_LENGTH && - received.length <= MAX_DIFF_STRING_LENGTH - ) { + if (isStringDiffable(expected, received)) { // Print specific substring diff for strings only: // * if neither string is empty // * if neither string is too long @@ -318,7 +319,7 @@ export const printDiffOrStringify = ( // therefore fall through to generic line diff below } - if (isDiffable(expected, received)) { + if (isLineDiffable(expected, received)) { const difference = jestDiff(expected, received, { aAnnotation: expectedLabel, bAnnotation: receivedLabel, From 4ea411fab81e62cdb90d14a5122a93d7685dcb80 Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Tue, 4 Jun 2019 08:39:22 -0400 Subject: [PATCH 4/7] declare const DIFF_CONTEXT_DEFAULT = 5 --- packages/jest-matcher-utils/src/joinAlignedDiffs.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/jest-matcher-utils/src/joinAlignedDiffs.ts b/packages/jest-matcher-utils/src/joinAlignedDiffs.ts index b2bdd2401f54..5fc986ddecff 100644 --- a/packages/jest-matcher-utils/src/joinAlignedDiffs.ts +++ b/packages/jest-matcher-utils/src/joinAlignedDiffs.ts @@ -10,6 +10,8 @@ import chalk from 'chalk'; import {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff} from './cleanupSemantic'; import {printCommonLine, printDeleteLine, printInsertLine} from './printDiffs'; +const DIFF_CONTEXT_DEFAULT = 5; // same as jest-diff + const PATCH_COLOR = chalk.yellow; // Copied from jest-diff @@ -30,7 +32,7 @@ const createPatchMark = ( // return joined lines with diff formatting (and patch marks, if needed). export const joinAlignedDiffsNoExpand = ( diffs: Array, - nContextLines: number = 5, + nContextLines: number = DIFF_CONTEXT_DEFAULT, ): string => { const iLength = diffs.length; const nContextLines2 = nContextLines + nContextLines; From f96328061a90a03d2230ef8d8af3440a8b76b945 Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Tue, 4 Jun 2019 08:44:00 -0400 Subject: [PATCH 5/7] Revert isStringDiffable because condition must be inline --- packages/jest-matcher-utils/src/index.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/jest-matcher-utils/src/index.ts b/packages/jest-matcher-utils/src/index.ts index 3730bcc28452..94c7c2a81357 100644 --- a/packages/jest-matcher-utils/src/index.ts +++ b/packages/jest-matcher-utils/src/index.ts @@ -266,14 +266,6 @@ const isLineDiffable = (expected: unknown, received: unknown): boolean => { const MAX_DIFF_STRING_LENGTH = 20000; -const isStringDiffable = (expected: string, received: string): boolean => - typeof expected === 'string' && - typeof received === 'string' && - expected.length !== 0 && - received.length !== 0 && - expected.length <= MAX_DIFF_STRING_LENGTH && - received.length <= MAX_DIFF_STRING_LENGTH; - export const printDiffOrStringify = ( expected: unknown, received: unknown, @@ -281,7 +273,14 @@ export const printDiffOrStringify = ( receivedLabel: string, expand: boolean, // CLI options: true if `--expand` or false if `--no-expand` ): string => { - if (isStringDiffable(expected, received)) { + 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 + ) { // Print specific substring diff for strings only: // * if neither string is empty // * if neither string is too long From b12971bf9928e35fd5e433d113abc48000623364 Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Wed, 12 Jun 2019 13:06:02 -0400 Subject: [PATCH 6/7] Move string difference functions to jest-diff package --- .eslintignore | 2 +- .prettierignore | 2 +- .../getAlignedDiffs.test.ts.snap | 0 .../joinAlignedDiffs.test.ts.snap | 0 .../src/__tests__/getAlignedDiffs.test.ts | 0 .../src/__tests__/joinAlignedDiffs.test.ts | 2 +- .../src/cleanupSemantic.ts | 0 packages/jest-diff/src/diffLines.ts | 335 +++++++++++++++++ packages/jest-diff/src/diffStrings.ts | 343 ++---------------- .../src/getAlignedDiffs.ts | 6 +- packages/jest-diff/src/index.ts | 13 +- .../src/joinAlignedDiffs.ts | 0 .../src/printDiffs.ts | 90 ++++- packages/jest-matcher-utils/package.json | 1 - ...snap => printDiffOrStringify.test.ts.snap} | 9 - ...s.test.ts => printDiffOrStringify.test.ts} | 18 +- .../jest-matcher-utils/src/diffStrings.ts | 49 --- packages/jest-matcher-utils/src/index.ts | 67 +--- packages/jest-matcher-utils/tsconfig.json | 1 - scripts/checkCopyrightHeaders.js | 2 +- 20 files changed, 475 insertions(+), 465 deletions(-) rename packages/{jest-matcher-utils => jest-diff}/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap (100%) rename packages/{jest-matcher-utils => jest-diff}/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap (100%) rename packages/{jest-matcher-utils => jest-diff}/src/__tests__/getAlignedDiffs.test.ts (100%) rename packages/{jest-matcher-utils => jest-diff}/src/__tests__/joinAlignedDiffs.test.ts (98%) rename packages/{jest-matcher-utils => jest-diff}/src/cleanupSemantic.ts (100%) create mode 100644 packages/jest-diff/src/diffLines.ts rename packages/{jest-matcher-utils => jest-diff}/src/getAlignedDiffs.ts (97%) rename packages/{jest-matcher-utils => jest-diff}/src/joinAlignedDiffs.ts (100%) rename packages/{jest-matcher-utils => jest-diff}/src/printDiffs.ts (54%) rename packages/jest-matcher-utils/src/__tests__/__snapshots__/{printDiffs.test.ts.snap => printDiffOrStringify.test.ts.snap} (79%) rename packages/jest-matcher-utils/src/__tests__/{printDiffs.test.ts => printDiffOrStringify.test.ts} (80%) delete mode 100644 packages/jest-matcher-utils/src/diffStrings.ts diff --git a/.eslintignore b/.eslintignore index d25c116b8537..a16ec3b78eaa 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,7 +4,7 @@ bin/ flow-typed/** packages/*/build/** packages/*/build-es5/** -packages/jest-matcher-utils/src/cleanupSemantic.ts +packages/jest-diff/src/cleanupSemantic.ts website/blog website/build website/node_modules diff --git a/.prettierignore b/.prettierignore index 9b259cd268a9..6b1fe09e1fdf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,4 @@ fixtures/failing-jsons/ -packages/jest-matcher-utils/src/cleanupSemantic.ts +packages/jest-diff/src/cleanupSemantic.ts packages/jest-config/src/__tests__/jest-preset.json packages/pretty-format/perf/world.geo.json diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap b/packages/jest-diff/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap similarity index 100% rename from packages/jest-matcher-utils/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap rename to packages/jest-diff/src/__tests__/__snapshots__/getAlignedDiffs.test.ts.snap diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap b/packages/jest-diff/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap similarity index 100% rename from packages/jest-matcher-utils/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap rename to packages/jest-diff/src/__tests__/__snapshots__/joinAlignedDiffs.test.ts.snap diff --git a/packages/jest-matcher-utils/src/__tests__/getAlignedDiffs.test.ts b/packages/jest-diff/src/__tests__/getAlignedDiffs.test.ts similarity index 100% rename from packages/jest-matcher-utils/src/__tests__/getAlignedDiffs.test.ts rename to packages/jest-diff/src/__tests__/getAlignedDiffs.test.ts diff --git a/packages/jest-matcher-utils/src/__tests__/joinAlignedDiffs.test.ts b/packages/jest-diff/src/__tests__/joinAlignedDiffs.test.ts similarity index 98% rename from packages/jest-matcher-utils/src/__tests__/joinAlignedDiffs.test.ts rename to packages/jest-diff/src/__tests__/joinAlignedDiffs.test.ts index 5d98115c0a57..f763b5f9de3e 100644 --- a/packages/jest-matcher-utils/src/__tests__/joinAlignedDiffs.test.ts +++ b/packages/jest-diff/src/__tests__/joinAlignedDiffs.test.ts @@ -6,7 +6,7 @@ */ import {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff} from '../cleanupSemantic'; -import {INVERTED_COLOR} from '../index'; +import {INVERTED_COLOR} from '../printDiffs'; import { joinAlignedDiffsExpand, joinAlignedDiffsNoExpand, diff --git a/packages/jest-matcher-utils/src/cleanupSemantic.ts b/packages/jest-diff/src/cleanupSemantic.ts similarity index 100% rename from packages/jest-matcher-utils/src/cleanupSemantic.ts rename to packages/jest-diff/src/cleanupSemantic.ts diff --git a/packages/jest-diff/src/diffLines.ts b/packages/jest-diff/src/diffLines.ts new file mode 100644 index 000000000000..5935a5e89cc1 --- /dev/null +++ b/packages/jest-diff/src/diffLines.ts @@ -0,0 +1,335 @@ +/** + * 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, {Chalk} from 'chalk'; +import diff, {Callbacks} from 'diff-sequences'; +import {NO_DIFF_MESSAGE} from './constants'; +import {printAnnotation} from './printDiffs'; +import {DiffOptions} from './types'; + +const DIFF_CONTEXT_DEFAULT = 5; + +type Original = { + a: string; + b: string; +}; + +const fgPatchMark = chalk.yellow; +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 + +// ONLY trailing if expected value is snapshot or multiline string. +const highlightTrailingSpaces = (line: string, bgColor: Chalk): string => + line.replace(/\s+$/, bgColor('$&')); + +// BOTH leading AND trailing if expected value is data structure. +const highlightLeadingTrailingSpaces = (line: string, bgColor: Chalk): string => + // If line consists of ALL spaces: highlight all of them. + highlightTrailingSpaces(line, bgColor).replace( + // If line has an ODD length of leading spaces: highlight only the LAST. + /^(\s\s)*(\s)(?=[^\s])/, + '$1' + bgColor('$2'), + ); + +type Highlight = (line: string, bgColor: Chalk) => string; + +const getHighlightSpaces = (bothEdges: boolean): Highlight => + bothEdges ? highlightLeadingTrailingSpaces : highlightTrailingSpaces; + +type Put = (line: string) => void; + +// Given index interval in expected lines, put formatted delete lines. +const formatDelete = ( + aStart: number, + aEnd: number, + aLinesUn: Array, + aLinesIn: Array, + put: Put, +) => { + const highlightSpaces = getHighlightSpaces(aLinesUn !== aLinesIn); + for (let aIndex = aStart; aIndex !== aEnd; aIndex += 1) { + const aLineUn = aLinesUn[aIndex]; + const aLineIn = aLinesIn[aIndex]; + const indentation = aLineIn.slice(0, aLineIn.length - aLineUn.length); + + put(fgDelete('- ' + indentation + highlightSpaces(aLineUn, bgInverse))); + } +}; + +// Given index interval in received lines, put formatted insert lines. +const formatInsert = ( + bStart: number, + bEnd: number, + bLinesUn: Array, + bLinesIn: Array, + put: Put, +) => { + const highlightSpaces = getHighlightSpaces(bLinesUn !== bLinesIn); + for (let bIndex = bStart; bIndex !== bEnd; bIndex += 1) { + const bLineUn = bLinesUn[bIndex]; + const bLineIn = bLinesIn[bIndex]; + const indentation = bLineIn.slice(0, bLineIn.length - bLineUn.length); + + put(fgInsert('+ ' + indentation + highlightSpaces(bLineUn, bgInverse))); + } +}; + +// Given the number of items and starting indexes of a common subsequence, +// put formatted common lines. +const formatCommon = ( + nCommon: number, + aCommon: number, + bCommon: number, + // aLinesUn has lines that are equal to bLinesUn within a common subsequence + aLinesIn: Array, + bLinesUn: Array, + bLinesIn: Array, + put: Put, +) => { + const highlightSpaces = getHighlightSpaces(bLinesUn !== bLinesIn); + for (; nCommon !== 0; nCommon -= 1, aCommon += 1, bCommon += 1) { + const bLineUn = bLinesUn[bCommon]; + const bLineIn = bLinesIn[bCommon]; + const bLineInLength = bLineIn.length; + + // For common lines, received indentation seems more intuitive. + const indentation = bLineIn.slice(0, bLineInLength - bLineUn.length); + + // Color shows whether expected and received line has same indentation. + const hasSameIndentation = aLinesIn[aCommon].length === bLineInLength; + const fg = hasSameIndentation ? fgCommon : fgIndent; + const bg = hasSameIndentation ? bgCommon : bgInverse; + + put(fg(' ' + indentation + highlightSpaces(bLineUn, bg))); + } +}; + +// jest --expand +// Return formatted diff as joined string of all lines. +const diffExpand = ( + aLinesUn: Array, + bLinesUn: Array, + aLinesIn: Array, + bLinesIn: Array, +): string => { + const isCommon: Callbacks['isCommon'] = (aIndex, bIndex) => + aLinesUn[aIndex] === bLinesUn[bIndex]; + + const array: Array = []; + const put = (line: string) => { + array.push(line); + }; + + let aStart = 0; + let bStart = 0; + + const foundSubsequence: Callbacks['foundSubsequence'] = ( + nCommon, + aCommon, + bCommon, + ) => { + formatDelete(aStart, aCommon, aLinesUn, aLinesIn, put); + formatInsert(bStart, bCommon, bLinesUn, bLinesIn, put); + formatCommon(nCommon, aCommon, bCommon, aLinesIn, bLinesUn, bLinesIn, put); + aStart = aCommon + nCommon; + bStart = bCommon + nCommon; + }; + + const aLength = aLinesUn.length; + const bLength = bLinesUn.length; + + 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); + + return array.join('\n'); +}; + +// In GNU diff format, indexes are one-based instead of zero-based. +const createPatchMark = ( + aStart: number, + aEnd: number, + bStart: number, + bEnd: number, +): string => + fgPatchMark( + `@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`, + ); + +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, +// then a “patch mark” precedes each set of adjacent changed and common lines. +const diffNoExpand = ( + aLinesUn: Array, + bLinesUn: Array, + aLinesIn: Array, + bLinesIn: Array, + nContextLines: number, +): string => { + const isCommon: Callbacks['isCommon'] = (aIndex, bIndex) => + aLinesUn[aIndex] === bLinesUn[bIndex]; + + let iPatchMark = 0; // index of placeholder line for patch mark + const array = ['']; + const put = (line: string) => { + array.push(line); + }; + + let isAtEnd = false; + const aLength = aLinesUn.length; + const bLength = bLinesUn.length; + const nContextLines2 = nContextLines + nContextLines; + + // Initialize the first patch for changes at the start, + // especially for edge case in which there is no common subsequence. + let aStart = 0; + let aEnd = 0; + let bStart = 0; + let bEnd = 0; + + // Given the number of items and starting indexes of each common subsequence, + // format any preceding change lines, and then common context lines. + const foundSubsequence: Callbacks['foundSubsequence'] = ( + nCommon, + aStartCommon, + bStartCommon, + ) => { + const aEndCommon = aStartCommon + nCommon; + const bEndCommon = bStartCommon + nCommon; + isAtEnd = aEndCommon === aLength && bEndCommon === bLength; + + // If common subsequence is at start, re-initialize the first patch. + if (aStartCommon === 0 && bStartCommon === 0) { + const nLines = nContextLines < nCommon ? nContextLines : nCommon; + aStart = aEndCommon - nLines; + bStart = bEndCommon - nLines; + + formatCommon(nLines, aStart, bStart, aLinesIn, bLinesUn, bLinesIn, put); + aEnd = aEndCommon; + bEnd = bEndCommon; + return; + } + + // Format preceding change lines. + formatDelete(aEnd, aStartCommon, aLinesUn, aLinesIn, put); + formatInsert(bEnd, bStartCommon, bLinesUn, bLinesIn, put); + aEnd = aStartCommon; + bEnd = bStartCommon; + + // If common subsequence is at end, then context follows preceding changes; + // else context follows preceding changes AND precedes following changes. + const maxContextLines = isAtEnd ? nContextLines : nContextLines2; + + if (nCommon <= maxContextLines) { + // The patch includes all lines in the common subsequence. + formatCommon(nCommon, aEnd, bEnd, aLinesIn, bLinesUn, bLinesIn, 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); + aEnd += nContextLines; + bEnd += nContextLines; + + array[iPatchMark] = createPatchMark(aStart, aEnd, bStart, bEnd); + + // If common subsequence is not at end, another patch follows it. + if (!isAtEnd) { + iPatchMark = array.length; // index of placeholder line + array[iPatchMark] = ''; + + const nLines = nContextLines < nCommon ? nContextLines : nCommon; + aStart = aEndCommon - nLines; + bStart = bEndCommon - nLines; + + formatCommon(nLines, aStart, bStart, aLinesIn, bLinesUn, bLinesIn, put); + aEnd = aEndCommon; + bEnd = bEndCommon; + } + }; + + diff(aLength, bLength, isCommon, foundSubsequence); + + // 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); + aEnd = aLength; + bEnd = bLength; + } + + if (aStart === 0 && aEnd === aLength && bStart === 0 && bEnd === bLength) { + array.splice(0, 1); // delete placeholder line for patch mark + } else { + array[iPatchMark] = createPatchMark(aStart, aEnd, bStart, bEnd); + } + + return array.join('\n'); +}; + +export default ( + a: string, + b: string, + options?: DiffOptions, + original?: Original, +): string => { + if (a === b) { + return NO_DIFF_MESSAGE; + } + + let aLinesUn = a.split('\n'); + let bLinesUn = b.split('\n'); + + // Indentation is unknown if expected value is snapshot or multiline string. + let aLinesIn = aLinesUn; + let bLinesIn = bLinesUn; + + if (original) { + // Indentation is known if expected value is data structure: + // Compare lines without indentation and format lines with indentation. + aLinesIn = original.a.split('\n'); + bLinesIn = original.b.split('\n'); + + if ( + aLinesUn.length !== aLinesIn.length || + bLinesUn.length !== bLinesIn.length + ) { + // Fall back if unindented and indented lines are inconsistent. + aLinesUn = aLinesIn; + bLinesUn = bLinesIn; + } + } + + return ( + printAnnotation(options) + + (options && options.expand === false + ? diffNoExpand( + aLinesUn, + bLinesUn, + aLinesIn, + bLinesIn, + getContextLines(options), + ) + : diffExpand(aLinesUn, bLinesUn, aLinesIn, bLinesIn)) + ); +}; diff --git a/packages/jest-diff/src/diffStrings.ts b/packages/jest-diff/src/diffStrings.ts index 6d8fba1533fb..ffbb6223377d 100644 --- a/packages/jest-diff/src/diffStrings.ts +++ b/packages/jest-diff/src/diffStrings.ts @@ -5,336 +5,45 @@ * LICENSE file in the root directory of this source tree. */ -import chalk, {Chalk} from 'chalk'; -import diff, {Callbacks} from 'diff-sequences'; -import {NO_DIFF_MESSAGE} from './constants'; -import {DiffOptions} from './types'; +import diffSequences from 'diff-sequences'; -const DIFF_CONTEXT_DEFAULT = 5; +import {Diff, DIFF_EQUAL, DIFF_DELETE, DIFF_INSERT} from './cleanupSemantic'; -type Original = { - a: string; - b: string; -}; - -const fgPatchMark = chalk.yellow; -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 - -// ONLY trailing if expected value is snapshot or multiline string. -const highlightTrailingSpaces = (line: string, bgColor: Chalk): string => - line.replace(/\s+$/, bgColor('$&')); - -// BOTH leading AND trailing if expected value is data structure. -const highlightLeadingTrailingSpaces = (line: string, bgColor: Chalk): string => - // If line consists of ALL spaces: highlight all of them. - highlightTrailingSpaces(line, bgColor).replace( - // If line has an ODD length of leading spaces: highlight only the LAST. - /^(\s\s)*(\s)(?=[^\s])/, - '$1' + bgColor('$2'), - ); - -type Highlight = (line: string, bgColor: Chalk) => string; - -const getHighlightSpaces = (bothEdges: boolean): Highlight => - bothEdges ? highlightLeadingTrailingSpaces : highlightTrailingSpaces; - -const getAnnotation = (options?: DiffOptions): string => - fgDelete('- ' + ((options && options.aAnnotation) || 'Expected')) + - '\n' + - fgInsert('+ ' + ((options && options.bAnnotation) || 'Received')) + - '\n\n'; - -type Put = (line: string) => void; - -// Given index interval in expected lines, put formatted delete lines. -const formatDelete = ( - aStart: number, - aEnd: number, - aLinesUn: Array, - aLinesIn: Array, - put: Put, -) => { - const highlightSpaces = getHighlightSpaces(aLinesUn !== aLinesIn); - for (let aIndex = aStart; aIndex !== aEnd; aIndex += 1) { - const aLineUn = aLinesUn[aIndex]; - const aLineIn = aLinesIn[aIndex]; - const indentation = aLineIn.slice(0, aLineIn.length - aLineUn.length); - - put(fgDelete('- ' + indentation + highlightSpaces(aLineUn, bgInverse))); - } -}; - -// Given index interval in received lines, put formatted insert lines. -const formatInsert = ( - bStart: number, - bEnd: number, - bLinesUn: Array, - bLinesIn: Array, - put: Put, -) => { - const highlightSpaces = getHighlightSpaces(bLinesUn !== bLinesIn); - for (let bIndex = bStart; bIndex !== bEnd; bIndex += 1) { - const bLineUn = bLinesUn[bIndex]; - const bLineIn = bLinesIn[bIndex]; - const indentation = bLineIn.slice(0, bLineIn.length - bLineUn.length); - - put(fgInsert('+ ' + indentation + highlightSpaces(bLineUn, bgInverse))); - } -}; - -// Given the number of items and starting indexes of a common subsequence, -// put formatted common lines. -const formatCommon = ( - nCommon: number, - aCommon: number, - bCommon: number, - // aLinesUn has lines that are equal to bLinesUn within a common subsequence - aLinesIn: Array, - bLinesUn: Array, - bLinesIn: Array, - put: Put, -) => { - const highlightSpaces = getHighlightSpaces(bLinesUn !== bLinesIn); - for (; nCommon !== 0; nCommon -= 1, aCommon += 1, bCommon += 1) { - const bLineUn = bLinesUn[bCommon]; - const bLineIn = bLinesIn[bCommon]; - const bLineInLength = bLineIn.length; - - // For common lines, received indentation seems more intuitive. - const indentation = bLineIn.slice(0, bLineInLength - bLineUn.length); - - // Color shows whether expected and received line has same indentation. - const hasSameIndentation = aLinesIn[aCommon].length === bLineInLength; - const fg = hasSameIndentation ? fgCommon : fgIndent; - const bg = hasSameIndentation ? bgCommon : bgInverse; - - put(fg(' ' + indentation + highlightSpaces(bLineUn, bg))); - } -}; - -// jest --expand -// Return formatted diff as joined string of all lines. -const diffExpand = ( - aLinesUn: Array, - bLinesUn: Array, - aLinesIn: Array, - bLinesIn: Array, -): string => { - const isCommon: Callbacks['isCommon'] = (aIndex, bIndex) => - aLinesUn[aIndex] === bLinesUn[bIndex]; - - const array: Array = []; - const put = (line: string) => { - array.push(line); - }; - - let aStart = 0; - let bStart = 0; - - const foundSubsequence: Callbacks['foundSubsequence'] = ( - nCommon, - aCommon, - bCommon, - ) => { - formatDelete(aStart, aCommon, aLinesUn, aLinesIn, put); - formatInsert(bStart, bCommon, bLinesUn, bLinesIn, put); - formatCommon(nCommon, aCommon, bCommon, aLinesIn, bLinesUn, bLinesIn, put); - aStart = aCommon + nCommon; - bStart = bCommon + nCommon; - }; - - const aLength = aLinesUn.length; - const bLength = bLinesUn.length; +const diffStrings = (a: string, b: string): Array => { + const isCommon = (aIndex: number, bIndex: number) => a[aIndex] === b[bIndex]; - diff(aLength, bLength, isCommon, foundSubsequence); + let aIndex = 0; + let bIndex = 0; + const diffs: Array = []; - // After the last common subsequence, format remaining change lines. - formatDelete(aStart, aLength, aLinesUn, aLinesIn, put); - formatInsert(bStart, bLength, bLinesUn, bLinesIn, put); - - return array.join('\n'); -}; - -// In GNU diff format, indexes are one-based instead of zero-based. -const createPatchMark = ( - aStart: number, - aEnd: number, - bStart: number, - bEnd: number, -): string => - fgPatchMark( - `@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`, - ); - -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, -// then a “patch mark” precedes each set of adjacent changed and common lines. -const diffNoExpand = ( - aLinesUn: Array, - bLinesUn: Array, - aLinesIn: Array, - bLinesIn: Array, - nContextLines: number, -): string => { - const isCommon: Callbacks['isCommon'] = (aIndex, bIndex) => - aLinesUn[aIndex] === bLinesUn[bIndex]; - - let iPatchMark = 0; // index of placeholder line for patch mark - const array = ['']; - const put = (line: string) => { - array.push(line); - }; - - let isAtEnd = false; - const aLength = aLinesUn.length; - const bLength = bLinesUn.length; - const nContextLines2 = nContextLines + nContextLines; - - // Initialize the first patch for changes at the start, - // especially for edge case in which there is no common subsequence. - let aStart = 0; - let aEnd = 0; - let bStart = 0; - let bEnd = 0; - - // Given the number of items and starting indexes of each common subsequence, - // format any preceding change lines, and then common context lines. - const foundSubsequence: Callbacks['foundSubsequence'] = ( - nCommon, - aStartCommon, - bStartCommon, + const foundSubsequence = ( + nCommon: number, + aCommon: number, + bCommon: number, ) => { - const aEndCommon = aStartCommon + nCommon; - const bEndCommon = bStartCommon + nCommon; - isAtEnd = aEndCommon === aLength && bEndCommon === bLength; - - // If common subsequence is at start, re-initialize the first patch. - if (aStartCommon === 0 && bStartCommon === 0) { - const nLines = nContextLines < nCommon ? nContextLines : nCommon; - aStart = aEndCommon - nLines; - bStart = bEndCommon - nLines; - - formatCommon(nLines, aStart, bStart, aLinesIn, bLinesUn, bLinesIn, put); - aEnd = aEndCommon; - bEnd = bEndCommon; - return; + if (aIndex !== aCommon) { + diffs.push(new Diff(DIFF_DELETE, a.slice(aIndex, aCommon))); } - - // Format preceding change lines. - formatDelete(aEnd, aStartCommon, aLinesUn, aLinesIn, put); - formatInsert(bEnd, bStartCommon, bLinesUn, bLinesIn, put); - aEnd = aStartCommon; - bEnd = bStartCommon; - - // If common subsequence is at end, then context follows preceding changes; - // else context follows preceding changes AND precedes following changes. - const maxContextLines = isAtEnd ? nContextLines : nContextLines2; - - if (nCommon <= maxContextLines) { - // The patch includes all lines in the common subsequence. - formatCommon(nCommon, aEnd, bEnd, aLinesIn, bLinesUn, bLinesIn, put); - aEnd += nCommon; - bEnd += nCommon; - return; + if (bIndex !== bCommon) { + diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex, bCommon))); } - // The patch ends because context is less than number of common lines. - formatCommon(nContextLines, aEnd, bEnd, aLinesIn, bLinesUn, bLinesIn, put); - aEnd += nContextLines; - bEnd += nContextLines; - - array[iPatchMark] = createPatchMark(aStart, aEnd, bStart, bEnd); - - // If common subsequence is not at end, another patch follows it. - if (!isAtEnd) { - iPatchMark = array.length; // index of placeholder line - array[iPatchMark] = ''; - - const nLines = nContextLines < nCommon ? nContextLines : nCommon; - aStart = aEndCommon - nLines; - bStart = bEndCommon - nLines; - - formatCommon(nLines, aStart, bStart, aLinesIn, bLinesUn, bLinesIn, put); - aEnd = aEndCommon; - bEnd = bEndCommon; - } + aIndex = aCommon + nCommon; // number of characters compared in a + bIndex = bCommon + nCommon; // number of characters compared in b + diffs.push(new Diff(DIFF_EQUAL, b.slice(bCommon, bIndex))); }; - diff(aLength, bLength, isCommon, foundSubsequence); + diffSequences(a.length, b.length, isCommon, foundSubsequence); - // 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); - aEnd = aLength; - bEnd = bLength; + // After the last common subsequence, push remaining change items. + if (aIndex !== a.length) { + diffs.push(new Diff(DIFF_DELETE, a.slice(aIndex))); } - - if (aStart === 0 && aEnd === aLength && bStart === 0 && bEnd === bLength) { - array.splice(0, 1); // delete placeholder line for patch mark - } else { - array[iPatchMark] = createPatchMark(aStart, aEnd, bStart, bEnd); + if (bIndex !== b.length) { + diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex))); } - return array.join('\n'); + return diffs; }; -export default ( - a: string, - b: string, - options?: DiffOptions, - original?: Original, -): string => { - if (a === b) { - return NO_DIFF_MESSAGE; - } - - let aLinesUn = a.split('\n'); - let bLinesUn = b.split('\n'); - - // Indentation is unknown if expected value is snapshot or multiline string. - let aLinesIn = aLinesUn; - let bLinesIn = bLinesUn; - - if (original) { - // Indentation is known if expected value is data structure: - // Compare lines without indentation and format lines with indentation. - aLinesIn = original.a.split('\n'); - bLinesIn = original.b.split('\n'); - - if ( - aLinesUn.length !== aLinesIn.length || - bLinesUn.length !== bLinesIn.length - ) { - // Fall back if unindented and indented lines are inconsistent. - aLinesUn = aLinesIn; - bLinesUn = bLinesIn; - } - } - - return ( - getAnnotation(options) + - (options && options.expand === false - ? diffNoExpand( - aLinesUn, - bLinesUn, - aLinesIn, - bLinesIn, - getContextLines(options), - ) - : diffExpand(aLinesUn, bLinesUn, aLinesIn, bLinesIn)) - ); -}; +export default diffStrings; diff --git a/packages/jest-matcher-utils/src/getAlignedDiffs.ts b/packages/jest-diff/src/getAlignedDiffs.ts similarity index 97% rename from packages/jest-matcher-utils/src/getAlignedDiffs.ts rename to packages/jest-diff/src/getAlignedDiffs.ts index b8162624dce1..57dd52040a92 100644 --- a/packages/jest-matcher-utils/src/getAlignedDiffs.ts +++ b/packages/jest-diff/src/getAlignedDiffs.ts @@ -6,7 +6,7 @@ */ import {Diff, DIFF_DELETE, DIFF_INSERT} from './cleanupSemantic'; -import {MULTILINE_REGEXP, getDiffString} from './printDiffs'; +import {MULTILINE_REGEXP, getHighlightedString} from './printDiffs'; // Encapsulate change lines until either a common newline or the end. class ChangeBuffer { @@ -27,7 +27,9 @@ class ChangeBuffer { private pushLine(): void { // 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, getDiffString(this.op, this.line))); + this.lines.push( + new Diff(this.op, getHighlightedString(this.op, this.line)), + ); this.line.length = 0; } diff --git a/packages/jest-diff/src/index.ts b/packages/jest-diff/src/index.ts index 53c88fa62bd4..3b3f37bffa66 100644 --- a/packages/jest-diff/src/index.ts +++ b/packages/jest-diff/src/index.ts @@ -8,7 +8,8 @@ import prettyFormat from 'pretty-format'; import chalk from 'chalk'; import getType from 'jest-get-type'; -import diffStrings from './diffStrings'; +import diffLines from './diffLines'; +import {getStringDiff} from './printDiffs'; import {NO_DIFF_MESSAGE, SIMILAR_MESSAGE} from './constants'; import {DiffOptions as JestDiffOptions} from './types'; @@ -79,7 +80,7 @@ function diff(a: any, b: any, options?: JestDiffOptions): string | null { switch (aType) { case 'string': - return diffStrings(a, b, options); + return diffLines(a, b, options); case 'boolean': case 'number': return comparePrimitive(a, b, options); @@ -97,7 +98,7 @@ function comparePrimitive( b: number | boolean, options?: JestDiffOptions, ) { - return diffStrings( + return diffLines( prettyFormat(a, FORMAT_OPTIONS), prettyFormat(b, FORMAT_OPTIONS), options, @@ -121,7 +122,7 @@ function compareObjects( let hasThrown = false; try { - diffMessage = diffStrings( + diffMessage = diffLines( prettyFormat(a, FORMAT_OPTIONS_0), prettyFormat(b, FORMAT_OPTIONS_0), options, @@ -137,7 +138,7 @@ function compareObjects( // If the comparison yields no results, compare again but this time // without calling `toJSON`. It's also possible that toJSON might throw. if (!diffMessage || diffMessage === NO_DIFF_MESSAGE) { - diffMessage = diffStrings( + diffMessage = diffLines( prettyFormat(a, FALLBACK_FORMAT_OPTIONS_0), prettyFormat(b, FALLBACK_FORMAT_OPTIONS_0), options, @@ -159,4 +160,6 @@ namespace diff { export type DiffOptions = JestDiffOptions; } +diff.getStringDiff = getStringDiff; + export = diff; diff --git a/packages/jest-matcher-utils/src/joinAlignedDiffs.ts b/packages/jest-diff/src/joinAlignedDiffs.ts similarity index 100% rename from packages/jest-matcher-utils/src/joinAlignedDiffs.ts rename to packages/jest-diff/src/joinAlignedDiffs.ts diff --git a/packages/jest-matcher-utils/src/printDiffs.ts b/packages/jest-diff/src/printDiffs.ts similarity index 54% rename from packages/jest-matcher-utils/src/printDiffs.ts rename to packages/jest-diff/src/printDiffs.ts index 2412cee2a225..134ed8be4984 100644 --- a/packages/jest-matcher-utils/src/printDiffs.ts +++ b/packages/jest-diff/src/printDiffs.ts @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +import chalk from 'chalk'; + import { cleanupSemantic, DIFF_EQUAL, @@ -18,18 +20,18 @@ import { joinAlignedDiffsExpand, joinAlignedDiffsNoExpand, } from './joinAlignedDiffs'; -import { - DIM_COLOR, - EXPECTED_COLOR, - INVERTED_COLOR, - RECEIVED_COLOR, -} from './index'; +import {DiffOptions} 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; // 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 getDiffString = (op: number, diffs: Array): string => +export const getHighlightedString = (op: number, diffs: Array): string => diffs.reduce( (reduced: string, diff: Diff): string => reduced + @@ -42,15 +44,15 @@ export const getDiffString = (op: number, diffs: Array): string => ); export const getExpectedString = (diffs: Array): string => - getDiffString(DIFF_DELETE, diffs); + getHighlightedString(DIFF_DELETE, diffs); export const getReceivedString = (diffs: Array): string => - getDiffString(DIFF_INSERT, diffs); + getHighlightedString(DIFF_INSERT, diffs); export const MULTILINE_REGEXP = /\n/; const NEWLINE_SYMBOL = '\u{21B5}'; // downwards arrow with corner leftwards -export const SPACE_SYMBOL = '\u{00B7}'; // middle dot +const SPACE_SYMBOL = '\u{00B7}'; // middle dot // Instead of inverse highlight which now implies a change, // replace common spaces with middle dot at the end of the line. @@ -87,6 +89,24 @@ export const computeStringDiffs = (expected: string, received: string) => { return {diffs, isMultiline}; }; +export const hasCommonDiff = (diffs: Array, isMultiline: boolean) => { + if (isMultiline) { + // Important: Ignore common newline that was appended to multiline strings! + const iLast = diffs.length - 1; + return diffs.some( + (diff, i) => diff[0] === DIFF_EQUAL && (i !== iLast || diff[1] !== '\n'), + ); + } + + 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'; + // Return formatted diff lines without labels. export const printMultilineStringDiffs = ( diffs: Array, @@ -97,3 +117,53 @@ export const printMultilineStringDiffs = ( ? joinAlignedDiffsExpand(lines) : joinAlignedDiffsNoExpand(lines); }; + +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; + } + + const {diffs, isMultiline} = computeStringDiffs(expected, received); + + if (!hasCommonDiff(diffs, isMultiline)) { + return null; + } + + return isMultiline + ? { + annotatedDiff: + printAnnotation(options) + + printMultilineStringDiffs( + diffs, + options === undefined || options.expand !== false, + ), + isMultiline, + } + : { + a: getExpectedString(diffs), + b: getReceivedString(diffs), + isMultiline, + }; +}; diff --git a/packages/jest-matcher-utils/package.json b/packages/jest-matcher-utils/package.json index 66d758341438..59e468f4632d 100644 --- a/packages/jest-matcher-utils/package.json +++ b/packages/jest-matcher-utils/package.json @@ -14,7 +14,6 @@ "main": "build/index.js", "dependencies": { "chalk": "^2.0.1", - "diff-sequences": "^24.3.0", "jest-diff": "^24.8.0", "jest-get-type": "^24.8.0", "pretty-format": "^24.8.0" diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffs.test.ts.snap b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap similarity index 79% rename from packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffs.test.ts.snap rename to packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap index df4464c9d065..4a197fa3dce2 100644 --- a/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffs.test.ts.snap +++ b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap @@ -27,12 +27,3 @@ exports[`printDiffOrStringify expected is multi line and received is empty 1`] = line\\" Received: \\"\\"" `; - -exports[`trailing spaces printExpected ordinary space precedes quote mark 1`] = `"\\"only \\""`; - -exports[`trailing spaces printReceived middle dots at end of open lines 1`] = ` -"\\"1 yes· -2 no -3 yes··· -4 no \\"" -`; diff --git a/packages/jest-matcher-utils/src/__tests__/printDiffs.test.ts b/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts similarity index 80% rename from packages/jest-matcher-utils/src/__tests__/printDiffs.test.ts rename to packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts index 81c5ffb363ad..a5c513405301 100644 --- a/packages/jest-matcher-utils/src/__tests__/printDiffs.test.ts +++ b/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts @@ -5,13 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { - EXPECTED_COLOR, - INVERTED_COLOR, - printDiffOrStringify, - printExpected, - printReceived, -} from '../index'; +import {EXPECTED_COLOR, INVERTED_COLOR, printDiffOrStringify} from '../index'; describe('printDiffOrStringify', () => { const testDiffOrStringify = (expected: string, received: string): string => @@ -54,13 +48,3 @@ describe('printDiffOrStringify', () => { expect(test).not.toContain(EXPECTED_COLOR('- ' + INVERTED_COLOR('line'))); }); }); - -describe('trailing spaces', () => { - test('printExpected ordinary space precedes quote mark', () => { - expect(printExpected('only ')).toMatchSnapshot(); - }); - - test('printReceived middle dots at end of open lines', () => { - expect(printReceived('1 yes \n2 no\n3 yes \n4 no ')).toMatchSnapshot(); - }); -}); diff --git a/packages/jest-matcher-utils/src/diffStrings.ts b/packages/jest-matcher-utils/src/diffStrings.ts deleted file mode 100644 index ffbb6223377d..000000000000 --- a/packages/jest-matcher-utils/src/diffStrings.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 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 diffSequences from 'diff-sequences'; - -import {Diff, DIFF_EQUAL, DIFF_DELETE, DIFF_INSERT} from './cleanupSemantic'; - -const diffStrings = (a: string, b: string): Array => { - const isCommon = (aIndex: number, bIndex: number) => a[aIndex] === b[bIndex]; - - let aIndex = 0; - let bIndex = 0; - const diffs: Array = []; - - const foundSubsequence = ( - nCommon: number, - aCommon: number, - bCommon: number, - ) => { - if (aIndex !== aCommon) { - diffs.push(new Diff(DIFF_DELETE, a.slice(aIndex, aCommon))); - } - if (bIndex !== bCommon) { - diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex, bCommon))); - } - - aIndex = aCommon + nCommon; // number of characters compared in a - bIndex = bCommon + nCommon; // number of characters compared in b - diffs.push(new Diff(DIFF_EQUAL, b.slice(bCommon, bIndex))); - }; - - diffSequences(a.length, b.length, isCommon, foundSubsequence); - - // After the last common subsequence, push remaining change items. - if (aIndex !== a.length) { - diffs.push(new Diff(DIFF_DELETE, a.slice(aIndex))); - } - if (bIndex !== b.length) { - diffs.push(new Diff(DIFF_INSERT, b.slice(bIndex))); - } - - return diffs; -}; - -export default diffStrings; diff --git a/packages/jest-matcher-utils/src/index.ts b/packages/jest-matcher-utils/src/index.ts index 94c7c2a81357..682614aa31d8 100644 --- a/packages/jest-matcher-utils/src/index.ts +++ b/packages/jest-matcher-utils/src/index.ts @@ -6,20 +6,10 @@ */ import chalk from 'chalk'; -import jestDiff, {DiffOptions} from 'jest-diff'; +import jestDiff, {DiffOptions, getStringDiff} from 'jest-diff'; import getType, {isPrimitive} from 'jest-get-type'; import prettyFormat from 'pretty-format'; -import {DIFF_EQUAL} from './cleanupSemantic'; -import { - MULTILINE_REGEXP, - SPACE_SYMBOL, - computeStringDiffs, - getExpectedString, - getReceivedString, - printMultilineStringDiffs, -} from './printDiffs'; - const { AsymmetricMatcher, DOMCollection, @@ -54,6 +44,9 @@ export const INVERTED_COLOR = chalk.inverse; export const BOLD_WEIGHT = chalk.bold; export const DIM_COLOR = chalk.dim; +const MULTILINE_REGEXP = /\n/; +const SPACE_SYMBOL = '\u{00B7}'; // middle dot + const NUMBERS = [ 'zero', 'one', @@ -264,8 +257,6 @@ const isLineDiffable = (expected: unknown, received: unknown): boolean => { return true; }; -const MAX_DIFF_STRING_LENGTH = 20000; - export const printDiffOrStringify = ( expected: unknown, received: unknown, @@ -273,49 +264,24 @@ 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' && - expected.length !== 0 && - received.length !== 0 && - expected.length <= MAX_DIFF_STRING_LENGTH && - received.length <= MAX_DIFF_STRING_LENGTH - ) { - // Print specific substring diff for strings only: - // * if neither string is empty - // * if neither string is too long - const {diffs, isMultiline} = computeStringDiffs(expected, received); + if (typeof expected === 'string' && typeof received === 'string') { + const result = getStringDiff(expected, received, { + aAnnotation: expectedLabel, + bAnnotation: receivedLabel, + expand, + }); - // Assume it has a change string, but does it have a common string? - // Important: Ignore common newline that was appended to multiline strings! - const iLast = diffs.length - 1; - if ( - diffs.some( - (diff, i) => - diff[0] === DIFF_EQUAL && - (!isMultiline || i !== iLast || diff[1] !== '\n'), - ) - ) { - if (isMultiline) { - return ( - EXPECTED_COLOR('- ' + expectedLabel) + - '\n' + - RECEIVED_COLOR('+ ' + receivedLabel) + - '\n\n' + - printMultilineStringDiffs(diffs, expand) - ); + if (result !== null) { + if (result.isMultiline) { + return result.annotatedDiff; } const printLabel = getLabelPrinter(expectedLabel, receivedLabel); - const expectedLine = - printLabel(expectedLabel) + printExpected(getExpectedString(diffs)); - const receivedLine = - printLabel(receivedLabel) + printReceived(getReceivedString(diffs)); + const expectedLine = printLabel(expectedLabel) + printExpected(result.a); + const receivedLine = printLabel(receivedLabel) + printReceived(result.b); return expectedLine + '\n' + receivedLine; } - // else the semantic cleanup removed all common strings, - // therefore fall through to generic line diff below } if (isLineDiffable(expected, received)) { @@ -357,7 +323,8 @@ const shouldPrintDiff = (actual: unknown, expected: unknown) => { } return true; }; -export const diff: typeof jestDiff = (a, b, options) => + +export const diff = (a: any, b: any, options?: DiffOptions): string | null => shouldPrintDiff(a, b) ? jestDiff(a, b, options) : null; export const pluralize = (word: string, count: number) => diff --git a/packages/jest-matcher-utils/tsconfig.json b/packages/jest-matcher-utils/tsconfig.json index 8210e0546bad..2248b5ff7fa1 100644 --- a/packages/jest-matcher-utils/tsconfig.json +++ b/packages/jest-matcher-utils/tsconfig.json @@ -5,7 +5,6 @@ "outDir": "build" }, "references": [ - {"path": "../diff-sequences"}, {"path": "../jest-diff"}, {"path": "../jest-get-type"}, {"path": "../pretty-format"} diff --git a/scripts/checkCopyrightHeaders.js b/scripts/checkCopyrightHeaders.js index 17587bb26337..fe9682e93324 100755 --- a/scripts/checkCopyrightHeaders.js +++ b/scripts/checkCopyrightHeaders.js @@ -101,7 +101,7 @@ const CUSTOM_IGNORED_PATTERNS = [ '^flow-typed/.*', '^packages/expect/src/jasmineUtils\\.ts$', '^packages/jest-config/src/vendor/jsonlint\\.js$', - '^packages/jest-matcher-utils/src/cleanupSemantic\\.ts$', + '^packages/jest-diff/src/cleanupSemantic\\.ts$', ].map(createRegExp); const IGNORED_PATTERNS = [ From a053e676e12d16752457b084ad855c75a615b8c8 Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Thu, 13 Jun 2019 11:43:08 -0400 Subject: [PATCH 7/7] Factor out createPatchMark --- packages/jest-diff/src/diffLines.ts | 14 +---------- packages/jest-diff/src/joinAlignedDiffs.ts | 27 +++++++--------------- packages/jest-diff/src/printDiffs.ts | 12 ++++++++++ 3 files changed, 21 insertions(+), 32 deletions(-) diff --git a/packages/jest-diff/src/diffLines.ts b/packages/jest-diff/src/diffLines.ts index 5935a5e89cc1..155fef041c19 100644 --- a/packages/jest-diff/src/diffLines.ts +++ b/packages/jest-diff/src/diffLines.ts @@ -8,7 +8,7 @@ import chalk, {Chalk} from 'chalk'; import diff, {Callbacks} from 'diff-sequences'; import {NO_DIFF_MESSAGE} from './constants'; -import {printAnnotation} from './printDiffs'; +import {createPatchMark, printAnnotation} from './printDiffs'; import {DiffOptions} from './types'; const DIFF_CONTEXT_DEFAULT = 5; @@ -18,7 +18,6 @@ type Original = { b: string; }; -const fgPatchMark = chalk.yellow; const fgDelete = chalk.green; const fgInsert = chalk.red; const fgCommon = chalk.dim; // common lines (even indentation same) @@ -155,17 +154,6 @@ const diffExpand = ( return array.join('\n'); }; -// In GNU diff format, indexes are one-based instead of zero-based. -const createPatchMark = ( - aStart: number, - aEnd: number, - bStart: number, - bEnd: number, -): string => - fgPatchMark( - `@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`, - ); - const getContextLines = (options?: DiffOptions): number => options && typeof options.contextLines === 'number' && diff --git a/packages/jest-diff/src/joinAlignedDiffs.ts b/packages/jest-diff/src/joinAlignedDiffs.ts index 5fc986ddecff..e4c7746ec351 100644 --- a/packages/jest-diff/src/joinAlignedDiffs.ts +++ b/packages/jest-diff/src/joinAlignedDiffs.ts @@ -5,26 +5,15 @@ * LICENSE file in the root directory of this source tree. */ -import chalk from 'chalk'; - import {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff} from './cleanupSemantic'; -import {printCommonLine, printDeleteLine, printInsertLine} from './printDiffs'; - -const DIFF_CONTEXT_DEFAULT = 5; // same as jest-diff - -const PATCH_COLOR = chalk.yellow; - -// Copied from jest-diff -// In GNU diff format, indexes are one-based instead of zero-based. -const createPatchMark = ( - aStart: number, - aEnd: number, - bStart: number, - bEnd: number, -): string => - PATCH_COLOR( - `@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`, - ); +import { + createPatchMark, + printCommonLine, + printDeleteLine, + printInsertLine, +} from './printDiffs'; + +const DIFF_CONTEXT_DEFAULT = 5; // same as diffLines // jest --no-expand // diff --git a/packages/jest-diff/src/printDiffs.ts b/packages/jest-diff/src/printDiffs.ts index 134ed8be4984..aa2eec6729ae 100644 --- a/packages/jest-diff/src/printDiffs.ts +++ b/packages/jest-diff/src/printDiffs.ts @@ -26,6 +26,7 @@ export const DIM_COLOR = chalk.dim; export const EXPECTED_COLOR = chalk.green; export const INVERTED_COLOR = chalk.inverse; export const RECEIVED_COLOR = chalk.red; +const PATCH_COLOR = chalk.yellow; // Given change op and array of diffs, return concatenated string: // * include common strings @@ -107,6 +108,17 @@ export const printAnnotation = (options?: DiffOptions): string => RECEIVED_COLOR('+ ' + ((options && options.bAnnotation) || 'Received')) + '\n\n'; +// In GNU diff format, indexes are one-based instead of zero-based. +export const createPatchMark = ( + aStart: number, + aEnd: number, + bStart: number, + bEnd: number, +): string => + PATCH_COLOR( + `@@ -${aStart + 1},${aEnd - aStart} +${bStart + 1},${bEnd - bStart} @@`, + ); + // Return formatted diff lines without labels. export const printMultilineStringDiffs = ( diffs: Array,