Skip to content

Commit deac914

Browse files
lib: add word-level diff for string comparisons
1 parent fa33ba3 commit deac914

File tree

2 files changed

+220
-0
lines changed

2 files changed

+220
-0
lines changed

lib/internal/assert/assertion_error.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ const {
66
ArrayPrototypeSlice,
77
Error,
88
ErrorCaptureStackTrace,
9+
MathMax,
910
ObjectAssign,
1011
ObjectDefineProperty,
1112
ObjectGetPrototypeOf,
1213
ObjectPrototypeHasOwnProperty,
14+
RegExpPrototypeSymbolSplit,
1315
String,
16+
StringPrototypeIncludes,
1417
StringPrototypeRepeat,
1518
StringPrototypeSlice,
1619
StringPrototypeSplit,
@@ -41,6 +44,7 @@ const kReadableOperator = {
4144

4245
const kMaxShortStringLength = 12;
4346
const kMaxLongStringLength = 512;
47+
const kMaxDiffDensityForWordDiff = 0.5;
4448

4549
const kMethodsWithCustomMessageDiff = ['deepStrictEqual', 'strictEqual', 'partialDeepStrictEqual'];
4650

@@ -104,6 +108,68 @@ function checkOperator(actual, expected, operator) {
104108
return operator;
105109
}
106110

111+
function splitByWordBoundaries(str) {
112+
return RegExpPrototypeSymbolSplit(/(\s+|_+|-+)/, str);
113+
}
114+
115+
function calculateDiffDensity(actual, expected) {
116+
const diff = myersDiff(StringPrototypeSplit(actual, ''), StringPrototypeSplit(expected, ''));
117+
let changedChars = 0;
118+
119+
for (let i = 0; i < diff.length; i++) {
120+
const operation = diff[i][0];
121+
if (operation !== 0) {
122+
changedChars++;
123+
}
124+
}
125+
126+
const totalChars = MathMax(actual.length, expected.length);
127+
return totalChars === 0 ? 0 : changedChars / totalChars;
128+
}
129+
130+
function checksUseOfWordDiff(actual, expected) {
131+
const hasWordBoundaries = StringPrototypeIncludes(actual, ' ') ||
132+
StringPrototypeIncludes(actual, '_') ||
133+
StringPrototypeIncludes(actual, '-') ||
134+
StringPrototypeIncludes(expected, ' ') ||
135+
StringPrototypeIncludes(expected, '_') ||
136+
StringPrototypeIncludes(expected, '-');
137+
138+
if (!hasWordBoundaries) {
139+
return false;
140+
}
141+
142+
const diffDensity = calculateDiffDensity(actual, expected);
143+
144+
return diffDensity <= kMaxDiffDensityForWordDiff;
145+
}
146+
147+
function getWordDiff(actual, expected) {
148+
const header = `${colors.green}actual${colors.white} ${colors.red}expected${colors.white}`;
149+
const skipped = false;
150+
151+
const actualWords = splitByWordBoundaries(actual);
152+
const expectedWords = splitByWordBoundaries(expected);
153+
154+
const diff = myersDiff(actualWords, expectedWords);
155+
let message = '\n';
156+
157+
for (let diffIdx = diff.length - 1; diffIdx >= 0; diffIdx--) {
158+
const { 0: operation, 1: value } = diff[diffIdx];
159+
let color = colors.white;
160+
161+
if (operation === 1) {
162+
color = colors.green;
163+
} else if (operation === -1) {
164+
color = colors.red;
165+
}
166+
167+
message += `${color}${value}${colors.white}`;
168+
}
169+
170+
return { message, header, skipped };
171+
}
172+
107173
function getColoredMyersDiff(actual, expected) {
108174
const header = `${colors.green}actual${colors.white} ${colors.red}expected${colors.white}`;
109175
const skipped = false;
@@ -164,6 +230,10 @@ function getSimpleDiff(originalActual, actual, originalExpected, expected) {
164230
const isStringComparison = typeof originalActual === 'string' && typeof originalExpected === 'string';
165231
// colored myers diff
166232
if (isStringComparison && colors.hasColors) {
233+
// We don't want include quotes for word diff checks
234+
if (checksUseOfWordDiff(originalActual, originalExpected)) {
235+
return getWordDiff(originalActual, originalExpected);
236+
}
167237
return getColoredMyersDiff(actual, expected);
168238
}
169239

test/parallel/test-assert.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,5 +1777,155 @@ test('Functions as error message', () => {
17771777
);
17781778
});
17791779

1780+
test('Word-level diff for strings with word boundaries', () => {
1781+
process.env.FORCE_COLOR = '1';
1782+
delete process.env.NODE_DISABLE_COLORS;
1783+
delete process.env.NO_COLOR;
1784+
1785+
assert.throws(
1786+
() => assert.strictEqual('the quick brown fox', 'the quick black fox'),
1787+
{
1788+
code: 'ERR_ASSERTION',
1789+
name: 'AssertionError',
1790+
generatedMessage: true,
1791+
message: 'Expected values to be strictly equal:\n' +
1792+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1793+
'\n' +
1794+
'\u001b[39mthe\u001b[39m\u001b[39m \u001b[39m\u001b[39mquick\u001b[39m' +
1795+
'\u001b[39m \u001b[39m\u001b[32mbrown\u001b[39m\u001b[31mblack\u001b[39m' +
1796+
'\u001b[39m \u001b[39m\u001b[39mfox\u001b[39m\n'
1797+
}
1798+
);
1799+
1800+
assert.throws(
1801+
() => assert.strictEqual('hello_world_test', 'hello_there_test'),
1802+
{
1803+
code: 'ERR_ASSERTION',
1804+
name: 'AssertionError',
1805+
generatedMessage: true,
1806+
message: 'Expected values to be strictly equal:\n' +
1807+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1808+
'\n' +
1809+
'\u001b[39mhello\u001b[39m\u001b[39m_\u001b[39m' +
1810+
'\u001b[32mworld\u001b[39m\u001b[31mthere\u001b[39m' +
1811+
'\u001b[39m_\u001b[39m\u001b[39mtest\u001b[39m\n'
1812+
}
1813+
);
1814+
1815+
assert.throws(
1816+
() => assert.strictEqual('hello-world-test', 'hello-there-test'),
1817+
{
1818+
code: 'ERR_ASSERTION',
1819+
name: 'AssertionError',
1820+
generatedMessage: true,
1821+
message: 'Expected values to be strictly equal:\n' +
1822+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1823+
'\n' +
1824+
'\u001b[39mhello\u001b[39m\u001b[39m-\u001b[39m' +
1825+
'\u001b[32mworld\u001b[39m\u001b[31mthere\u001b[39m' +
1826+
'\u001b[39m-\u001b[39m\u001b[39mtest\u001b[39m\n'
1827+
}
1828+
);
1829+
1830+
assert.throws(
1831+
() => assert.strictEqual('abcdefghij', 'abcdxfghij'),
1832+
{
1833+
code: 'ERR_ASSERTION',
1834+
name: 'AssertionError',
1835+
generatedMessage: true,
1836+
message: 'Expected values to be strictly equal:\n' +
1837+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1838+
'\n' +
1839+
'\u001b[39m\'\u001b[39m\u001b[39ma\u001b[39m\u001b[39mb\u001b[39m' +
1840+
'\u001b[39mc\u001b[39m\u001b[39md\u001b[39m\u001b[32me\u001b[39m' +
1841+
'\u001b[31mx\u001b[39m\u001b[39mf\u001b[39m\u001b[39mg\u001b[39m' +
1842+
'\u001b[39mh\u001b[39m\u001b[39mi\u001b[39m\u001b[39mj\u001b[39m\u001b[39m\'\u001b[39m\n'
1843+
}
1844+
);
1845+
1846+
assert.throws(
1847+
() => assert.strictEqual('hello_world-test case', 'hello_there-test case'),
1848+
{
1849+
code: 'ERR_ASSERTION',
1850+
name: 'AssertionError',
1851+
generatedMessage: true,
1852+
message: 'Expected values to be strictly equal:\n' +
1853+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1854+
'\n' +
1855+
'\u001b[39mhello\u001b[39m\u001b[39m_\u001b[39m' +
1856+
'\u001b[32mworld\u001b[39m\u001b[31mthere\u001b[39m' +
1857+
'\u001b[39m-\u001b[39m\u001b[39mtest\u001b[39m' +
1858+
'\u001b[39m \u001b[39m\u001b[39mcase\u001b[39m\n'
1859+
}
1860+
);
1861+
1862+
assert.throws(
1863+
() => assert.strictEqual('version 1 2 3', 'version 1 2 4'),
1864+
{
1865+
code: 'ERR_ASSERTION',
1866+
name: 'AssertionError',
1867+
generatedMessage: true,
1868+
message: 'Expected values to be strictly equal:\n' +
1869+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1870+
'\n' +
1871+
'\u001b[39mversion\u001b[39m\u001b[39m \u001b[39m' +
1872+
'\u001b[39m1\u001b[39m\u001b[39m \u001b[39m' +
1873+
'\u001b[39m2\u001b[39m\u001b[39m \u001b[39m' +
1874+
'\u001b[32m3\u001b[39m\u001b[31m4\u001b[39m\n'
1875+
}
1876+
);
1877+
1878+
assert.throws(
1879+
() => assert.strictEqual('hello world', 'hello world'),
1880+
{
1881+
code: 'ERR_ASSERTION',
1882+
name: 'AssertionError',
1883+
generatedMessage: true,
1884+
message: 'Expected values to be strictly equal:\n' +
1885+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1886+
'\n' +
1887+
'\u001b[39mhello\u001b[39m' +
1888+
'\u001b[32m \u001b[39m\u001b[31m \u001b[39m' +
1889+
'\u001b[39mworld\u001b[39m\n'
1890+
}
1891+
);
1892+
1893+
assert.throws(
1894+
() => assert.strictEqual('[email protected] foo', '[email protected] bar'),
1895+
{
1896+
code: 'ERR_ASSERTION',
1897+
name: 'AssertionError',
1898+
generatedMessage: true,
1899+
message: 'Expected values to be strictly equal:\n' +
1900+
'\u001b[32mactual\u001b[39m \u001b[31mexpected\u001b[39m\n' +
1901+
'\n' +
1902+
'\u001b[[email protected]\u001b[39m\u001b[39m \u001b[39m' +
1903+
'\u001b[32mfoo\u001b[39m\u001b[31mbar\u001b[39m\n'
1904+
}
1905+
);
1906+
1907+
// Fall back to character diff because of word density
1908+
assert.throws(
1909+
() => assert.strictEqual('hello', 'hallo'),
1910+
{
1911+
code: 'ERR_ASSERTION',
1912+
name: 'AssertionError',
1913+
generatedMessage: true,
1914+
message: "Expected values to be strictly equal:\n\n'hello' !== 'hallo'\n"
1915+
}
1916+
);
1917+
1918+
assert.throws(
1919+
() => assert.strictEqual('', 'hello world'),
1920+
{
1921+
code: 'ERR_ASSERTION',
1922+
name: 'AssertionError',
1923+
generatedMessage: true,
1924+
message: "Expected values to be strictly equal:\n\n'' !== 'hello world'\n"
1925+
}
1926+
);
1927+
1928+
});
1929+
17801930
/* eslint-enable no-restricted-syntax */
17811931
/* eslint-enable no-restricted-properties */

0 commit comments

Comments
 (0)