diff --git a/lib/internal/assert/assertion_error.js b/lib/internal/assert/assertion_error.js index 5dbf1e7a341380..788886c2c19524 100644 --- a/lib/internal/assert/assertion_error.js +++ b/lib/internal/assert/assertion_error.js @@ -182,6 +182,41 @@ function isSimpleDiff(actual, inspectedActual, expected, inspectedExpected) { return typeof actual !== 'object' || actual === null || typeof expected !== 'object' || expected === null; } +// Produce a short, human-readable label for the prototype of `val` to be used +// when surfacing prototype-mismatch information in the assertion diff. The +// returned label is intentionally compact so that it can be appended to the +// existing diff without overwhelming it. +function describePrototype(val) { + const proto = ObjectGetPrototypeOf(val); + if (proto === null) { + return '[Object: null prototype]'; + } + const ctor = proto.constructor; + if (typeof ctor === 'function' && typeof ctor.name === 'string' && ctor.name !== '') { + return ctor.name; + } + return ''; +} + +// Detect the case where two values inspect identically but are not +// deep-strict-equal because their prototypes differ. In that situation the +// usual diff is unhelpful (it shows the same text on both sides), so we want +// to make the prototype mismatch explicit. Only top-level objects/functions +// are considered; primitives reach this code path only when they trivially +// fail simpler comparisons. +function hasTopLevelPrototypeMismatch(actual, expected) { + if (actual === null || expected === null) { + return false; + } + const typeofActual = typeof actual; + const typeofExpected = typeof expected; + if ((typeofActual !== 'object' && typeofActual !== 'function') || + (typeofExpected !== 'object' && typeofExpected !== 'function')) { + return false; + } + return ObjectGetPrototypeOf(actual) !== ObjectGetPrototypeOf(expected); +} + function createErrDiff(actual, expected, operator, customMessage, diffType = 'simple') { operator = checkOperator(actual, expected, operator); @@ -204,15 +239,39 @@ function createErrDiff(actual, expected, operator, customMessage, diffType = 'si skipped = true; } } else if (inspectedActual === inspectedExpected) { - // Handles the case where the objects are structurally the same but different references - operator = 'notIdentical'; - if (inspectedSplitActual.length > 50 && diffType !== 'full') { - message = `${ArrayPrototypeJoin(ArrayPrototypeSlice(inspectedSplitActual, 0, 50), '\n')}\n...}`; - skipped = true; + // The two values inspect identically. For deep-equality operators this + // typically means the prototypes differ in a way `inspect` cannot show + // (e.g. anonymous classes, or two distinct prototype objects with the + // same shape). Surface the prototype mismatch explicitly when present; + // otherwise fall back to the existing "not reference-equal" message, + // which covers e.g. `strictEqual([], [])`. + const prototypeMismatch = (operator === 'deepStrictEqual' || + operator === 'partialDeepStrictEqual') && + hasTopLevelPrototypeMismatch(actual, expected); + if (prototypeMismatch) { + const actualProto = describePrototype(actual); + const expectedProto = describePrototype(expected); + const protoLine = `Object prototypes differ: ${actualProto} !== ${expectedProto}`; + let body; + if (inspectedSplitActual.length > 50 && diffType !== 'full') { + body = `${ArrayPrototypeJoin(ArrayPrototypeSlice(inspectedSplitActual, 0, 50), '\n')}\n...}`; + skipped = true; + } else { + body = ArrayPrototypeJoin(inspectedSplitActual, '\n'); + } + message = `${protoLine}\n\n${body}`; + header = ''; } else { - message = ArrayPrototypeJoin(inspectedSplitActual, '\n'); + // Handles the case where the objects are structurally the same but different references + operator = 'notIdentical'; + if (inspectedSplitActual.length > 50 && diffType !== 'full') { + message = `${ArrayPrototypeJoin(ArrayPrototypeSlice(inspectedSplitActual, 0, 50), '\n')}\n...}`; + skipped = true; + } else { + message = ArrayPrototypeJoin(inspectedSplitActual, '\n'); + } + header = ''; } - header = ''; } else { const checkCommaDisparity = actual != null && typeof actual === 'object'; const diff = myersDiff(inspectedSplitActual, inspectedSplitExpected, checkCommaDisparity); diff --git a/test/parallel/test-assert-deep-prototype-diff.js b/test/parallel/test-assert-deep-prototype-diff.js new file mode 100644 index 00000000000000..c3e64ce49dfdea --- /dev/null +++ b/test/parallel/test-assert-deep-prototype-diff.js @@ -0,0 +1,115 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); + +// Disable colored output to prevent color codes from breaking assertion +// message comparisons. This should only be an issue when process.stdout +// is a TTY. +if (process.stdout.isTTY) { + process.env.NODE_DISABLE_COLORS = '1'; +} + +// Regression tests for https://github.com/nodejs/node/issues/50397. +// +// When `assert.deepStrictEqual` fails solely because the two values have +// different prototypes, the diff used to render as `{} !== {}` (or similar), +// giving the user no clue about why the assertion failed. The assertion +// error formatter now surfaces the prototype mismatch explicitly. + +test('deepStrictEqual surfaces anonymous-class prototype mismatch', () => { + const A = (() => class {})(); + const B = (() => class {})(); + assert.throws( + () => assert.deepStrictEqual(new A(), new B()), + (err) => { + assert.strictEqual(err.code, 'ERR_ASSERTION'); + assert.match(err.message, /Object prototypes differ:/); + // The previous "Values have same structure but are not reference-equal" + // message must no longer be used for prototype-only mismatches because + // it is misleading: the values do not even have the same prototype. + assert.doesNotMatch(err.message, /same structure but are not reference-equal/); + return true; + } + ); +}); + +test('deepStrictEqual surfaces named-class prototype mismatch', () => { + class Foo {} + class Bar {} + assert.throws( + () => assert.deepStrictEqual(new Foo(), new Bar()), + (err) => { + assert.strictEqual(err.code, 'ERR_ASSERTION'); + // Both class names should appear somewhere in the rendered message: + // either in the existing inspect-based diff (`+ Foo {}` / `- Bar {}`) + // or in the new "Object prototypes differ:" line. The important + // guarantee is that the user can identify both prototypes from the + // error message alone. + assert.match(err.message, /Foo/); + assert.match(err.message, /Bar/); + return true; + } + ); +}); + +test('deepStrictEqual surfaces null-prototype mismatch', () => { + const a = { __proto__: null }; + const b = {}; + assert.throws( + () => assert.deepStrictEqual(a, b), + (err) => { + assert.strictEqual(err.code, 'ERR_ASSERTION'); + assert.match(err.message, /null prototype/); + return true; + } + ); +}); + +test('deepStrictEqual prototype-mismatch message is helpful for empty objects', () => { + // This is the most pathological case: both sides inspect identically as + // `{}`, so without the prototype-mismatch information the diff body alone + // is useless. The fix must produce an explanatory line. + const A = (() => class {})(); + const B = (() => class {})(); + let captured; + try { + assert.deepStrictEqual(new A(), new B()); + } catch (err) { + captured = err; + } + assert.ok(captured, 'deepStrictEqual should have thrown'); + assert.match(captured.message, /Object prototypes differ:/); +}); + +test('strictEqual on structurally-equal arrays still uses notIdentical message', () => { + // Sanity check that the new code path does not regress the existing + // behavior of `strictEqual([], [])`. That comparison continues to use + // the "Values have same structure but are not reference-equal" message + // because the prototypes do match (both are Array.prototype). + assert.throws( + () => assert.strictEqual([], []), + { + code: 'ERR_ASSERTION', + message: 'Values have same structure but are not reference-equal:\n\n[]\n', + } + ); +}); + +test('deepStrictEqual on structurally-equal values with same prototype still fails clearly', () => { + // When the prototypes match but the values are not reference-equal, the + // existing notIdentical fallback should still apply (deepStrictEqual on + // two equal-shape objects with the same prototype should normally pass; + // here we use objects whose enumerable properties differ to exercise the + // ordinary diff path and confirm it is unaffected). + assert.throws( + () => assert.deepStrictEqual({ a: 1 }, { a: 2 }), + (err) => { + assert.strictEqual(err.code, 'ERR_ASSERTION'); + // The ordinary diff path must NOT mention prototype differences + // because the prototypes are identical. + assert.doesNotMatch(err.message, /Object prototypes differ:/); + return true; + } + ); +});