Skip to content

Commit 1fb4a8b

Browse files
committed
assert: surface prototype mismatch in deepStrictEqual diff
When two values share the same enumerable shape but have different prototypes, the diff produced by `assert.deepStrictEqual` rendered the misleading `Values have same structure but are not reference-equal: {}` message, leaving the user without a hint that the prototype is the differentiator. Add an explicit `Object prototypes differ: <actualProto> !== <expectedProto>` line above the inspected body when the operator is `deepStrictEqual` or `partialDeepStrictEqual` and the top-level prototypes differ. Other operators (e.g., `strictEqual([], [])`) keep their existing `notIdentical` fallback message. Fixes: #50397 Signed-off-by: Maruthan G <[email protected]>
1 parent 8f348bc commit 1fb4a8b

2 files changed

Lines changed: 181 additions & 7 deletions

File tree

lib/internal/assert/assertion_error.js

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,41 @@ function isSimpleDiff(actual, inspectedActual, expected, inspectedExpected) {
182182
return typeof actual !== 'object' || actual === null || typeof expected !== 'object' || expected === null;
183183
}
184184

185+
// Produce a short, human-readable label for the prototype of `val` to be used
186+
// when surfacing prototype-mismatch information in the assertion diff. The
187+
// returned label is intentionally compact so that it can be appended to the
188+
// existing diff without overwhelming it.
189+
function describePrototype(val) {
190+
const proto = ObjectGetPrototypeOf(val);
191+
if (proto === null) {
192+
return '[Object: null prototype]';
193+
}
194+
const ctor = proto.constructor;
195+
if (typeof ctor === 'function' && typeof ctor.name === 'string' && ctor.name !== '') {
196+
return ctor.name;
197+
}
198+
return '<anonymous>';
199+
}
200+
201+
// Detect the case where two values inspect identically but are not
202+
// deep-strict-equal because their prototypes differ. In that situation the
203+
// usual diff is unhelpful (it shows the same text on both sides), so we want
204+
// to make the prototype mismatch explicit. Only top-level objects/functions
205+
// are considered; primitives reach this code path only when they trivially
206+
// fail simpler comparisons.
207+
function hasTopLevelPrototypeMismatch(actual, expected) {
208+
if (actual === null || expected === null) {
209+
return false;
210+
}
211+
const typeofActual = typeof actual;
212+
const typeofExpected = typeof expected;
213+
if ((typeofActual !== 'object' && typeofActual !== 'function') ||
214+
(typeofExpected !== 'object' && typeofExpected !== 'function')) {
215+
return false;
216+
}
217+
return ObjectGetPrototypeOf(actual) !== ObjectGetPrototypeOf(expected);
218+
}
219+
185220
function createErrDiff(actual, expected, operator, customMessage, diffType = 'simple') {
186221
operator = checkOperator(actual, expected, operator);
187222

@@ -204,15 +239,39 @@ function createErrDiff(actual, expected, operator, customMessage, diffType = 'si
204239
skipped = true;
205240
}
206241
} else if (inspectedActual === inspectedExpected) {
207-
// Handles the case where the objects are structurally the same but different references
208-
operator = 'notIdentical';
209-
if (inspectedSplitActual.length > 50 && diffType !== 'full') {
210-
message = `${ArrayPrototypeJoin(ArrayPrototypeSlice(inspectedSplitActual, 0, 50), '\n')}\n...}`;
211-
skipped = true;
242+
// The two values inspect identically. For deep-equality operators this
243+
// typically means the prototypes differ in a way `inspect` cannot show
244+
// (e.g. anonymous classes, or two distinct prototype objects with the
245+
// same shape). Surface the prototype mismatch explicitly when present;
246+
// otherwise fall back to the existing "not reference-equal" message,
247+
// which covers e.g. `strictEqual([], [])`.
248+
const prototypeMismatch = (operator === 'deepStrictEqual' ||
249+
operator === 'partialDeepStrictEqual') &&
250+
hasTopLevelPrototypeMismatch(actual, expected);
251+
if (prototypeMismatch) {
252+
const actualProto = describePrototype(actual);
253+
const expectedProto = describePrototype(expected);
254+
const protoLine = `Object prototypes differ: ${actualProto} !== ${expectedProto}`;
255+
let body;
256+
if (inspectedSplitActual.length > 50 && diffType !== 'full') {
257+
body = `${ArrayPrototypeJoin(ArrayPrototypeSlice(inspectedSplitActual, 0, 50), '\n')}\n...}`;
258+
skipped = true;
259+
} else {
260+
body = ArrayPrototypeJoin(inspectedSplitActual, '\n');
261+
}
262+
message = `${protoLine}\n\n${body}`;
263+
header = '';
212264
} else {
213-
message = ArrayPrototypeJoin(inspectedSplitActual, '\n');
265+
// Handles the case where the objects are structurally the same but different references
266+
operator = 'notIdentical';
267+
if (inspectedSplitActual.length > 50 && diffType !== 'full') {
268+
message = `${ArrayPrototypeJoin(ArrayPrototypeSlice(inspectedSplitActual, 0, 50), '\n')}\n...}`;
269+
skipped = true;
270+
} else {
271+
message = ArrayPrototypeJoin(inspectedSplitActual, '\n');
272+
}
273+
header = '';
214274
}
215-
header = '';
216275
} else {
217276
const checkCommaDisparity = actual != null && typeof actual === 'object';
218277
const diff = myersDiff(inspectedSplitActual, inspectedSplitExpected, checkCommaDisparity);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
'use strict';
2+
require('../common');
3+
const assert = require('assert');
4+
const { test } = require('node:test');
5+
6+
// Disable colored output to prevent color codes from breaking assertion
7+
// message comparisons. This should only be an issue when process.stdout
8+
// is a TTY.
9+
if (process.stdout.isTTY) {
10+
process.env.NODE_DISABLE_COLORS = '1';
11+
}
12+
13+
// Regression tests for https://github.com/nodejs/node/issues/50397.
14+
//
15+
// When `assert.deepStrictEqual` fails solely because the two values have
16+
// different prototypes, the diff used to render as `{} !== {}` (or similar),
17+
// giving the user no clue about why the assertion failed. The assertion
18+
// error formatter now surfaces the prototype mismatch explicitly.
19+
20+
test('deepStrictEqual surfaces anonymous-class prototype mismatch', () => {
21+
const A = (() => class {})();
22+
const B = (() => class {})();
23+
assert.throws(
24+
() => assert.deepStrictEqual(new A(), new B()),
25+
(err) => {
26+
assert.strictEqual(err.code, 'ERR_ASSERTION');
27+
assert.match(err.message, /Object prototypes differ:/);
28+
// The previous "Values have same structure but are not reference-equal"
29+
// message must no longer be used for prototype-only mismatches because
30+
// it is misleading: the values do not even have the same prototype.
31+
assert.doesNotMatch(err.message, /same structure but are not reference-equal/);
32+
return true;
33+
}
34+
);
35+
});
36+
37+
test('deepStrictEqual surfaces named-class prototype mismatch', () => {
38+
class Foo {}
39+
class Bar {}
40+
assert.throws(
41+
() => assert.deepStrictEqual(new Foo(), new Bar()),
42+
(err) => {
43+
assert.strictEqual(err.code, 'ERR_ASSERTION');
44+
// Both class names should appear somewhere in the rendered message:
45+
// either in the existing inspect-based diff (`+ Foo {}` / `- Bar {}`)
46+
// or in the new "Object prototypes differ:" line. The important
47+
// guarantee is that the user can identify both prototypes from the
48+
// error message alone.
49+
assert.match(err.message, /Foo/);
50+
assert.match(err.message, /Bar/);
51+
return true;
52+
}
53+
);
54+
});
55+
56+
test('deepStrictEqual surfaces null-prototype mismatch', () => {
57+
const a = { __proto__: null };
58+
const b = {};
59+
assert.throws(
60+
() => assert.deepStrictEqual(a, b),
61+
(err) => {
62+
assert.strictEqual(err.code, 'ERR_ASSERTION');
63+
assert.match(err.message, /null prototype/);
64+
return true;
65+
}
66+
);
67+
});
68+
69+
test('deepStrictEqual prototype-mismatch message is helpful for empty objects', () => {
70+
// This is the most pathological case: both sides inspect identically as
71+
// `{}`, so without the prototype-mismatch information the diff body alone
72+
// is useless. The fix must produce an explanatory line.
73+
const A = (() => class {})();
74+
const B = (() => class {})();
75+
let captured;
76+
try {
77+
assert.deepStrictEqual(new A(), new B());
78+
} catch (err) {
79+
captured = err;
80+
}
81+
assert.ok(captured, 'deepStrictEqual should have thrown');
82+
assert.match(captured.message, /Object prototypes differ:/);
83+
});
84+
85+
test('strictEqual on structurally-equal arrays still uses notIdentical message', () => {
86+
// Sanity check that the new code path does not regress the existing
87+
// behavior of `strictEqual([], [])`. That comparison continues to use
88+
// the "Values have same structure but are not reference-equal" message
89+
// because the prototypes do match (both are Array.prototype).
90+
assert.throws(
91+
() => assert.strictEqual([], []),
92+
{
93+
code: 'ERR_ASSERTION',
94+
message: 'Values have same structure but are not reference-equal:\n\n[]\n',
95+
}
96+
);
97+
});
98+
99+
test('deepStrictEqual on structurally-equal values with same prototype still fails clearly', () => {
100+
// When the prototypes match but the values are not reference-equal, the
101+
// existing notIdentical fallback should still apply (deepStrictEqual on
102+
// two equal-shape objects with the same prototype should normally pass;
103+
// here we use objects whose enumerable properties differ to exercise the
104+
// ordinary diff path and confirm it is unaffected).
105+
assert.throws(
106+
() => assert.deepStrictEqual({ a: 1 }, { a: 2 }),
107+
(err) => {
108+
assert.strictEqual(err.code, 'ERR_ASSERTION');
109+
// The ordinary diff path must NOT mention prototype differences
110+
// because the prototypes are identical.
111+
assert.doesNotMatch(err.message, /Object prototypes differ:/);
112+
return true;
113+
}
114+
);
115+
});

0 commit comments

Comments
 (0)