From 9c7ea3820f6d1609d926764af124b15271671ae0 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 16 Jun 2023 11:21:59 +0200 Subject: [PATCH] fix: revert concordance diff, use jest's diff output (#3582) --- packages/expect/src/jest-expect.ts | 25 +- packages/expect/src/jest-matcher-utils.ts | 11 +- packages/expect/src/types.ts | 25 +- .../components/views/ViewReportError.vue | 3 +- packages/ui/client/composables/diff.ts | 1 - packages/utils/package.json | 2 +- packages/utils/rollup.config.js | 2 +- packages/utils/src/descriptors.ts | 98 --- packages/utils/src/diff.ts | 51 -- packages/utils/src/diff/cleanupSemantic.ts | 561 ++++++++++++++++++ packages/utils/src/diff/constants.ts | 12 + packages/utils/src/diff/diffLines.ts | 197 ++++++ packages/utils/src/diff/diffStrings.ts | 47 ++ packages/utils/src/diff/getAlignedDiffs.ts | 235 ++++++++ packages/utils/src/diff/getType.ts | 67 +++ packages/utils/src/diff/index.ts | 204 +++++++ packages/utils/src/diff/joinAlignedDiffs.ts | 292 +++++++++ .../utils/src/diff/normalizeDiffOptions.ts | 63 ++ packages/utils/src/diff/printDiffs.ts | 65 ++ packages/utils/src/diff/types.ts | 51 ++ packages/utils/src/error.ts | 14 +- packages/utils/src/external.d.ts | 3 - packages/vitest/src/node/error.ts | 5 +- pnpm-lock.yaml | 87 ++- .../test/__snapshots__/mocked.test.ts.snap | 58 +- test/core/test/diff.test.ts | 44 +- test/core/test/jest-matcher-utils.test.ts | 4 +- .../tests/__snapshots__/html.test.ts.snap | 16 +- 28 files changed, 1933 insertions(+), 310 deletions(-) delete mode 100644 packages/ui/client/composables/diff.ts delete mode 100644 packages/utils/src/descriptors.ts delete mode 100644 packages/utils/src/diff.ts create mode 100644 packages/utils/src/diff/cleanupSemantic.ts create mode 100644 packages/utils/src/diff/constants.ts create mode 100644 packages/utils/src/diff/diffLines.ts create mode 100644 packages/utils/src/diff/diffStrings.ts create mode 100644 packages/utils/src/diff/getAlignedDiffs.ts create mode 100644 packages/utils/src/diff/getType.ts create mode 100644 packages/utils/src/diff/index.ts create mode 100644 packages/utils/src/diff/joinAlignedDiffs.ts create mode 100644 packages/utils/src/diff/normalizeDiffOptions.ts create mode 100644 packages/utils/src/diff/printDiffs.ts create mode 100644 packages/utils/src/diff/types.ts delete mode 100644 packages/utils/src/external.d.ts diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 0ad8ced8c88b..659263dd54f0 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -186,6 +186,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { 'expected #{this} to be truthy', 'expected #{this} to not be truthy', obj, + false, ) }) def('toBeFalsy', function () { @@ -195,6 +196,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { 'expected #{this} to be falsy', 'expected #{this} to not be falsy', obj, + false, ) }) def('toBeGreaterThan', function (expected: number | bigint) { @@ -207,6 +209,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected ${actual} to be not greater than ${expected}`, actual, expected, + false, ) }) def('toBeGreaterThanOrEqual', function (expected: number | bigint) { @@ -219,6 +222,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected ${actual} to be not greater than or equal to ${expected}`, actual, expected, + false, ) }) def('toBeLessThan', function (expected: number | bigint) { @@ -231,6 +235,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected ${actual} to be not less than ${expected}`, actual, expected, + false, ) }) def('toBeLessThanOrEqual', function (expected: number | bigint) { @@ -243,6 +248,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected ${actual} to be not less than or equal to ${expected}`, actual, expected, + false, ) }) def('toBeNaN', function () { @@ -328,6 +334,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected #{this} to not be close to #{exp}, received difference is ${receivedDiff}, but expected ${expectedDiff}`, received, expected, + false, ) }) @@ -356,10 +363,10 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { } const formatCalls = (spy: EnhancedSpy, msg: string, actualCall?: any) => { if (spy.mock.calls) { - msg += c().gray(`\n\nReceived: \n${spy.mock.calls.map((callArg, i) => { - let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call:\n\n`) + msg += c().gray(`\n\nReceived: \n\n${spy.mock.calls.map((callArg, i) => { + let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call:\n\n`) if (actualCall) - methodCall += diff(actualCall, callArg, { showLegend: false }) + methodCall += diff(actualCall, callArg, { omitAnnotationLines: true }) else methodCall += stringify(callArg).split('\n').map(line => ` ${line}`).join('\n') @@ -371,10 +378,10 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return msg } const formatReturns = (spy: EnhancedSpy, msg: string, actualReturn?: any) => { - msg += c().gray(`\n\nReceived: \n${spy.mock.results.map((callReturn, i) => { - let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call return:\n\n`) + msg += c().gray(`\n\nReceived: \n\n${spy.mock.results.map((callReturn, i) => { + let methodCall = c().bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call return:\n\n`) if (actualReturn) - methodCall += diff(actualReturn, callReturn.value, { showLegend: false }) + methodCall += diff(actualReturn, callReturn.value, { omitAnnotationLines: true }) else methodCall += stringify(callReturn).split('\n').map(line => ` ${line}`).join('\n') @@ -394,6 +401,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected "${spyName}" to not be called #{exp} times`, number, callCount, + false, ) }) def('toHaveBeenCalledOnce', function () { @@ -406,6 +414,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected "${spyName}" to not be called once`, 1, callCount, + false, ) }) def(['toHaveBeenCalled', 'toBeCalled'], function () { @@ -525,6 +534,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected error not to be instance of ${name}`, expected, thrown, + false, ) } @@ -546,6 +556,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { 'expected error not to match asymmetric matcher', matcher.toString(), thrown, + false, ) } @@ -561,6 +572,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected "${spyName}" to not be successfully called`, calledAndNotThrew, !calledAndNotThrew, + false, ) }) def(['toHaveReturnedTimes', 'toReturnTimes'], function (times: number) { @@ -573,6 +585,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { `expected "${spyName}" to not be successfully called ${times} times`, `expected number of returns: ${times}`, `received number of returns: ${successfulReturns}`, + false, ) }) def(['toHaveReturnedWith', 'toReturnWith'], function (value: any) { diff --git a/packages/expect/src/jest-matcher-utils.ts b/packages/expect/src/jest-matcher-utils.ts index f82719b15580..32dcc2c0319e 100644 --- a/packages/expect/src/jest-matcher-utils.ts +++ b/packages/expect/src/jest-matcher-utils.ts @@ -1,7 +1,7 @@ import { getColors, stringify } from '@vitest/utils' -import { unifiedDiff } from '@vitest/utils/diff' -import type { DiffOptions, MatcherHintOptions } from './types' +import type { MatcherHintOptions } from './types' +export { diff } from '@vitest/utils/diff' export { stringify } export function getMatcherUtils() { @@ -101,10 +101,3 @@ export function getMatcherUtils() { printExpected, } } - -// TODO: do something with options -export function diff(a: any, b: any, options?: DiffOptions) { - return unifiedDiff(b, a, { - showLegend: options?.showLegend, - }) -} diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 6c4406bac208..b4aa42789e55 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -17,6 +17,8 @@ export type ChaiPlugin = FirstFunctionArgument export type Tester = (a: any, b: any) => boolean | undefined +export type { DiffOptions } from '@vitest/utils/diff' + export interface MatcherHintOptions { comment?: string expectedColor?: Formatter @@ -28,29 +30,6 @@ export interface MatcherHintOptions { secondArgumentColor?: Formatter } -export interface DiffOptions { - aAnnotation?: string - aColor?: Formatter - aIndicator?: string - bAnnotation?: string - bColor?: Formatter - bIndicator?: string - changeColor?: Formatter - changeLineTrailingSpaceColor?: Formatter - commonColor?: Formatter - commonIndicator?: string - commonLineTrailingSpaceColor?: Formatter - contextLines?: number - emptyFirstOrLastLinePlaceholder?: string - expand?: boolean - includeChangeCounts?: boolean - omitAnnotationLines?: boolean - patchColor?: Formatter - // pretty-format type - compareKeys?: any - showLegend?: boolean -} - export interface MatcherState { assertionCalls: number currentTestName?: string diff --git a/packages/ui/client/components/views/ViewReportError.vue b/packages/ui/client/components/views/ViewReportError.vue index 39ae42a99365..2e962f03503f 100644 --- a/packages/ui/client/components/views/ViewReportError.vue +++ b/packages/ui/client/components/views/ViewReportError.vue @@ -1,6 +1,5 @@ diff --git a/packages/ui/client/composables/diff.ts b/packages/ui/client/composables/diff.ts deleted file mode 100644 index 003dc6e39b5a..000000000000 --- a/packages/ui/client/composables/diff.ts +++ /dev/null @@ -1 +0,0 @@ -export { unifiedDiff } from '@vitest/utils/diff' diff --git a/packages/utils/package.json b/packages/utils/package.json index da40031bdceb..391063b6166c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -47,7 +47,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "concordance": "^5.0.4", + "diff-sequences": "^29.4.3", "loupe": "^2.3.6", "pretty-format": "^27.5.1" } diff --git a/packages/utils/rollup.config.js b/packages/utils/rollup.config.js index 721ece68bdb9..3118cdcdaae5 100644 --- a/packages/utils/rollup.config.js +++ b/packages/utils/rollup.config.js @@ -9,7 +9,7 @@ import pkg from './package.json' assert { type: 'json' } const entries = { index: 'src/index.ts', helpers: 'src/helpers.ts', - diff: 'src/diff.ts', + diff: 'src/diff/index.ts', error: 'src/error.ts', types: 'src/types.ts', } diff --git a/packages/utils/src/descriptors.ts b/packages/utils/src/descriptors.ts deleted file mode 100644 index 366b91a6b76c..000000000000 --- a/packages/utils/src/descriptors.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as concordance from 'concordance' -import { getColors } from './colors' - -const concordanceModule = 'default' in concordance - ? concordance.default - : concordance as any - -interface DisplayOptions { - theme?: any - maxDepth?: number -} - -export function getConcordanceTheme() { - const c = getColors() - - // this theme is taken from ava: https://github.com/avajs/ava/blob/main/lib/concordance-options.js - // no adjustments were made so far except for the diff padding - return { - boolean: c.yellow, - circular: c.gray('[Circular]'), - date: { - invalid: c.red('invalid'), - value: c.blue, - }, - diffGutters: { - actual: ` ${c.green('-')} `, - expected: ` ${c.red('+')} `, - padding: ' ', - }, - error: { - ctor: { open: `${c.gray.open}(`, close: `)${c.gray.close}` }, - name: c.magenta, - }, - function: { - name: c.blue, - stringTag: c.magenta, - }, - global: c.magenta, - item: { after: c.gray(',') }, - list: { openBracket: c.gray('['), closeBracket: c.gray(']') }, - mapEntry: { after: c.gray(',') }, - maxDepth: c.gray('…'), - null: c.yellow, - number: c.yellow, - object: { - openBracket: c.gray('{'), - closeBracket: c.gray('}'), - ctor: c.magenta, - stringTag: { open: `${c.magenta.open}@`, close: c.magenta.close }, - secondaryStringTag: { open: `${c.gray.open}@`, close: c.gray.close }, - }, - property: { - after: c.gray(','), - keyBracket: { open: c.gray('['), close: c.gray(']') }, - valueFallback: c.gray('…'), - }, - regexp: { - source: { open: `${c.blue.open}/`, close: `/${c.blue.close}` }, - flags: c.yellow, - }, - stats: { separator: c.gray('---') }, - string: { - open: c.blue.open, - close: c.blue.close, - line: { open: c.blue('\''), close: c.blue('\'') }, - multiline: { start: c.blue('`'), end: c.blue('`') }, - controlPicture: c.gray, - diff: { - insert: { - open: c.bgGreen.open + c.black.open, - close: c.black.close + c.bgGreen.close, - }, - delete: { - open: c.bgRed.open + c.black.open, - close: c.black.close + c.bgRed.close, - }, - equal: c.blue, - insertLine: { - open: c.green.open, - close: c.green.close, - }, - deleteLine: { - open: c.red.open, - close: c.red.close, - }, - }, - }, - symbol: c.yellow, - typedArray: { - bytes: c.yellow, - }, - undefined: c.yellow, - } -} - -export function diffDescriptors(actual: unknown, expected: unknown, options: DisplayOptions): string { - return concordanceModule.diff(expected, actual, options) -} diff --git a/packages/utils/src/diff.ts b/packages/utils/src/diff.ts deleted file mode 100644 index 9da3ab5b22af..000000000000 --- a/packages/utils/src/diff.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { getColors } from './colors' -import { diffDescriptors, getConcordanceTheme } from './descriptors' - -export interface DiffOptions { - showLegend?: boolean -} - -/** -* Returns unified diff between two strings with coloured ANSI output. -* -* @private -* @param {String} actual -* @param {String} expected -* @return {string} The diff. -*/ -export function unifiedDiff(actual: unknown, expected: unknown, options: DiffOptions = {}) { - const theme = getConcordanceTheme() - const diff = diffDescriptors(actual, expected, { theme }) - - const { showLegend = true } = options - - const counts = { - '+': 0, - '-': 0, - } - const c = getColors() - const plus = theme.diffGutters.actual - const minus = ` ${c.green('+')}` - - const lines = diff.split(/\r?\n/g) - lines.forEach((line) => { - if (line.startsWith(plus)) - counts['+']++ - else if (line.startsWith(minus)) - counts['-']++ - }) - - if (counts['+'] === 0 && counts['-'] === 0) - return '' - - let legend = '' - - if (showLegend) { - legend = ` ${c.green(`- Expected - ${counts['-']}`)} - ${c.red(`+ Received + ${counts['+']}`)} - -` - } - - return legend + diff.replace(/␊\s*$/mg, '') -} diff --git a/packages/utils/src/diff/cleanupSemantic.ts b/packages/utils/src/diff/cleanupSemantic.ts new file mode 100644 index 000000000000..89cf06ec3c1b --- /dev/null +++ b/packages/utils/src/diff/cleanupSemantic.ts @@ -0,0 +1,561 @@ +/** + * 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.' + */ +const DIFF_DELETE = -1 +const DIFF_INSERT = 1 +const 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. + */ +const 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/ + let pointermin = 0 + let pointermax = Math.min(text1.length, text2.length) + let pointermid = pointermax + let 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. + */ +const 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/ + let pointermin = 0 + let pointermax = Math.min(text1.length, text2.length) + let pointermid = pointermax + let 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 + */ +const diff_commonOverlap_ = function (text1: string, text2: string): number { + // Cache the text lengths to prevent multiple calls. + const text1_length = text1.length + const 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) + + const 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/ + let best = 0 + let length = 1 + while (true) { + const pattern = text1.substring(text_length - length) + const 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. + */ +const diff_cleanupSemantic = function (diffs: Array) { + let changes = false + const equalities = [] // Stack of indices where equalities are found. + let equalitiesLength = 0 // Keeping our own length var is faster in JS. + /** @type {?string} */ + let lastEquality = null + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + let pointer = 0 // Index of current position. + // Number of characters that changed prior to the equality. + let length_insertions1 = 0 + let length_deletions1 = 0 + // Number of characters that changed after the equality. + let length_insertions2 = 0 + let 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) { + const deletion = diffs[pointer - 1][1] + const insertion = diffs[pointer][1] + const overlap_length1 = diff_commonOverlap_(deletion, insertion) + const 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++ + } +} + +// Define some regex patterns for matching boundaries. +const nonAlphaNumericRegex_ = /[^a-zA-Z0-9]/ +const whitespaceRegex_ = /\s/ +const linebreakRegex_ = /[\r\n]/ +const blanklineEndRegex_ = /\n\r?\n$/ +const blanklineStartRegex_ = /^\r?\n\r?\n/ + +/** + * 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. + */ +function diff_cleanupSemanticLossless(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. + const char1 = one.charAt(one.length - 1) + const char2 = two.charAt(0) + const nonAlphaNumeric1 = char1.match(nonAlphaNumericRegex_) + const nonAlphaNumeric2 = char2.match(nonAlphaNumericRegex_) + const whitespace1 = nonAlphaNumeric1 + && char1.match(whitespaceRegex_) + const whitespace2 = nonAlphaNumeric2 + && char2.match(whitespaceRegex_) + const lineBreak1 = whitespace1 + && char1.match(linebreakRegex_) + const lineBreak2 = whitespace2 + && char2.match(linebreakRegex_) + const blankLine1 = lineBreak1 + && one.match(blanklineEndRegex_) + const 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 + } + + let 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. + let equality1 = diffs[pointer - 1][1] + let edit = diffs[pointer][1] + let equality2 = diffs[pointer + 1][1] + + // First, shift the edit as far left as possible. + const commonOffset = diff_commonSuffix(equality1, edit) + if (commonOffset) { + const 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. + let bestEquality1 = equality1 + let bestEdit = edit + let bestEquality2 = equality2 + let 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) + const 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++ + } +} + +/** + * 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. + */ +function diff_cleanupMerge(diffs: Array) { + // Add a dummy entry at the end. + diffs.push(new Diff(DIFF_EQUAL, '')) + let pointer = 0 + let count_delete = 0 + let count_insert = 0 + let text_delete = '' + let text_insert = '' + let 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 + let 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/utils/src/diff/constants.ts b/packages/utils/src/diff/constants.ts new file mode 100644 index 000000000000..4245a83c1d78 --- /dev/null +++ b/packages/utils/src/diff/constants.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const NO_DIFF_MESSAGE = 'Compared values have no visual difference.' + +export const SIMILAR_MESSAGE + = 'Compared values serialize to the same structure.\n' + + 'Printing internal object structure without calling `toJSON` instead.' diff --git a/packages/utils/src/diff/diffLines.ts b/packages/utils/src/diff/diffLines.ts new file mode 100644 index 000000000000..647667d05ae7 --- /dev/null +++ b/packages/utils/src/diff/diffLines.ts @@ -0,0 +1,197 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as diff from 'diff-sequences' +import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic' +import { + joinAlignedDiffsExpand, + joinAlignedDiffsNoExpand, +} from './joinAlignedDiffs' +import { normalizeDiffOptions } from './normalizeDiffOptions' +import type { DiffOptions, DiffOptionsNormalized } from './types' + +function isEmptyString(lines: Array) { + return lines.length === 1 && lines[0].length === 0 +} + +interface ChangeCounts { + a: number + b: number +} + +function countChanges(diffs: Array): ChangeCounts { + let a = 0 + let b = 0 + + diffs.forEach((diff) => { + switch (diff[0]) { + case DIFF_DELETE: + a += 1 + break + + case DIFF_INSERT: + b += 1 + break + } + }) + + return { a, b } +} + +function printAnnotation({ + aAnnotation, + aColor, + aIndicator, + bAnnotation, + bColor, + bIndicator, + includeChangeCounts, + omitAnnotationLines, +}: DiffOptionsNormalized, +changeCounts: ChangeCounts): string { + if (omitAnnotationLines) + return '' + + let aRest = '' + let bRest = '' + + if (includeChangeCounts) { + const aCount = String(changeCounts.a) + const bCount = String(changeCounts.b) + + // Padding right aligns the ends of the annotations. + const baAnnotationLengthDiff = bAnnotation.length - aAnnotation.length + const aAnnotationPadding = ' '.repeat(Math.max(0, baAnnotationLengthDiff)) + const bAnnotationPadding = ' '.repeat(Math.max(0, -baAnnotationLengthDiff)) + + // Padding left aligns the ends of the counts. + const baCountLengthDiff = bCount.length - aCount.length + const aCountPadding = ' '.repeat(Math.max(0, baCountLengthDiff)) + const bCountPadding = ' '.repeat(Math.max(0, -baCountLengthDiff)) + + aRest = `${aAnnotationPadding} ${aIndicator} ${aCountPadding}${aCount}` + bRest = `${bAnnotationPadding} ${bIndicator} ${bCountPadding}${bCount}` + } + + const a = `${aIndicator} ${aAnnotation}${aRest}` + const b = `${bIndicator} ${bAnnotation}${bRest}` + return `${aColor(a)}\n${bColor(b)}\n\n` +} + +export function printDiffLines(diffs: Array, + options: DiffOptionsNormalized): string { + return printAnnotation(options, countChanges(diffs)) + + (options.expand + ? joinAlignedDiffsExpand(diffs, options) + : joinAlignedDiffsNoExpand(diffs, options)) +} + +// Compare two arrays of strings line-by-line. Format as comparison lines. +export function diffLinesUnified(aLines: Array, + bLines: Array, + options?: DiffOptions): string { + return printDiffLines( + diffLinesRaw( + isEmptyString(aLines) ? [] : aLines, + isEmptyString(bLines) ? [] : bLines, + ), + normalizeDiffOptions(options), + ) +} + +// Given two pairs of arrays of strings: +// Compare the pair of comparison arrays line-by-line. +// Format the corresponding lines in the pair of displayable arrays. +export function diffLinesUnified2(aLinesDisplay: Array, + bLinesDisplay: Array, + aLinesCompare: Array, + bLinesCompare: Array, + options?: DiffOptions): string { + if (isEmptyString(aLinesDisplay) && isEmptyString(aLinesCompare)) { + aLinesDisplay = [] + aLinesCompare = [] + } + if (isEmptyString(bLinesDisplay) && isEmptyString(bLinesCompare)) { + bLinesDisplay = [] + bLinesCompare = [] + } + + if ( + aLinesDisplay.length !== aLinesCompare.length + || bLinesDisplay.length !== bLinesCompare.length + ) { + // Fall back to diff of display lines. + return diffLinesUnified(aLinesDisplay, bLinesDisplay, options) + } + + const diffs = diffLinesRaw(aLinesCompare, bLinesCompare) + + // Replace comparison lines with displayable lines. + let aIndex = 0 + let bIndex = 0 + diffs.forEach((diff: Diff) => { + switch (diff[0]) { + case DIFF_DELETE: + diff[1] = aLinesDisplay[aIndex] + aIndex += 1 + break + + case DIFF_INSERT: + diff[1] = bLinesDisplay[bIndex] + bIndex += 1 + break + + default: + diff[1] = bLinesDisplay[bIndex] + aIndex += 1 + bIndex += 1 + } + }) + + return printDiffLines(diffs, normalizeDiffOptions(options)) +} + +// Compare two arrays of strings line-by-line. +export function diffLinesRaw(aLines: Array, + bLines: Array): Array { + const aLength = aLines.length + const bLength = bLines.length + + const isCommon = (aIndex: number, bIndex: number) => + aLines[aIndex] === bLines[bIndex] + + const diffs: Array = [] + let aIndex = 0 + let bIndex = 0 + + const foundSubsequence = ( + nCommon: number, + aCommon: number, + bCommon: number, + ) => { + for (; aIndex !== aCommon; aIndex += 1) + diffs.push(new Diff(DIFF_DELETE, aLines[aIndex])) + + for (; bIndex !== bCommon; bIndex += 1) + diffs.push(new Diff(DIFF_INSERT, bLines[bIndex])) + + for (; nCommon !== 0; nCommon -= 1, aIndex += 1, bIndex += 1) + diffs.push(new Diff(DIFF_EQUAL, bLines[bIndex])) + } + + // @ts-expect-error wrong bundling + diff.default.default(aLength, bLength, isCommon, foundSubsequence) + + // After the last common subsequence, push remaining change items. + for (; aIndex !== aLength; aIndex += 1) + diffs.push(new Diff(DIFF_DELETE, aLines[aIndex])) + + for (; bIndex !== bLength; bIndex += 1) + diffs.push(new Diff(DIFF_INSERT, bLines[bIndex])) + + return diffs +} diff --git a/packages/utils/src/diff/diffStrings.ts b/packages/utils/src/diff/diffStrings.ts new file mode 100644 index 000000000000..88d28c023b0c --- /dev/null +++ b/packages/utils/src/diff/diffStrings.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as diffSequences from 'diff-sequences' +import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic' + +function 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))) + } + + // @ts-expect-error wrong bundling + diffSequences.default.default(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/utils/src/diff/getAlignedDiffs.ts b/packages/utils/src/diff/getAlignedDiffs.ts new file mode 100644 index 000000000000..6979305515bb --- /dev/null +++ b/packages/utils/src/diff/getAlignedDiffs.ts @@ -0,0 +1,235 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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 type { DiffOptionsColor } from './types' + +// Given change op and array of diffs, return concatenated string: +// * include common strings +// * include change strings which have argument op with changeColor +// * exclude change strings which have opposite op +function concatenateRelevantDiffs(op: number, + diffs: Array, + changeColor: DiffOptionsColor): string { + return diffs.reduce( + (reduced: string, diff: Diff): string => + reduced + + (diff[0] === DIFF_EQUAL + ? diff[1] + : (diff[0] === op && diff[1].length !== 0) // empty if change is newline + ? changeColor(diff[1]) + : ''), + '', + ) +} + +// Encapsulate change lines until either a common newline or the end. +class ChangeBuffer { + private readonly op: number + private line: Array // incomplete line + private lines: Array // complete lines + private readonly changeColor: DiffOptionsColor + + constructor(op: number, changeColor: DiffOptionsColor) { + this.op = op + this.line = [] + this.lines = [] + this.changeColor = changeColor + } + + 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. + + // If line has multiple diffs, then assume it has a common diff, + // therefore change diffs have change color; + // otherwise then it has line color only. + this.lines.push( + this.line.length !== 1 + ? new Diff( + this.op, + concatenateRelevantDiffs(this.op, this.line, this.changeColor), + ) + : this.line[0][0] === this.op + ? this.line[0] // can use instance + : new Diff(this.op, this.line[0][1]), // was common diff + ) + 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 (string.includes('\n')) { + 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 readonly deleteBuffer: ChangeBuffer + private readonly insertBuffer: ChangeBuffer + private readonly 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 (string.includes('\n')) { + 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 +function getAlignedDiffs(diffs: Array, + changeColor: DiffOptionsColor): Array { + const deleteBuffer = new ChangeBuffer(DIFF_DELETE, changeColor) + const insertBuffer = new ChangeBuffer(DIFF_INSERT, changeColor) + 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/utils/src/diff/getType.ts b/packages/utils/src/diff/getType.ts new file mode 100644 index 000000000000..9e6950774530 --- /dev/null +++ b/packages/utils/src/diff/getType.ts @@ -0,0 +1,67 @@ +type ValueType = + | 'array' + | 'bigint' + | 'boolean' + | 'function' + | 'null' + | 'number' + | 'object' + | 'regexp' + | 'map' + | 'set' + | 'date' + | 'string' + | 'symbol' + | 'undefined' + +// get the type of a value with handling the edge cases like `typeof []` +// and `typeof null` +export function getType(value: unknown): ValueType { + if (value === undefined) { + return 'undefined' + } + else if (value === null) { + return 'null' + } + else if (Array.isArray(value)) { + return 'array' + } + else if (typeof value === 'boolean') { + return 'boolean' + } + else if (typeof value === 'function') { + return 'function' + } + else if (typeof value === 'number') { + return 'number' + } + else if (typeof value === 'string') { + return 'string' + } + else if (typeof value === 'bigint') { + return 'bigint' + } + else if (typeof value === 'object') { + if (value != null) { + if (value.constructor === RegExp) + return 'regexp' + + else if (value.constructor === Map) + return 'map' + + else if (value.constructor === Set) + return 'set' + + else if (value.constructor === Date) + return 'date' + } + return 'object' + } + else if (typeof value === 'symbol') { + return 'symbol' + } + + throw new Error(`value of unknown type: ${value}`) +} + +export const isPrimitive = (value: unknown): boolean => Object(value) !== value diff --git a/packages/utils/src/diff/index.ts b/packages/utils/src/diff/index.ts new file mode 100644 index 000000000000..e65b776473cf --- /dev/null +++ b/packages/utils/src/diff/index.ts @@ -0,0 +1,204 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +// This is a fork of Jest's jest-diff package, but it doesn't depend on Node environment (like chalk). + +import type { PrettyFormatOptions } from 'pretty-format' +import { + format as prettyFormat, + plugins as prettyFormatPlugins, +} from 'pretty-format' +import { getType } from './getType' +import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } from './cleanupSemantic' +import { NO_DIFF_MESSAGE, SIMILAR_MESSAGE } from './constants' +import { diffLinesRaw, diffLinesUnified, diffLinesUnified2 } from './diffLines' +import { normalizeDiffOptions } from './normalizeDiffOptions' +import { diffStringsRaw, diffStringsUnified } from './printDiffs' +import type { DiffOptions } from './types' + +export type { DiffOptions, DiffOptionsColor } from './types' + +export { diffLinesRaw, diffLinesUnified, diffLinesUnified2 } +export { diffStringsRaw, diffStringsUnified } +export { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff } + +function getCommonMessage(message: string, options?: DiffOptions) { + const { commonColor } = normalizeDiffOptions(options) + return commonColor(message) +} + +const { + AsymmetricMatcher, + DOMCollection, + DOMElement, + Immutable, + ReactElement, + ReactTestComponent, +} = prettyFormatPlugins + +const PLUGINS = [ + ReactTestComponent, + ReactElement, + DOMElement, + DOMCollection, + Immutable, + AsymmetricMatcher, +] +const FORMAT_OPTIONS = { + plugins: PLUGINS, +} +const FALLBACK_FORMAT_OPTIONS = { + callToJSON: false, + maxDepth: 10, + plugins: PLUGINS, +} + +// Generate a string that will highlight the difference between two values +// with green and red. (similar to how github does code diffing) + +export function diff(a: any, b: any, options?: DiffOptions): string | null { + if (Object.is(a, b)) + return '' + + const aType = getType(a) + let expectedType = aType + let omitDifference = false + if (aType === 'object' && typeof a.asymmetricMatch === 'function') { + if (a.$$typeof !== Symbol.for('jest.asymmetricMatcher')) { + // Do not know expected type of user-defined asymmetric matcher. + return null + } + if (typeof a.getExpectedType !== 'function') { + // For example, expect.anything() matches either null or undefined + return null + } + expectedType = a.getExpectedType() + // Primitive types boolean and number omit difference below. + // For example, omit difference for expect.stringMatching(regexp) + omitDifference = expectedType === 'string' + } + + if (expectedType !== getType(b)) { + const { aAnnotation, aColor, aIndicator, bAnnotation, bColor, bIndicator } = normalizeDiffOptions(options) + const formatOptions = getFormatOptions(FALLBACK_FORMAT_OPTIONS, options) + const aDisplay = prettyFormat(a, formatOptions) + const bDisplay = prettyFormat(b, formatOptions) + const aDiff = `${aColor(`${aIndicator} ${aAnnotation}:`)} \n${aDisplay}` + const bDiff = `${bColor(`${bIndicator} ${bAnnotation}:`)} \n${bDisplay}` + return `${aDiff}\n\n${bDiff}` + } + + if (omitDifference) + return null + + switch (aType) { + case 'string': + return diffLinesUnified(a.split('\n'), b.split('\n'), options) + case 'boolean': + case 'number': + return comparePrimitive(a, b, options) + case 'map': + return compareObjects(sortMap(a), sortMap(b), options) + case 'set': + return compareObjects(sortSet(a), sortSet(b), options) + default: + return compareObjects(a, b, options) + } +} + +function comparePrimitive( + a: number | boolean, + b: number | boolean, + options?: DiffOptions, +) { + const aFormat = prettyFormat(a, FORMAT_OPTIONS) + const bFormat = prettyFormat(b, FORMAT_OPTIONS) + return aFormat === bFormat + ? '' + : diffLinesUnified(aFormat.split('\n'), bFormat.split('\n'), options) +} + +function sortMap(map: Map) { + return new Map(Array.from(map.entries()).sort()) +} + +function sortSet(set: Set) { + return new Set(Array.from(set.values()).sort()) +} + +function compareObjects( + a: Record, + b: Record, + options?: DiffOptions, +) { + let difference + let hasThrown = false + + try { + const formatOptions = getFormatOptions(FORMAT_OPTIONS, options) + difference = getObjectsDifference(a, b, formatOptions, options) + } + catch { + hasThrown = true + } + + const noDiffMessage = getCommonMessage(NO_DIFF_MESSAGE, options) + // If the comparison yields no results, compare again but this time + // without calling `toJSON`. It's also possible that toJSON might throw. + if (difference === undefined || difference === noDiffMessage) { + const formatOptions = getFormatOptions(FALLBACK_FORMAT_OPTIONS, options) + difference = getObjectsDifference(a, b, formatOptions, options) + + if (difference !== noDiffMessage && !hasThrown) { + difference = `${getCommonMessage( + SIMILAR_MESSAGE, + options, + )}\n\n${difference}` + } + } + + return difference +} + +function getFormatOptions( + formatOptions: PrettyFormatOptions, + options?: DiffOptions, +): PrettyFormatOptions { + const { compareKeys } = normalizeDiffOptions(options) + + return { + ...formatOptions, + compareKeys, + } +} + +function getObjectsDifference( + a: Record, + b: Record, + formatOptions: PrettyFormatOptions, + options?: DiffOptions, +): string { + const formatOptionsZeroIndent = { ...formatOptions, indent: 0 } + const aCompare = prettyFormat(a, formatOptionsZeroIndent) + const bCompare = prettyFormat(b, formatOptionsZeroIndent) + + if (aCompare === bCompare) { + return getCommonMessage(NO_DIFF_MESSAGE, options) + } + else { + const aDisplay = prettyFormat(a, formatOptions) + const bDisplay = prettyFormat(b, formatOptions) + + return diffLinesUnified2( + aDisplay.split('\n'), + bDisplay.split('\n'), + aCompare.split('\n'), + bCompare.split('\n'), + options, + ) + } +} diff --git a/packages/utils/src/diff/joinAlignedDiffs.ts b/packages/utils/src/diff/joinAlignedDiffs.ts new file mode 100644 index 000000000000..5b5484848eae --- /dev/null +++ b/packages/utils/src/diff/joinAlignedDiffs.ts @@ -0,0 +1,292 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { Diff } from './cleanupSemantic' +import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from './cleanupSemantic' +import type { DiffOptionsColor, DiffOptionsNormalized } from './types' + +function formatTrailingSpaces(line: string, + trailingSpaceFormatter: DiffOptionsColor): string { + return line.replace(/\s+$/, match => trailingSpaceFormatter(match)) +} + +function printDiffLine(line: string, + isFirstOrLast: boolean, + color: DiffOptionsColor, + indicator: string, + trailingSpaceFormatter: DiffOptionsColor, + emptyFirstOrLastLinePlaceholder: string): string { + return line.length !== 0 + ? color( + `${indicator} ${formatTrailingSpaces(line, trailingSpaceFormatter)}`, + ) + : indicator !== ' ' + ? color(indicator) + : (isFirstOrLast && emptyFirstOrLastLinePlaceholder.length !== 0) + ? color(`${indicator} ${emptyFirstOrLastLinePlaceholder}`) + : '' +} + +function printDeleteLine(line: string, + isFirstOrLast: boolean, + { + aColor, + aIndicator, + changeLineTrailingSpaceColor, + emptyFirstOrLastLinePlaceholder, + }: DiffOptionsNormalized): string { + return printDiffLine( + line, + isFirstOrLast, + aColor, + aIndicator, + changeLineTrailingSpaceColor, + emptyFirstOrLastLinePlaceholder, + ) +} + +function printInsertLine(line: string, + isFirstOrLast: boolean, + { + bColor, + bIndicator, + changeLineTrailingSpaceColor, + emptyFirstOrLastLinePlaceholder, + }: DiffOptionsNormalized): string { + return printDiffLine( + line, + isFirstOrLast, + bColor, + bIndicator, + changeLineTrailingSpaceColor, + emptyFirstOrLastLinePlaceholder, + ) +} + +function printCommonLine(line: string, + isFirstOrLast: boolean, + { + commonColor, + commonIndicator, + commonLineTrailingSpaceColor, + emptyFirstOrLastLinePlaceholder, + }: DiffOptionsNormalized): string { + return printDiffLine( + line, + isFirstOrLast, + commonColor, + commonIndicator, + commonLineTrailingSpaceColor, + emptyFirstOrLastLinePlaceholder, + ) +} + +// In GNU diff format, indexes are one-based instead of zero-based. +function createPatchMark(aStart: number, + aEnd: number, + bStart: number, + bEnd: number, + { patchColor }: DiffOptionsNormalized): string { + return patchColor( + `@@ -${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 function joinAlignedDiffsNoExpand(diffs: Array, + options: DiffOptionsNormalized): string { + const iLength = diffs.length + const nContextLines = options.contextLines + const nContextLines2 = nContextLines + nContextLines + + // First pass: count output lines and see if it has patches. + 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, options)) + aEnd += 1 + bEnd += 1 + } + + const pushDeleteLine = (line: string): void => { + const j = lines.length + lines.push(printDeleteLine(line, j === 0 || j === jLast, options)) + aEnd += 1 + } + + const pushInsertLine = (line: string): void => { + const j = lines.length + lines.push(printInsertLine(line, j === 0 || j === jLast, options)) + 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, + options, + ) + 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, options) + + return lines.join('\n') +} + +// jest --expand +// +// Given array of aligned strings with inverse highlight formatting, +// return joined lines with diff formatting. +export function joinAlignedDiffsExpand(diffs: Array, + options: DiffOptionsNormalized): string { + return diffs + .map((diff: Diff, i: number, diffs: Array): string => { + const line = diff[1] + const isFirstOrLast = i === 0 || i === diffs.length - 1 + + switch (diff[0]) { + case DIFF_DELETE: + return printDeleteLine(line, isFirstOrLast, options) + + case DIFF_INSERT: + return printInsertLine(line, isFirstOrLast, options) + + default: + return printCommonLine(line, isFirstOrLast, options) + } + }) + .join('\n') +} diff --git a/packages/utils/src/diff/normalizeDiffOptions.ts b/packages/utils/src/diff/normalizeDiffOptions.ts new file mode 100644 index 000000000000..422dd77377e9 --- /dev/null +++ b/packages/utils/src/diff/normalizeDiffOptions.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { CompareKeys } from 'pretty-format' +import { getColors } from '../colors' +import type { DiffOptions, DiffOptionsNormalized } from './types' + +export const noColor = (string: string): string => string + +const DIFF_CONTEXT_DEFAULT = 5 + +function getDefaultOptions(): DiffOptionsNormalized { + const c = getColors() + + return { + aAnnotation: 'Expected', + aColor: c.green, + aIndicator: '-', + bAnnotation: 'Received', + bColor: c.red, + bIndicator: '+', + changeColor: c.inverse, + changeLineTrailingSpaceColor: noColor, + commonColor: c.dim, + commonIndicator: ' ', + commonLineTrailingSpaceColor: noColor, + compareKeys: undefined, + contextLines: DIFF_CONTEXT_DEFAULT, + emptyFirstOrLastLinePlaceholder: '', + expand: true, + includeChangeCounts: false, + omitAnnotationLines: false, + patchColor: c.yellow, + } +} + +function getCompareKeys(compareKeys?: CompareKeys): CompareKeys { + return (compareKeys && typeof compareKeys === 'function') + ? compareKeys + : undefined +} + +function getContextLines(contextLines?: number): number { + return (typeof contextLines === 'number' + && Number.isSafeInteger(contextLines) + && contextLines >= 0) + ? contextLines + : DIFF_CONTEXT_DEFAULT +} + +// Pure function returns options with all properties. +export function normalizeDiffOptions(options: DiffOptions = {}): DiffOptionsNormalized { + return { + ...getDefaultOptions(), + ...options, + compareKeys: getCompareKeys(options.compareKeys), + contextLines: getContextLines(options.contextLines), + } +} diff --git a/packages/utils/src/diff/printDiffs.ts b/packages/utils/src/diff/printDiffs.ts new file mode 100644 index 000000000000..c18df48b89bf --- /dev/null +++ b/packages/utils/src/diff/printDiffs.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { Diff } from './cleanupSemantic' +import { DIFF_EQUAL, cleanupSemantic } from './cleanupSemantic' +import { diffLinesUnified, printDiffLines } from './diffLines' +import diffStrings from './diffStrings' +import getAlignedDiffs from './getAlignedDiffs' +import { normalizeDiffOptions } from './normalizeDiffOptions' +import type { DiffOptions } from './types' + +function hasCommonDiff(diffs: Array, isMultiline: boolean): 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) +} + +// Compare two strings character-by-character. +// Format as comparison lines in which changed substrings have inverse colors. +export function diffStringsUnified(a: string, + b: string, + options?: DiffOptions): string { + if (a !== b && a.length !== 0 && b.length !== 0) { + const isMultiline = a.includes('\n') || b.includes('\n') + + // getAlignedDiffs assumes that a newline was appended to the strings. + const diffs = diffStringsRaw( + isMultiline ? `${a}\n` : a, + isMultiline ? `${b}\n` : b, + true, // cleanupSemantic + ) + + if (hasCommonDiff(diffs, isMultiline)) { + const optionsNormalized = normalizeDiffOptions(options) + const lines = getAlignedDiffs(diffs, optionsNormalized.changeColor) + return printDiffLines(lines, optionsNormalized) + } + } + + // Fall back to line-by-line diff. + return diffLinesUnified(a.split('\n'), b.split('\n'), options) +} + +// Compare two strings character-by-character. +// Optionally clean up small common substrings, also known as chaff. +export function diffStringsRaw(a: string, + b: string, + cleanup: boolean): Array { + const diffs = diffStrings(a, b) + + if (cleanup) + cleanupSemantic(diffs) // impure function + + return diffs +} diff --git a/packages/utils/src/diff/types.ts b/packages/utils/src/diff/types.ts new file mode 100644 index 000000000000..c7b984fd9fad --- /dev/null +++ b/packages/utils/src/diff/types.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import type { CompareKeys } from 'pretty-format' + +export type DiffOptionsColor = (arg: string) => string // subset of picocolors type + +export interface DiffOptions { + aAnnotation?: string + aColor?: DiffOptionsColor + aIndicator?: string + bAnnotation?: string + bColor?: DiffOptionsColor + bIndicator?: string + changeColor?: DiffOptionsColor + changeLineTrailingSpaceColor?: DiffOptionsColor + commonColor?: DiffOptionsColor + commonIndicator?: string + commonLineTrailingSpaceColor?: DiffOptionsColor + contextLines?: number + emptyFirstOrLastLinePlaceholder?: string + expand?: boolean + includeChangeCounts?: boolean + omitAnnotationLines?: boolean + patchColor?: DiffOptionsColor + compareKeys?: CompareKeys +} + +export interface DiffOptionsNormalized { + aAnnotation: string + aColor: DiffOptionsColor + aIndicator: string + bAnnotation: string + bColor: DiffOptionsColor + bIndicator: string + changeColor: DiffOptionsColor + changeLineTrailingSpaceColor: DiffOptionsColor + commonColor: DiffOptionsColor + commonIndicator: string + commonLineTrailingSpaceColor: DiffOptionsColor + compareKeys: CompareKeys + contextLines: number + emptyFirstOrLastLinePlaceholder: string + expand: boolean + includeChangeCounts: boolean + omitAnnotationLines: boolean + patchColor: DiffOptionsColor +} diff --git a/packages/utils/src/error.ts b/packages/utils/src/error.ts index a610477688ec..536aba4f7abe 100644 --- a/packages/utils/src/error.ts +++ b/packages/utils/src/error.ts @@ -1,7 +1,6 @@ -import type { DiffOptions } from './diff' -import { unifiedDiff } from './diff' +import { diff } from './diff' import { format } from './display' -import { deepClone, getOwnProperties, getType } from './helpers' +import { getOwnProperties, getType } from './helpers' import { stringify } from './stringify' const IS_RECORD_SYMBOL = '@@__IMMUTABLE_RECORD__@@' @@ -87,7 +86,7 @@ function normalizeErrorMessage(message: string) { return message.replace(/__vite_ssr_import_\d+__\./g, '') } -export function processError(err: any, options: DiffOptions = {}) { +export function processError(err: any) { if (!err || typeof err !== 'object') return { message: err } // stack is not serialized in worker communication @@ -97,13 +96,8 @@ export function processError(err: any, options: DiffOptions = {}) { if (err.name) err.nameStr = String(err.name) - const clonedActual = deepClone(err.actual, { forceWritable: true }) - const clonedExpected = deepClone(err.expected, { forceWritable: true }) - - const { replacedActual, replacedExpected } = replaceAsymmetricMatcher(clonedActual, clonedExpected) - if (err.showDiff || (err.showDiff === undefined && err.expected !== undefined && err.actual !== undefined)) - err.diff = unifiedDiff(replacedActual, replacedExpected, options) + err.diff = diff(err.actual, err.expected) if (typeof err.expected !== 'string') err.expected = stringify(err.expected, 10) diff --git a/packages/utils/src/external.d.ts b/packages/utils/src/external.d.ts deleted file mode 100644 index af046adb6812..000000000000 --- a/packages/utils/src/external.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'concordance' { - export function diff(expected: unknown, actual: unknown, options?: any): string -} \ No newline at end of file diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index db86d82cdcb8..db16a2e0b399 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -184,8 +184,9 @@ function printModuleWarningForSourceCode(logger: Logger, path: string) { )) } -export function displayDiff(diff: string, console: Console) { - console.error(`\n${diff}\n`) +export function displayDiff(diff: string | null, console: Console) { + if (diff) + console.error(`\n${diff}\n`) } function printErrorMessage(error: ErrorWithDiff, logger: Logger) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c72b4b005675..706b8638c004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1225,9 +1225,9 @@ importers: packages/utils: dependencies: - concordance: - specifier: ^5.0.4 - version: 5.0.4 + diff-sequences: + specifier: ^29.4.3 + version: 29.4.3 loupe: specifier: ^2.3.6 version: 2.3.6 @@ -1513,7 +1513,7 @@ importers: version: link:../../packages/vitest webdriverio: specifier: latest - version: 8.11.2(typescript@5.0.4) + version: 8.10.7(typescript@5.0.4) test/base: devDependencies: @@ -1651,7 +1651,7 @@ importers: version: 3.3.4 webdriverio: specifier: latest - version: 8.11.2(typescript@5.0.4) + version: 8.10.7(typescript@5.0.4) test/css: devDependencies: @@ -1931,7 +1931,7 @@ importers: version: link:../../packages/vitest webdriverio: specifier: latest - version: 8.11.2(typescript@5.0.4) + version: 8.10.7(typescript@5.0.4) test/web-worker: devDependencies: @@ -9869,13 +9869,13 @@ packages: vue-demi: 0.14.0(vue@3.2.39) dev: false - /@wdio/config@8.11.0: - resolution: {integrity: sha512-nBQXsXbPCjddtI/3rAK5yFs3eD3f0T3lZMivweTkLLR7GKBxGjiFoBjXtfqUrHJYa+2uwfXrwxo6y+dA6fVbuw==} + /@wdio/config@8.10.7: + resolution: {integrity: sha512-m7JX9X/RPM+4KZQkSUhPHXeS3PJJky0UB62ZLh28TYCzVxEKNq1gFb6Cvqfn+w6Ym/UCFBaZzDrRLLXUAgUifw==} engines: {node: ^16.13 || >=18} dependencies: - '@wdio/logger': 8.11.0 + '@wdio/logger': 8.10.6 '@wdio/types': 8.10.4 - '@wdio/utils': 8.11.0 + '@wdio/utils': 8.10.7 decamelize: 6.0.0 deepmerge-ts: 5.0.0 glob: 10.2.2 @@ -9897,8 +9897,8 @@ packages: read-pkg-up: 9.1.0 dev: true - /@wdio/logger@8.11.0: - resolution: {integrity: sha512-IsuKSaYi7NKEdgA57h8muzlN/MVp1dQG+V4C//7g4m03YJUnNQLvDhJzLjdeNTfvZy61U7foQSyt+3ktNzZkXA==} + /@wdio/logger@8.10.6: + resolution: {integrity: sha512-pJYKecNYS0vu0FxDaii6MYQlYkORDLGdRWi70hrihH20sMZofzMA1tvzRMRk1VTrsUUE4TLJXKdmT2JRMN+OPw==} engines: {node: ^16.13 || >=18} dependencies: chalk: 5.2.0 @@ -9917,8 +9917,8 @@ packages: strip-ansi: 6.0.1 dev: true - /@wdio/protocols@8.11.0: - resolution: {integrity: sha512-eXTMYt/XoaX53H/Q2qmsn1uWthIC5aSTGtX9YyXD/AkagG2hXeX3lLmzNWBaSIvKR+vWXRYbg3Y/7IvL2s25Wg==} + /@wdio/protocols@8.10.2: + resolution: {integrity: sha512-Iv7Nqq6YsMQR9qvOM2mswUcKwx7bdx3cWVSmbMc8hwGJuNCBI+BP1fzmD9OidUftd1CQNvfugsG8Vq8vQWRyGg==} dev: true /@wdio/protocols@8.8.1: @@ -9953,11 +9953,11 @@ packages: '@types/node': 18.16.3 dev: true - /@wdio/utils@8.11.0: - resolution: {integrity: sha512-XBl1zalk5UPu8QKZ7LZIA82Ad363fpNHZHP5uI5OxUFnk4ZPWgY9eCWpeD+4f9a0DS0w2Dro15E4PORNX84pIw==} + /@wdio/utils@8.10.7: + resolution: {integrity: sha512-G9r/bQl4J25WPKeW0e+27gqNvG+x7MyZEOICl6SwyBe0SqO7JBFOBARx4oUEp2zQYmnCRNtCrHa7yM/O4OPuKA==} engines: {node: ^16.13 || >=18} dependencies: - '@wdio/logger': 8.11.0 + '@wdio/logger': 8.10.6 '@wdio/types': 8.10.4 import-meta-resolve: 3.0.0 p-iteration: 1.1.8 @@ -13176,24 +13176,24 @@ packages: resolution: {integrity: sha512-LF+0k1kYkrx2dZsvjLyNY2ySydz4lCy/xFvjuI5mCFGnepk5hC9iXbsdFk6jYma0ZvXaTxl3sGTiVr/GC0knyQ==} dev: true - /devtools-protocol@0.0.1152884: - resolution: {integrity: sha512-9eP6OmCoU1cWArpXLuzyZQcBJ2PkINOh8Nwx8W5i8u6NDigDE5/mPlLLBAfshwn5YVvIz6ZQ9jbs0PZvKGccdQ==} + /devtools-protocol@0.0.1149535: + resolution: {integrity: sha512-vpM8tGaYz2nrN9n8rvUEhQCgU05ocejO5WIJySsftEHxUahQ/fWuNyPxXuQNBEmaISYyMZkxCunhjtSEyBl/Dg==} dev: true /devtools-protocol@0.0.981744: resolution: {integrity: sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==} dev: true - /devtools@8.11.0(typescript@5.0.4): - resolution: {integrity: sha512-j1wXFQyjswJ6doAV1+h4Bxl8+Oeb8SMpWTpBVa0DurGsxfft8sU2OhDlMo5tx/zbX82X5sGyJDMnKHqBJ2XRvQ==} + /devtools@8.10.7(typescript@5.0.4): + resolution: {integrity: sha512-picJDxsjpaOW7gnQjcQVGDXvxuP1RZ4VNJpvJ+qiy86Gf3l4FGLKYkpJJFAMsIugL0XPs89bIVhjbtIv5NGL1w==} engines: {node: ^16.13 || >=18} dependencies: '@types/node': 20.3.1 - '@wdio/config': 8.11.0 - '@wdio/logger': 8.11.0 - '@wdio/protocols': 8.11.0 + '@wdio/config': 8.10.7 + '@wdio/logger': 8.10.6 + '@wdio/protocols': 8.10.2 '@wdio/types': 8.10.4 - '@wdio/utils': 8.11.0 + '@wdio/utils': 8.10.7 chrome-launcher: 0.15.1 edge-paths: 3.0.5 import-meta-resolve: 3.0.0 @@ -13248,10 +13248,9 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dev: true - /diff-sequences@29.0.0: - resolution: {integrity: sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA==} + /diff-sequences@29.4.3: + resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true /diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} @@ -17268,7 +17267,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: chalk: 4.1.2 - diff-sequences: 29.0.0 + diff-sequences: 29.4.3 jest-get-type: 29.0.0 pretty-format: 29.0.1 dev: true @@ -24886,17 +24885,17 @@ packages: resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==} dev: true - /webdriver@8.11.1: - resolution: {integrity: sha512-hSpUZYzUA65t4DDtKujCHUX6hpFTUleb7lWMcf5xjPz8sxWrK9R8NIw7pXt/GU6PVS331nGAaYkzoXrqz2VB8w==} + /webdriver@8.10.7: + resolution: {integrity: sha512-pBy29S9e8IYKYHfS0gp91Jp9SUvXJQckJKJdj+VNLNL9toSFo10N7xRpv8W1f7HkniXrgESi9GHYNyc1J/5lLA==} engines: {node: ^16.13 || >=18} dependencies: '@types/node': 20.3.1 '@types/ws': 8.5.4 - '@wdio/config': 8.11.0 - '@wdio/logger': 8.11.0 - '@wdio/protocols': 8.11.0 + '@wdio/config': 8.10.7 + '@wdio/logger': 8.10.6 + '@wdio/protocols': 8.10.2 '@wdio/types': 8.10.4 - '@wdio/utils': 8.11.0 + '@wdio/utils': 8.10.7 deepmerge-ts: 5.0.0 got: 12.6.1 ky: 0.33.3 @@ -24926,23 +24925,23 @@ packages: - utf-8-validate dev: true - /webdriverio@8.11.2(typescript@5.0.4): - resolution: {integrity: sha512-e/9WkdNTfWeoaSo2UzK0Giec/nQX3i7U9J8esimhozH/EpwSqIaEJ2pRRlxRVafEhe2OBG1QDhnLnDjdCC5Hxg==} + /webdriverio@8.10.7(typescript@5.0.4): + resolution: {integrity: sha512-TkkPE3zBxdLRdcsNLqHct2OARnfMYB9/A0ri4sccmc3C3dVFiW99NAstN88nzD1SYzXAbxALRuITVd5oswqqhg==} engines: {node: ^16.13 || >=18} dependencies: '@types/node': 20.3.1 - '@wdio/config': 8.11.0 - '@wdio/logger': 8.11.0 - '@wdio/protocols': 8.11.0 + '@wdio/config': 8.10.7 + '@wdio/logger': 8.10.6 + '@wdio/protocols': 8.10.2 '@wdio/repl': 8.10.1 '@wdio/types': 8.10.4 - '@wdio/utils': 8.11.0 + '@wdio/utils': 8.10.7 archiver: 5.3.1 aria-query: 5.0.2 css-shorthand-properties: 1.1.1 css-value: 0.0.1 - devtools: 8.11.0(typescript@5.0.4) - devtools-protocol: 0.0.1152884 + devtools: 8.10.7(typescript@5.0.4) + devtools-protocol: 0.0.1149535 grapheme-splitter: 1.0.4 import-meta-resolve: 3.0.0 is-plain-obj: 4.1.0 @@ -24954,7 +24953,7 @@ packages: resq: 1.11.0 rgb2hex: 0.2.5 serialize-error: 8.1.0 - webdriver: 8.11.1 + webdriver: 8.10.7 transitivePeerDependencies: - bufferutil - encoding diff --git a/test/core/test/__snapshots__/mocked.test.ts.snap b/test/core/test/__snapshots__/mocked.test.ts.snap index b0a115818920..a8cdcda8925e 100644 --- a/test/core/test/__snapshots__/mocked.test.ts.snap +++ b/test/core/test/__snapshots__/mocked.test.ts.snap @@ -4,10 +4,11 @@ exports[`mocked function which fails on toReturnWith > just one call 1`] = ` "expected \\"spy\\" to return with: 2 at least once Received: - 1st spy call return: - - 2 - + 1 + 1st spy call return: + +- 2 ++ 1 Number of calls: 1 @@ -18,20 +19,21 @@ exports[`mocked function which fails on toReturnWith > multi calls 1`] = ` "expected \\"spy\\" to return with: 2 at least once Received: - 1st spy call return: - - 2 - + 1 + 1st spy call return: + +- 2 ++ 1 - 2nd spy call return: + 2nd spy call return: - - 2 - + 1 +- 2 ++ 1 - 3rd spy call return: + 3rd spy call return: - - 2 - + 1 +- 2 ++ 1 Number of calls: 3 @@ -42,26 +44,27 @@ exports[`mocked function which fails on toReturnWith > oject type 1`] = ` "expected \\"spy\\" to return with: { a: '4' } at least once Received: - 1st spy call return: - { - - a: '4', - + a: '1', - } + 1st spy call return: - 2nd spy call return: + Object { +- \\"a\\": \\"4\\", ++ \\"a\\": \\"1\\", + } - { - - a: '4', - + a: '1', - } + 2nd spy call return: - 3rd spy call return: + Object { +- \\"a\\": \\"4\\", ++ \\"a\\": \\"1\\", + } - { - - a: '4', - + a: '1', - } + 3rd spy call return: + + Object { +- \\"a\\": \\"4\\", ++ \\"a\\": \\"1\\", + } Number of calls: 3 @@ -74,6 +77,7 @@ exports[`mocked function which fails on toReturnWith > zero call 1`] = ` Received: + Number of calls: 0 " `; diff --git a/test/core/test/diff.test.ts b/test/core/test/diff.test.ts index f6902388311e..d8d14c3c093b 100644 --- a/test/core/test/diff.test.ts +++ b/test/core/test/diff.test.ts @@ -1,24 +1,24 @@ import { expect, test, vi } from 'vitest' import { getDefaultColors, setupColors } from '@vitest/utils' import { displayDiff } from 'vitest/src/node/error' -import { unifiedDiff } from '@vitest/utils/diff' +import { diff } from '@vitest/utils/diff' test('displays object diff', () => { const objectA = { a: 1, b: 2 } const objectB = { a: 1, b: 3 } const console = { log: vi.fn(), error: vi.fn() } setupColors(getDefaultColors()) - displayDiff(unifiedDiff(objectA, objectB), console as any) + displayDiff(diff(objectA, objectB), console as any) expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(` " - - Expected - 1 - + Received + 1 + - Expected + + Received - { - a: 1, - - b: 3, - + b: 2, - } + Object { + \\"a\\": 1, + - \\"b\\": 2, + + \\"b\\": 3, + } " `) }) @@ -28,14 +28,14 @@ test('display one line string diff', () => { const string2 = 'string2' const console = { log: vi.fn(), error: vi.fn() } setupColors(getDefaultColors()) - displayDiff(unifiedDiff(string1, string2), console as any) + displayDiff(diff(string1, string2), console as any) expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(` " - - Expected - 1 - + Received + 1 + - Expected + + Received - - 'string2' - + 'string1' + - string1 + + string2 " `) }) @@ -45,17 +45,17 @@ test('display multiline line string diff', () => { const string2 = 'string2\nstring2\nstring1' const console = { log: vi.fn(), error: vi.fn() } setupColors(getDefaultColors()) - displayDiff(unifiedDiff(string1, string2), console as any) + displayDiff(diff(string1, string2), console as any) expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(` " - - Expected - 2 - + Received + 2 + - Expected + + Received - + string1 - \`string2 - - string2 - - string1\` - + string3\` + - string1 + string2 + - string3 + + string2 + + string1 " `) }) diff --git a/test/core/test/jest-matcher-utils.test.ts b/test/core/test/jest-matcher-utils.test.ts index 55bef70718ae..90f353a4ab17 100644 --- a/test/core/test/jest-matcher-utils.test.ts +++ b/test/core/test/jest-matcher-utils.test.ts @@ -5,7 +5,7 @@ describe('jest-matcher-utils', () => { expect.extend({ toBeJestEqual(received: any, expected: any) { return { - message: () => this.utils.diff(expected, received), + message: () => this.utils.diff(expected, received) || '', pass: received === expected, } }, @@ -17,6 +17,6 @@ describe('jest-matcher-utils', () => { expect(() => { // @ts-expect-error "toBeJestEqual" is a custom matcher we just created expect('a').toBeJestEqual('b') - }).toThrowError(/- 'b'.*\+ 'a'/ms) + }).toThrowError(/- b.*\+ a/ms) }) }) diff --git a/test/reporters/tests/__snapshots__/html.test.ts.snap b/test/reporters/tests/__snapshots__/html.test.ts.snap index 43e7be1930fe..dfe9a67035f6 100644 --- a/test/reporters/tests/__snapshots__/html.test.ts.snap +++ b/test/reporters/tests/__snapshots__/html.test.ts.snap @@ -45,11 +45,11 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" "error": { "actual": "2", "constructor": "Function", - "diff": " - Expected - 1 - + Received + 1 + "diff": "- Expected ++ Received - - 1 - + 2", +- 2 ++ 1", "expected": "1", "message": "expected 2 to deeply equal 1", "name": "AssertionError", @@ -65,11 +65,11 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" { "actual": "2", "constructor": "Function", - "diff": " - Expected - 1 - + Received + 1 + "diff": "- Expected ++ Received - - 1 - + 2", +- 2 ++ 1", "expected": "1", "message": "expected 2 to deeply equal 1", "name": "AssertionError",