From 3cb3274aee3670b805e4c12cc7dd997426fcd0d0 Mon Sep 17 00:00:00 2001 From: Mark Pedrotti Date: Mon, 27 May 2019 07:49:59 -0400 Subject: [PATCH] expect: Highlight substring differences when matcher fails, part 1 (#8448) * expect: Improve report when matcher fails, part 20 * Added newlines to cleanupSemantic * Add Facebook copyright header to declarations * Added exception to checkCopyrightHeaders script * Update CHANGELOG.md * Make substring highlight explicit in CHANGELOG.md * Convert cleanupSemantic to TypeScript * Add diff-sequences to references in tsconfig.json * Delete unneeded toString method of Diff class * Add condition for edge case of empty string * Decide not to rename DIFF_EQUAL * Rewrite complicated condition as its plain meaning * Encapsulate substring diff in array subclass with toJSON method * Move printDiffOrStringify to jest-matcher-utils * Add copyright header to 2 added files --- .eslintignore | 1 + .prettierignore | 1 + CHANGELOG.md | 1 + .../__snapshots__/matchers.test.js.snap | 48 +- .../expect/src/__tests__/matchers.test.js | 22 + packages/expect/src/matchers.ts | 15 +- packages/expect/src/print.ts | 73 --- packages/jest-matcher-utils/package.json | 1 + .../jest-matcher-utils/src/cleanupSemantic.ts | 556 ++++++++++++++++++ .../jest-matcher-utils/src/diffStrings.ts | 58 ++ packages/jest-matcher-utils/src/index.ts | 116 +++- packages/jest-matcher-utils/src/printDiff.ts | 36 ++ packages/jest-matcher-utils/tsconfig.json | 1 + scripts/checkCopyrightHeaders.js | 1 + 14 files changed, 841 insertions(+), 89 deletions(-) create mode 100644 packages/jest-matcher-utils/src/cleanupSemantic.ts create mode 100644 packages/jest-matcher-utils/src/diffStrings.ts create mode 100644 packages/jest-matcher-utils/src/printDiff.ts diff --git a/.eslintignore b/.eslintignore index 53cfa65d36c6..d25c116b8537 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,6 +4,7 @@ bin/ flow-typed/** packages/*/build/** packages/*/build-es5/** +packages/jest-matcher-utils/src/cleanupSemantic.ts website/blog website/build website/node_modules diff --git a/.prettierignore b/.prettierignore index 1019f4c475e3..9b259cd268a9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ fixtures/failing-jsons/ +packages/jest-matcher-utils/src/cleanupSemantic.ts packages/jest-config/src/__tests__/jest-preset.json packages/pretty-format/perf/world.geo.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ea66aee969ad..13bd95c916d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[expect]` Highlight substring differences when matcher fails, part 1 ([#8448](https://github.com/facebook/jest/pull/8448)) - `[jest-cli]` Improve chai support (with detailed output, to match jest exceptions) ([#8454](https://github.com/facebook/jest/pull/8454)) ### Fixes diff --git a/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap b/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap index f09ede5af303..2de490df82a3 100644 --- a/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap +++ b/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap @@ -278,6 +278,13 @@ exports[`.toBe() fails for 'undefined' with '.not' 1`] = ` Expected: not undefined" `; +exports[`.toBe() fails for: "" and "compare one-line string to empty string" 1`] = ` +"expect(received).toBe(expected) // Object.is equality + +Expected: \\"compare one-line string to empty string\\" +Received: \\"\\"" +`; + exports[`.toBe() fails for: "abc" and "cde" 1`] = ` "expect(received).toBe(expected) // Object.is equality @@ -303,6 +310,13 @@ string" 1`] = ` string" `; +exports[`.toBe() fails for: "painless JavaScript testing" and "delightful JavaScript testing" 1`] = ` +"expect(received).toBe(expected) // Object.is equality + +Expected: \\"delightful JavaScript testing\\" +Received: \\"painless JavaScript testing\\"" +`; + exports[`.toBe() fails for: "with trailing space" and "without trailing space" 1`] = ` "expect(received).toBe(expected) // Object.is equality @@ -1922,6 +1936,13 @@ exports[`.toContain(), .toContainEqual() error cases for toContainEqual 1`] = ` Received has value: null" `; +exports[`.toEqual() {pass: false} expect("1 234,57 $").toEqual("1 234,57 $") 1`] = ` +"expect(received).toEqual(expected) // deep equality + +Expected: \\"1 234,57 $\\" +Received: \\"1 234,57 $\\"" +`; + exports[`.toEqual() {pass: false} expect("Eve").toEqual({"asymmetricMatch": [Function asymmetricMatch]}) 1`] = ` "expect(received).toEqual(expected) // deep equality @@ -1985,15 +2006,8 @@ exports[`.toEqual() {pass: false} expect([1]).toEqual([2]) 1`] = ` exports[`.toEqual() {pass: false} expect({"a": 1, "b": 2}).toEqual(ObjectContaining {"a": 2}) 1`] = ` "expect(received).toEqual(expected) // deep equality -- Expected -+ Received - -- ObjectContaining { -- \\"a\\": 2, -+ Object { -+ \\"a\\": 1, -+ \\"b\\": 2, - }" +Expected: ObjectContaining {\\"a\\": 2} +Received: {\\"a\\": 1, \\"b\\": 2}" `; exports[`.toEqual() {pass: false} expect({"a": 1}).toEqual({"a": 2}) 1`] = ` @@ -3057,6 +3071,15 @@ Expected value: 2 Received value: 1" `; +exports[`.toHaveProperty() {pass: false} expect({"children": ["\\"That cartoon\\""], "props": null, "type": "p"}).toHaveProperty('children,0', "\\"That cat cartoon\\"") 1`] = ` +"expect(received).toHaveProperty(path, value) + +Expected path: [\\"children\\", 0] + +Expected value: \\"\\\\\\"That cat cartoon\\\\\\"\\" +Received value: \\"\\\\\\"That cartoon\\\\\\"\\"" +`; + exports[`.toHaveProperty() {pass: false} expect({"key": 1}).toHaveProperty('not') 1`] = ` "expect(received).toHaveProperty(path) @@ -3497,6 +3520,13 @@ Expected substring: \\"foo\\" Received string: \\"bar\\"" `; +exports[`.toStrictEqual() displays substring diff 1`] = ` +"expect(received).toStrictEqual(expected) // deep equality + +Expected: \\"Another caveat is that Jest will not typecheck your tests.\\" +Received: \\"Because TypeScript support in Babel is just transpilation, Jest will not type-check your tests as they run.\\"" +`; + exports[`.toStrictEqual() matches the expected snapshot when it fails 1`] = ` "expect(received).toStrictEqual(expected) // deep equality diff --git a/packages/expect/src/__tests__/matchers.test.js b/packages/expect/src/__tests__/matchers.test.js index 86daa26117ae..de742ae50bcf 100644 --- a/packages/expect/src/__tests__/matchers.test.js +++ b/packages/expect/src/__tests__/matchers.test.js @@ -215,6 +215,8 @@ describe('.toBe()', () => { [Symbol('received'), Symbol('expected')], [new Error('received'), new Error('expected')], ['abc', 'cde'], + ['painless JavaScript testing', 'delightful JavaScript testing'], + ['', 'compare one-line string to empty string'], ['with \ntrailing space', 'without trailing space'], ['four\n4\nline\nstring', '3\nline\nstring'], [[], []], @@ -318,6 +320,16 @@ describe('.toStrictEqual()', () => { ).toThrowErrorMatchingSnapshot(); }); + it('displays substring diff', () => { + const expected = + 'Another caveat is that Jest will not typecheck your tests.'; + const received = + 'Because TypeScript support in Babel is just transpilation, Jest will not type-check your tests as they run.'; + expect(() => + jestExpect(received).toStrictEqual(expected), + ).toThrowErrorMatchingSnapshot(); + }); + it('does not pass for different types', () => { expect({ test: new TestClassA(1, 2), @@ -358,6 +370,7 @@ describe('.toEqual()', () => { [{a: 1}, {a: 2}], [{a: 5}, {b: 6}], ['banana', 'apple'], + ['1\u{00A0}234,57\u{00A0}$', '1 234,57 $'], // issues/6881 [null, undefined], [[1], [2]], [[1, 2], [2, 1]], @@ -1348,6 +1361,14 @@ describe('.toHaveProperty()', () => { const memoized = function() {}; memoized.memo = []; + const receivedDiff = { + children: ['"That cartoon"'], + props: null, + type: 'p', + }; + const pathDiff = ['children', 0]; + const valueDiff = '"That cat cartoon"'; + [ [{a: {b: {c: {d: 1}}}}, 'a.b.c.d', 1], [{a: {b: {c: {d: 1}}}}, ['a', 'b', 'c', 'd'], 1], @@ -1384,6 +1405,7 @@ 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], [{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/matchers.ts b/packages/expect/src/matchers.ts index 977cb205cbb3..52a8c9fe7e4f 100644 --- a/packages/expect/src/matchers.ts +++ b/packages/expect/src/matchers.ts @@ -18,6 +18,7 @@ import { getLabelPrinter, matcherErrorMessage, matcherHint, + printDiffOrStringify, printReceived, printExpected, printWithType, @@ -26,7 +27,6 @@ import { } from 'jest-matcher-utils'; import {MatchersObject, MatcherState} from './types'; import { - printDiffOrStringify, printExpectedConstructorName, printExpectedConstructorNameNot, printReceivedArrayContainExpectedItem, @@ -51,6 +51,9 @@ const RECEIVED_LABEL = 'Received'; const EXPECTED_VALUE_LABEL = 'Expected value'; const RECEIVED_VALUE_LABEL = 'Received value'; +// The optional property of matcher context is true if undefined. +const isExpand = (expand?: boolean): boolean => expand !== false; + const toStrictEqualTesters = [ iterableEquality, typeEquality, @@ -107,7 +110,7 @@ const matchers: MatchersObject = { received, EXPECTED_LABEL, RECEIVED_LABEL, - this.expand, + isExpand(this.expand), ) ); }; @@ -577,7 +580,7 @@ const matchers: MatchersObject = { received, EXPECTED_LABEL, RECEIVED_LABEL, - this.expand, + isExpand(this.expand), ); // Passing the actual and expected objects so that a custom reporter @@ -751,7 +754,7 @@ const matchers: MatchersObject = { receivedValue, EXPECTED_VALUE_LABEL, RECEIVED_VALUE_LABEL, - this.expand, + isExpand(this.expand), ) : `Received path: ${printReceived( expectedPathType === 'array' || receivedPath.length === 0 @@ -886,7 +889,7 @@ const matchers: MatchersObject = { getObjectSubset(received, expected), EXPECTED_LABEL, RECEIVED_LABEL, - this.expand, + isExpand(this.expand), ); return {message, pass}; @@ -918,7 +921,7 @@ const matchers: MatchersObject = { received, EXPECTED_LABEL, RECEIVED_LABEL, - this.expand, + isExpand(this.expand), ); // Passing the actual and expected objects so that a custom reporter diff --git a/packages/expect/src/print.ts b/packages/expect/src/print.ts index c386d388b769..aa03601398d8 100644 --- a/packages/expect/src/print.ts +++ b/packages/expect/src/print.ts @@ -6,18 +6,13 @@ * */ -import getType, {isPrimitive} from 'jest-get-type'; import { EXPECTED_COLOR, INVERTED_COLOR, RECEIVED_COLOR, - diff, - getLabelPrinter, - printExpected, printReceived, stringify, } from 'jest-matcher-utils'; -import {isOneline} from './utils'; // Format substring but do not enclose in double quote marks. // The replacement is compatible with pretty-format package. @@ -66,74 +61,6 @@ export const printReceivedArrayContainExpectedItem = ( ']', ); -const shouldPrintDiff = (expected: unknown, received: unknown): boolean => { - const expectedType = getType(expected); - const receivedType = getType(received); - - if (expectedType !== receivedType) { - return false; - } - - if (isPrimitive(expected)) { - // Print diff only if both strings have more than one line. - return expectedType === 'string' && !isOneline(expected, received); - } - - if ( - expectedType === 'date' || - expectedType === 'function' || - expectedType === 'regexp' - ) { - return false; - } - - if (expected instanceof Error && received instanceof Error) { - return false; - } - - return true; -}; - -export const printDiffOrStringify = ( - expected: unknown, - received: unknown, - expectedLabel: string, // include colon and one or more spaces, - receivedLabel: string, // same as returned by getLabelPrinter - expand?: boolean, // diff option: true if `--expand` CLI option -): string => { - // 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 :( - const difference = shouldPrintDiff(expected, received) - ? diff(expected, received, { - aAnnotation: expectedLabel, - bAnnotation: receivedLabel, - expand, - }) // string | null - : null; - - // 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). - if ( - typeof difference === 'string' && - difference.includes('- ' + expectedLabel) && - difference.includes('+ ' + receivedLabel) - ) { - return difference; - } - - const printLabel = getLabelPrinter(expectedLabel, receivedLabel); - return ( - `${printLabel(expectedLabel)}${printExpected(expected)}\n` + - `${printLabel(receivedLabel)}${ - stringify(expected) === stringify(received) - ? 'serializes to the same string' - : printReceived(received) - }` - ); -}; - export const printExpectedConstructorName = ( label: string, expected: Function, diff --git a/packages/jest-matcher-utils/package.json b/packages/jest-matcher-utils/package.json index 59e468f4632d..66d758341438 100644 --- a/packages/jest-matcher-utils/package.json +++ b/packages/jest-matcher-utils/package.json @@ -14,6 +14,7 @@ "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/cleanupSemantic.ts b/packages/jest-matcher-utils/src/cleanupSemantic.ts new file mode 100644 index 000000000000..0df74e0bc895 --- /dev/null +++ b/packages/jest-matcher-utils/src/cleanupSemantic.ts @@ -0,0 +1,556 @@ +/** + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * @author fraser@google.com (Neil Fraser) + */ + +/** + * CHANGES by pedrottimark to diff_match_patch_uncompressed.ts file: + * + * 1. Delete anything not needed to use diff_cleanupSemantic method + * 2. Convert from prototype properties to var declarations + * 3. Convert Diff to class from constructor and prototype + * 4. Add type annotations for arguments and return values + * 5. Add exports + */ + +/** + * The data structure representing a diff is an array of tuples: + * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']] + * which means: delete 'Hello', add 'Goodbye' and keep ' world.' + */ +var DIFF_DELETE = -1; +var DIFF_INSERT = 1; +var DIFF_EQUAL = 0; + +/** + * Class representing one diff tuple. + * Attempts to look like a two-element array (which is what this used to be). + * @param {number} op Operation, one of: DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL. + * @param {string} text Text to be deleted, inserted, or retained. + * @constructor + */ +class Diff { + 0: number; + 1: string; + + constructor(op: number, text: string) { + this[0] = op; + this[1] = text; + } +} + + +/** + * Determine the common prefix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the start of each + * string. + */ +var diff_commonPrefix = function(text1: string, text2: string): number { + // Quick check for common null cases. + if (!text1 || !text2 || text1.charAt(0) != text2.charAt(0)) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + var pointermin = 0; + var pointermax = Math.min(text1.length, text2.length); + var pointermid = pointermax; + var pointerstart = 0; + while (pointermin < pointermid) { + if (text1.substring(pointerstart, pointermid) == + text2.substring(pointerstart, pointermid)) { + pointermin = pointermid; + pointerstart = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + + +/** + * Determine the common suffix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of each string. + */ +var diff_commonSuffix = function(text1: string, text2: string): number { + // Quick check for common null cases. + if (!text1 || !text2 || + text1.charAt(text1.length - 1) != text2.charAt(text2.length - 1)) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + var pointermin = 0; + var pointermax = Math.min(text1.length, text2.length); + var pointermid = pointermax; + var pointerend = 0; + while (pointermin < pointermid) { + if (text1.substring(text1.length - pointermid, text1.length - pointerend) == + text2.substring(text2.length - pointermid, text2.length - pointerend)) { + pointermin = pointermid; + pointerend = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + + +/** + * Determine if the suffix of one string is the prefix of another. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of the first + * string and the start of the second string. + * @private + */ +var diff_commonOverlap_ = function(text1: string, text2: string): number { + // Cache the text lengths to prevent multiple calls. + var text1_length = text1.length; + var text2_length = text2.length; + // Eliminate the null case. + if (text1_length == 0 || text2_length == 0) { + return 0; + } + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + var text_length = Math.min(text1_length, text2_length); + // Quick check for the worst case. + if (text1 == text2) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + var best = 0; + var length = 1; + while (true) { + var pattern = text1.substring(text_length - length); + var found = text2.indexOf(pattern); + if (found == -1) { + return best; + } + length += found; + if (found == 0 || text1.substring(text_length - length) == + text2.substring(0, length)) { + best = length; + length++; + } + } +}; + + +/** + * Reduce the number of edits by eliminating semantically trivial equalities. + * @param {!Array.} diffs Array of diff tuples. + */ + var diff_cleanupSemantic = function(diffs: Array) { + var changes = false; + var equalities = []; // Stack of indices where equalities are found. + var equalitiesLength = 0; // Keeping our own length var is faster in JS. + /** @type {?string} */ + var lastEquality = null; + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + var pointer = 0; // Index of current position. + // Number of characters that changed prior to the equality. + var length_insertions1 = 0; + var length_deletions1 = 0; + // Number of characters that changed after the equality. + var length_insertions2 = 0; + var length_deletions2 = 0; + while (pointer < diffs.length) { + if (diffs[pointer][0] == DIFF_EQUAL) { // Equality found. + equalities[equalitiesLength++] = pointer; + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = diffs[pointer][1]; + } else { // An insertion or deletion. + if (diffs[pointer][0] == DIFF_INSERT) { + length_insertions2 += diffs[pointer][1].length; + } else { + length_deletions2 += diffs[pointer][1].length; + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if (lastEquality && (lastEquality.length <= + Math.max(length_insertions1, length_deletions1)) && + (lastEquality.length <= Math.max(length_insertions2, + length_deletions2))) { + // Duplicate record. + diffs.splice(equalities[equalitiesLength - 1], 0, + new Diff(DIFF_DELETE, lastEquality)); + // Change second copy to insert. + diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; + // Throw away the equality we just deleted. + equalitiesLength--; + // Throw away the previous equality (it needs to be reevaluated). + equalitiesLength--; + pointer = equalitiesLength > 0 ? equalities[equalitiesLength - 1] : -1; + length_insertions1 = 0; // Reset the counters. + length_deletions1 = 0; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + pointer++; + } + + // Normalize the diff. + if (changes) { + diff_cleanupMerge(diffs); + } + diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = 1; + while (pointer < diffs.length) { + if (diffs[pointer - 1][0] == DIFF_DELETE && + diffs[pointer][0] == DIFF_INSERT) { + var deletion = diffs[pointer - 1][1]; + var insertion = diffs[pointer][1]; + var overlap_length1 = diff_commonOverlap_(deletion, insertion); + var overlap_length2 = diff_commonOverlap_(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if (overlap_length1 >= deletion.length / 2 || + overlap_length1 >= insertion.length / 2) { + // Overlap found. Insert an equality and trim the surrounding edits. + diffs.splice(pointer, 0, new Diff(DIFF_EQUAL, + insertion.substring(0, overlap_length1))); + diffs[pointer - 1][1] = + deletion.substring(0, deletion.length - overlap_length1); + diffs[pointer + 1][1] = insertion.substring(overlap_length1); + pointer++; + } + } else { + if (overlap_length2 >= deletion.length / 2 || + overlap_length2 >= insertion.length / 2) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + diffs.splice(pointer, 0, new Diff(DIFF_EQUAL, + deletion.substring(0, overlap_length2))); + diffs[pointer - 1][0] = DIFF_INSERT; + diffs[pointer - 1][1] = + insertion.substring(0, insertion.length - overlap_length2); + diffs[pointer + 1][0] = DIFF_DELETE; + diffs[pointer + 1][1] = + deletion.substring(overlap_length2); + pointer++; + } + } + pointer++; + } + pointer++; + } +}; + + +/** + * Look for single edits surrounded on both sides by equalities + * which can be shifted sideways to align the edit to a word boundary. + * e.g: The cat came. -> The cat came. + * @param {!Array.} diffs Array of diff tuples. + */ +var diff_cleanupSemanticLossless = function(diffs: Array) { + /** + * Given two strings, compute a score representing whether the internal + * boundary falls on logical boundaries. + * Scores range from 6 (best) to 0 (worst). + * Closure, but does not reference any external variables. + * @param {string} one First string. + * @param {string} two Second string. + * @return {number} The score. + * @private + */ + function diff_cleanupSemanticScore_(one: string, two: string): number { + if (!one || !two) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + var char1 = one.charAt(one.length - 1); + var char2 = two.charAt(0); + var nonAlphaNumeric1 = char1.match(nonAlphaNumericRegex_); + var nonAlphaNumeric2 = char2.match(nonAlphaNumericRegex_); + var whitespace1 = nonAlphaNumeric1 && + char1.match(whitespaceRegex_); + var whitespace2 = nonAlphaNumeric2 && + char2.match(whitespaceRegex_); + var lineBreak1 = whitespace1 && + char1.match(linebreakRegex_); + var lineBreak2 = whitespace2 && + char2.match(linebreakRegex_); + var blankLine1 = lineBreak1 && + one.match(blanklineEndRegex_); + var blankLine2 = lineBreak2 && + two.match(blanklineStartRegex_); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + var pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1][0] == DIFF_EQUAL && + diffs[pointer + 1][0] == DIFF_EQUAL) { + // This is a single edit surrounded by equalities. + var equality1 = diffs[pointer - 1][1]; + var edit = diffs[pointer][1]; + var equality2 = diffs[pointer + 1][1]; + + // First, shift the edit as far left as possible. + var commonOffset = diff_commonSuffix(equality1, edit); + if (commonOffset) { + var commonString = edit.substring(edit.length - commonOffset); + equality1 = equality1.substring(0, equality1.length - commonOffset); + edit = commonString + edit.substring(0, edit.length - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + var bestEquality1 = equality1; + var bestEdit = edit; + var bestEquality2 = equality2; + var bestScore = diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + while (edit.charAt(0) === equality2.charAt(0)) { + equality1 += edit.charAt(0); + edit = edit.substring(1) + equality2.charAt(0); + equality2 = equality2.substring(1); + var score = diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (diffs[pointer - 1][1] != bestEquality1) { + // We have an improvement, save it back to the diff. + if (bestEquality1) { + diffs[pointer - 1][1] = bestEquality1; + } else { + diffs.splice(pointer - 1, 1); + pointer--; + } + diffs[pointer][1] = bestEdit; + if (bestEquality2) { + diffs[pointer + 1][1] = bestEquality2; + } else { + diffs.splice(pointer + 1, 1); + pointer--; + } + } + } + pointer++; + } +}; + + +// Define some regex patterns for matching boundaries. +var nonAlphaNumericRegex_ = /[^a-zA-Z0-9]/; +var whitespaceRegex_ = /\s/; +var linebreakRegex_ = /[\r\n]/; +var blanklineEndRegex_ = /\n\r?\n$/; +var blanklineStartRegex_ = /^\r?\n\r?\n/; + + +/** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * @param {!Array.} diffs Array of diff tuples. + */ +var diff_cleanupMerge = function(diffs: Array) { + // Add a dummy entry at the end. + diffs.push(new Diff(DIFF_EQUAL, '')); + var pointer = 0; + var count_delete = 0; + var count_insert = 0; + var text_delete = ''; + var text_insert = ''; + var commonlength; + while (pointer < diffs.length) { + switch (diffs[pointer][0]) { + case DIFF_INSERT: + count_insert++; + text_insert += diffs[pointer][1]; + pointer++; + break; + case DIFF_DELETE: + count_delete++; + text_delete += diffs[pointer][1]; + pointer++; + break; + case DIFF_EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete + count_insert > 1) { + if (count_delete !== 0 && count_insert !== 0) { + // Factor out any common prefixies. + commonlength = diff_commonPrefix(text_insert, text_delete); + if (commonlength !== 0) { + if ((pointer - count_delete - count_insert) > 0 && + diffs[pointer - count_delete - count_insert - 1][0] == + DIFF_EQUAL) { + diffs[pointer - count_delete - count_insert - 1][1] += + text_insert.substring(0, commonlength); + } else { + diffs.splice(0, 0, new Diff(DIFF_EQUAL, + text_insert.substring(0, commonlength))); + pointer++; + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + // Factor out any common suffixies. + commonlength = diff_commonSuffix(text_insert, text_delete); + if (commonlength !== 0) { + diffs[pointer][1] = text_insert.substring(text_insert.length - + commonlength) + diffs[pointer][1]; + text_insert = text_insert.substring(0, text_insert.length - + commonlength); + text_delete = text_delete.substring(0, text_delete.length - + commonlength); + } + } + // Delete the offending records and add the merged ones. + pointer -= count_delete + count_insert; + diffs.splice(pointer, count_delete + count_insert); + if (text_delete.length) { + diffs.splice(pointer, 0, + new Diff(DIFF_DELETE, text_delete)); + pointer++; + } + if (text_insert.length) { + diffs.splice(pointer, 0, + new Diff(DIFF_INSERT, text_insert)); + pointer++; + } + pointer++; + } else if (pointer !== 0 && diffs[pointer - 1][0] == DIFF_EQUAL) { + // Merge this equality with the previous one. + diffs[pointer - 1][1] += diffs[pointer][1]; + diffs.splice(pointer, 1); + } else { + pointer++; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + } + if (diffs[diffs.length - 1][1] === '') { + diffs.pop(); // Remove the dummy entry at the end. + } + + // Second pass: look for single edits surrounded on both sides by equalities + // which can be shifted sideways to eliminate an equality. + // e.g: ABAC -> ABAC + var changes = false; + pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1][0] == DIFF_EQUAL && + diffs[pointer + 1][0] == DIFF_EQUAL) { + // This is a single edit surrounded by equalities. + if (diffs[pointer][1].substring(diffs[pointer][1].length - + diffs[pointer - 1][1].length) == diffs[pointer - 1][1]) { + // Shift the edit over the previous equality. + diffs[pointer][1] = diffs[pointer - 1][1] + + diffs[pointer][1].substring(0, diffs[pointer][1].length - + diffs[pointer - 1][1].length); + diffs[pointer + 1][1] = diffs[pointer - 1][1] + diffs[pointer + 1][1]; + diffs.splice(pointer - 1, 1); + changes = true; + } else if (diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) == + diffs[pointer + 1][1]) { + // Shift the edit over the next equality. + diffs[pointer - 1][1] += diffs[pointer + 1][1]; + diffs[pointer][1] = + diffs[pointer][1].substring(diffs[pointer + 1][1].length) + + diffs[pointer + 1][1]; + diffs.splice(pointer + 1, 1); + changes = true; + } + } + pointer++; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + diff_cleanupMerge(diffs); + } +}; + + +export { + Diff, + DIFF_EQUAL, + DIFF_DELETE, + DIFF_INSERT, + diff_cleanupSemantic as cleanupSemantic, +}; diff --git a/packages/jest-matcher-utils/src/diffStrings.ts b/packages/jest-matcher-utils/src/diffStrings.ts new file mode 100644 index 000000000000..6df165de718a --- /dev/null +++ b/packages/jest-matcher-utils/src/diffStrings.ts @@ -0,0 +1,58 @@ +/** + * 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 { + cleanupSemantic, + Diff, + DIFF_EQUAL, + DIFF_DELETE, + DIFF_INSERT, +} from './cleanupSemantic'; + +const diffStrings = (a: string, b: string): Array | null => { + 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))); + } + + 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; +}; + +export default diffStrings; diff --git a/packages/jest-matcher-utils/src/index.ts b/packages/jest-matcher-utils/src/index.ts index 9f9de13390ce..ee0b59edc999 100644 --- a/packages/jest-matcher-utils/src/index.ts +++ b/packages/jest-matcher-utils/src/index.ts @@ -7,8 +7,16 @@ import chalk from 'chalk'; import jestDiff, {DiffOptions} from 'jest-diff'; -import getType from 'jest-get-type'; +import getType, {isPrimitive} from 'jest-get-type'; import prettyFormat from 'pretty-format'; + +import diffStrings from './diffStrings'; +import { + MULTILINE_REGEXP, + getExpectedString, + getReceivedString, +} from './printDiff'; + const { AsymmetricMatcher, DOMCollection, @@ -198,6 +206,112 @@ export const ensureExpectedIsNonNegativeInteger = ( } }; +const isDiffable = (expected: unknown, received: unknown): boolean => { + const expectedType = getType(expected); + const receivedType = getType(received); + + if (expectedType !== receivedType) { + return false; + } + + if (isPrimitive(expected)) { + // Print diff only if both strings have more than one line. + return ( + typeof expected === 'string' && + typeof received === 'string' && + MULTILINE_REGEXP.test(expected) && + MULTILINE_REGEXP.test(received) + ); + } + + if ( + expectedType === 'date' || + expectedType === 'function' || + expectedType === 'regexp' + ) { + return false; + } + + if (expected instanceof Error && received instanceof Error) { + return false; + } + + if ( + expectedType === 'object' && + typeof (expected as any).asymmetricMatch === 'function' + ) { + return false; + } + + if ( + receivedType === 'object' && + typeof (received as any).asymmetricMatch === 'function' + ) { + return false; + } + + return true; +}; + +export const printDiffOrStringify = ( + expected: unknown, + received: unknown, + expectedLabel: string, + 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) + ) { + const diffs = diffStrings(expected, received); + + if (Array.isArray(diffs)) { + const expectedLine = + printLabel(expectedLabel) + printExpected(getExpectedString(diffs)); + const receivedLine = + printLabel(receivedLabel) + printReceived(getReceivedString(diffs)); + + 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 :( + const difference = jestDiff(expected, received, { + aAnnotation: expectedLabel, + bAnnotation: receivedLabel, + expand, + }); + + if ( + typeof difference === 'string' && + difference.includes('- ' + expectedLabel) && + difference.includes('+ ' + receivedLabel) + ) { + return difference; + } + } + + // 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) + }` + ); +}; + // Sometimes, e.g. when comparing two numbers, the output from jest-diff // does not contain more information than the `Expected:` / `Received:` already gives. // In those cases, we do not print a diff to make the output shorter and not redundant. diff --git a/packages/jest-matcher-utils/src/printDiff.ts b/packages/jest-matcher-utils/src/printDiff.ts new file mode 100644 index 000000000000..6866d8513f80 --- /dev/null +++ b/packages/jest-matcher-utils/src/printDiff.ts @@ -0,0 +1,36 @@ +/** + * 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/tsconfig.json b/packages/jest-matcher-utils/tsconfig.json index 2248b5ff7fa1..8210e0546bad 100644 --- a/packages/jest-matcher-utils/tsconfig.json +++ b/packages/jest-matcher-utils/tsconfig.json @@ -5,6 +5,7 @@ "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 e7e1cc9e7dd7..17587bb26337 100755 --- a/scripts/checkCopyrightHeaders.js +++ b/scripts/checkCopyrightHeaders.js @@ -101,6 +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$', ].map(createRegExp); const IGNORED_PATTERNS = [