From deac914f5474cd4c3b7a586c19d281a99daf1409 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 1 Nov 2025 14:19:58 -0300 Subject: [PATCH] lib: add word-level diff for string comparisons --- lib/internal/assert/assertion_error.js | 70 ++++++++++++ test/parallel/test-assert.js | 150 +++++++++++++++++++++++++ 2 files changed, 220 insertions(+) diff --git a/lib/internal/assert/assertion_error.js b/lib/internal/assert/assertion_error.js index 5c15b96b12d1ea..8084c9699d4f8b 100644 --- a/lib/internal/assert/assertion_error.js +++ b/lib/internal/assert/assertion_error.js @@ -6,11 +6,14 @@ const { ArrayPrototypeSlice, Error, ErrorCaptureStackTrace, + MathMax, ObjectAssign, ObjectDefineProperty, ObjectGetPrototypeOf, ObjectPrototypeHasOwnProperty, + RegExpPrototypeSymbolSplit, String, + StringPrototypeIncludes, StringPrototypeRepeat, StringPrototypeSlice, StringPrototypeSplit, @@ -41,6 +44,7 @@ const kReadableOperator = { const kMaxShortStringLength = 12; const kMaxLongStringLength = 512; +const kMaxDiffDensityForWordDiff = 0.5; const kMethodsWithCustomMessageDiff = ['deepStrictEqual', 'strictEqual', 'partialDeepStrictEqual']; @@ -104,6 +108,68 @@ function checkOperator(actual, expected, operator) { return operator; } +function splitByWordBoundaries(str) { + return RegExpPrototypeSymbolSplit(/(\s+|_+|-+)/, str); +} + +function calculateDiffDensity(actual, expected) { + const diff = myersDiff(StringPrototypeSplit(actual, ''), StringPrototypeSplit(expected, '')); + let changedChars = 0; + + for (let i = 0; i < diff.length; i++) { + const operation = diff[i][0]; + if (operation !== 0) { + changedChars++; + } + } + + const totalChars = MathMax(actual.length, expected.length); + return totalChars === 0 ? 0 : changedChars / totalChars; +} + +function checksUseOfWordDiff(actual, expected) { + const hasWordBoundaries = StringPrototypeIncludes(actual, ' ') || + StringPrototypeIncludes(actual, '_') || + StringPrototypeIncludes(actual, '-') || + StringPrototypeIncludes(expected, ' ') || + StringPrototypeIncludes(expected, '_') || + StringPrototypeIncludes(expected, '-'); + + if (!hasWordBoundaries) { + return false; + } + + const diffDensity = calculateDiffDensity(actual, expected); + + return diffDensity <= kMaxDiffDensityForWordDiff; +} + +function getWordDiff(actual, expected) { + const header = `${colors.green}actual${colors.white} ${colors.red}expected${colors.white}`; + const skipped = false; + + const actualWords = splitByWordBoundaries(actual); + const expectedWords = splitByWordBoundaries(expected); + + const diff = myersDiff(actualWords, expectedWords); + let message = '\n'; + + for (let diffIdx = diff.length - 1; diffIdx >= 0; diffIdx--) { + const { 0: operation, 1: value } = diff[diffIdx]; + let color = colors.white; + + if (operation === 1) { + color = colors.green; + } else if (operation === -1) { + color = colors.red; + } + + message += `${color}${value}${colors.white}`; + } + + return { message, header, skipped }; +} + function getColoredMyersDiff(actual, expected) { const header = `${colors.green}actual${colors.white} ${colors.red}expected${colors.white}`; const skipped = false; @@ -164,6 +230,10 @@ function getSimpleDiff(originalActual, actual, originalExpected, expected) { const isStringComparison = typeof originalActual === 'string' && typeof originalExpected === 'string'; // colored myers diff if (isStringComparison && colors.hasColors) { + // We don't want include quotes for word diff checks + if (checksUseOfWordDiff(originalActual, originalExpected)) { + return getWordDiff(originalActual, originalExpected); + } return getColoredMyersDiff(actual, expected); } diff --git a/test/parallel/test-assert.js b/test/parallel/test-assert.js index e7e80b830b030d..420f7445cf8028 100644 --- a/test/parallel/test-assert.js +++ b/test/parallel/test-assert.js @@ -1777,5 +1777,155 @@ test('Functions as error message', () => { ); }); +test('Word-level diff for strings with word boundaries', () => { + process.env.FORCE_COLOR = '1'; + delete process.env.NODE_DISABLE_COLORS; + delete process.env.NO_COLOR; + + assert.throws( + () => assert.strictEqual('the quick brown fox', 'the quick black fox'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39mthe\u001b[39m\u001b[39m \u001b[39m\u001b[39mquick\u001b[39m' + + '\u001b[39m \u001b[39m\u001b[32mbrown\u001b[39m\u001b[31mblack\u001b[39m' + + '\u001b[39m \u001b[39m\u001b[39mfox\u001b[39m\n' + } + ); + + assert.throws( + () => assert.strictEqual('hello_world_test', 'hello_there_test'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39mhello\u001b[39m\u001b[39m_\u001b[39m' + + '\u001b[32mworld\u001b[39m\u001b[31mthere\u001b[39m' + + '\u001b[39m_\u001b[39m\u001b[39mtest\u001b[39m\n' + } + ); + + assert.throws( + () => assert.strictEqual('hello-world-test', 'hello-there-test'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39mhello\u001b[39m\u001b[39m-\u001b[39m' + + '\u001b[32mworld\u001b[39m\u001b[31mthere\u001b[39m' + + '\u001b[39m-\u001b[39m\u001b[39mtest\u001b[39m\n' + } + ); + + assert.throws( + () => assert.strictEqual('abcdefghij', 'abcdxfghij'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39m\'\u001b[39m\u001b[39ma\u001b[39m\u001b[39mb\u001b[39m' + + '\u001b[39mc\u001b[39m\u001b[39md\u001b[39m\u001b[32me\u001b[39m' + + '\u001b[31mx\u001b[39m\u001b[39mf\u001b[39m\u001b[39mg\u001b[39m' + + '\u001b[39mh\u001b[39m\u001b[39mi\u001b[39m\u001b[39mj\u001b[39m\u001b[39m\'\u001b[39m\n' + } + ); + + assert.throws( + () => assert.strictEqual('hello_world-test case', 'hello_there-test case'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39mhello\u001b[39m\u001b[39m_\u001b[39m' + + '\u001b[32mworld\u001b[39m\u001b[31mthere\u001b[39m' + + '\u001b[39m-\u001b[39m\u001b[39mtest\u001b[39m' + + '\u001b[39m \u001b[39m\u001b[39mcase\u001b[39m\n' + } + ); + + assert.throws( + () => assert.strictEqual('version 1 2 3', 'version 1 2 4'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39mversion\u001b[39m\u001b[39m \u001b[39m' + + '\u001b[39m1\u001b[39m\u001b[39m \u001b[39m' + + '\u001b[39m2\u001b[39m\u001b[39m \u001b[39m' + + '\u001b[32m3\u001b[39m\u001b[31m4\u001b[39m\n' + } + ); + + assert.throws( + () => assert.strictEqual('hello world', 'hello world'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39mhello\u001b[39m' + + '\u001b[32m \u001b[39m\u001b[31m \u001b[39m' + + '\u001b[39mworld\u001b[39m\n' + } + ); + + assert.throws( + () => assert.strictEqual('test@example.com foo', 'test@example.com bar'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: 'Expected values to be strictly equal:\n' + + '\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' + + '\n' + + '\u001b[39mtest@example.com\u001b[39m\u001b[39m \u001b[39m' + + '\u001b[32mfoo\u001b[39m\u001b[31mbar\u001b[39m\n' + } + ); + + // Fall back to character diff because of word density + assert.throws( + () => assert.strictEqual('hello', 'hallo'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: "Expected values to be strictly equal:\n\n'hello' !== 'hallo'\n" + } + ); + + assert.throws( + () => assert.strictEqual('', 'hello world'), + { + code: 'ERR_ASSERTION', + name: 'AssertionError', + generatedMessage: true, + message: "Expected values to be strictly equal:\n\n'' !== 'hello world'\n" + } + ); + +}); + /* eslint-enable no-restricted-syntax */ /* eslint-enable no-restricted-properties */