Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions lib/internal/assert/assertion_error.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ const {
ArrayPrototypeSlice,
Error,
ErrorCaptureStackTrace,
MathMax,
ObjectAssign,
ObjectDefineProperty,
ObjectGetPrototypeOf,
ObjectPrototypeHasOwnProperty,
RegExpPrototypeSymbolSplit,
String,
StringPrototypeIncludes,
StringPrototypeRepeat,
StringPrototypeSlice,
StringPrototypeSplit,
Expand Down Expand Up @@ -41,6 +44,7 @@ const kReadableOperator = {

const kMaxShortStringLength = 12;
const kMaxLongStringLength = 512;
const kMaxDiffDensityForWordDiff = 0.5;

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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
150 changes: 150 additions & 0 deletions test/parallel/test-assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */