diff --git a/doc/api/assert.md b/doc/api/assert.md index 9379b8a816892b..c2556e762d23c0 100644 --- a/doc/api/assert.md +++ b/doc/api/assert.md @@ -229,11 +229,20 @@ The `Assert` class allows creating independent assertion instances with custom o ### `new assert.Assert([options])` + + * `options` {Object} * `diff` {string} If set to `'full'`, shows the full diff in assertion errors. Defaults to `'simple'`. Accepted values: `'simple'`, `'full'`. * `strict` {boolean} If set to `true`, non-strict methods behave like their corresponding strict methods. Defaults to `true`. + * `skipPrototype` {boolean} If set to `true`, skips prototype and constructor + comparison in deep equality checks. Defaults to `false`. Creates a new assertion instance. The `diff` option controls the verbosity of diffs in assertion error messages. @@ -245,7 +254,8 @@ assertInstance.deepStrictEqual({ a: 1 }, { a: 2 }); ``` **Important**: When destructuring assertion methods from an `Assert` instance, -the methods lose their connection to the instance's configuration options (such as `diff` and `strict` settings). +the methods lose their connection to the instance's configuration options (such +as `diff`, `strict`, and `skipPrototype` settings). The destructured methods will fall back to default behavior instead. ```js @@ -259,6 +269,33 @@ const { strictEqual } = myAssert; strictEqual({ a: 1 }, { b: { c: 1 } }); ``` +The `skipPrototype` option affects all deep equality methods: + +```js +class Foo { + constructor(a) { + this.a = a; + } +} + +class Bar { + constructor(a) { + this.a = a; + } +} + +const foo = new Foo(1); +const bar = new Bar(1); + +// Default behavior - fails due to different constructors +const assert1 = new Assert(); +assert1.deepStrictEqual(foo, bar); // AssertionError + +// Skip prototype comparison - passes if properties are equal +const assert2 = new Assert({ skipPrototype: true }); +assert2.deepStrictEqual(foo, bar); // OK +``` + When destructured, methods lose access to the instance's `this` context and revert to default assertion behavior (diff: 'simple', non-strict mode). To maintain custom options when using destructured methods, avoid diff --git a/doc/api/util.md b/doc/api/util.md index f414e5985fda1a..40e850e2eb212c 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -1553,19 +1553,56 @@ inspect.defaultOptions.maxArrayLength = null; console.log(arr); // logs the full array ``` -## `util.isDeepStrictEqual(val1, val2)` +## `util.isDeepStrictEqual(val1, val2[, options])` * `val1` {any} * `val2` {any} +* `skipPrototype` {boolean} If `true`, prototype and constructor + comparison is skipped during deep strict equality check. **Default:** `false`. * Returns: {boolean} Returns `true` if there is deep strict equality between `val1` and `val2`. Otherwise, returns `false`. +By default, deep strict equality includes comparison of object prototypes and +constructors. When `skipPrototype` is `true`, objects with +different prototypes or constructors can still be considered equal if their +enumerable properties are deeply strictly equal. + +```js +const util = require('node:util'); + +class Foo { + constructor(a) { + this.a = a; + } +} + +class Bar { + constructor(a) { + this.a = a; + } +} + +const foo = new Foo(1); +const bar = new Bar(1); + +// Different constructors, same properties +console.log(util.isDeepStrictEqual(foo, bar)); +// false + +console.log(util.isDeepStrictEqual(foo, bar, true)); +// true +``` + See [`assert.deepStrictEqual()`][] for more information about deep strict equality. diff --git a/lib/assert.js b/lib/assert.js index 5ca0854f482bad..6dc0694a173a87 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -93,6 +93,8 @@ const NO_EXCEPTION_SENTINEL = {}; * @property {'full'|'simple'} [diff='simple'] - If set to 'full', shows the full diff in assertion errors. * @property {boolean} [strict=true] - If set to true, non-strict methods behave like their corresponding * strict methods. + * @property {boolean} [skipPrototype=false] - If set to true, skips comparing prototypes + * in deep equality checks. */ /** @@ -105,7 +107,7 @@ function Assert(options) { throw new ERR_CONSTRUCT_CALL_REQUIRED('Assert'); } - options = ObjectAssign({ __proto__: null, strict: true }, options); + options = ObjectAssign({ __proto__: null, strict: true, skipPrototype: false }, options); const allowedDiffs = ['simple', 'full']; if (options.diff !== undefined) { @@ -311,7 +313,7 @@ Assert.prototype.deepStrictEqual = function deepStrictEqual(actual, expected, me throw new ERR_MISSING_ARGS('actual', 'expected'); } if (isDeepEqual === undefined) lazyLoadComparison(); - if (!isDeepStrictEqual(actual, expected)) { + if (!isDeepStrictEqual(actual, expected, this?.[kOptions]?.skipPrototype)) { innerFail({ actual, expected, @@ -337,7 +339,7 @@ function notDeepStrictEqual(actual, expected, message) { throw new ERR_MISSING_ARGS('actual', 'expected'); } if (isDeepEqual === undefined) lazyLoadComparison(); - if (isDeepStrictEqual(actual, expected)) { + if (isDeepStrictEqual(actual, expected, this?.[kOptions]?.skipPrototype)) { innerFail({ actual, expected, diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 81eefcba15f4d4..cfbf393e0ba1dc 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -127,9 +127,10 @@ const { getOwnNonIndexProperties, } = internalBinding('util'); -const kStrict = 1; +const kStrict = 2; +const kStrictWithoutPrototypes = 3; const kLoose = 0; -const kPartial = 2; +const kPartial = 1; const kNoIterator = 0; const kIsArray = 1; @@ -458,7 +459,7 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { } } else if (keys2.length !== (keys1 = ObjectKeys(val1)).length) { return false; - } else if (mode === kStrict) { + } else if (mode === kStrict || mode === kStrictWithoutPrototypes) { const symbolKeysA = getOwnSymbols(val1); if (symbolKeysA.length !== 0) { let count = 0; @@ -1027,7 +1028,10 @@ module.exports = { isDeepEqual(val1, val2) { return detectCycles(val1, val2, kLoose); }, - isDeepStrictEqual(val1, val2) { + isDeepStrictEqual(val1, val2, skipPrototype) { + if (skipPrototype) { + return detectCycles(val1, val2, kStrictWithoutPrototypes); + } return detectCycles(val1, val2, kStrict); }, isPartialStrictEqual(val1, val2) { diff --git a/lib/util.js b/lib/util.js index 4b014a290c312f..2d128a99b5b1de 100644 --- a/lib/util.js +++ b/lib/util.js @@ -487,12 +487,11 @@ module.exports = { isArray: deprecate(ArrayIsArray, 'The `util.isArray` API is deprecated. Please use `Array.isArray()` instead.', 'DEP0044'), - isDeepStrictEqual(a, b) { + isDeepStrictEqual(a, b, skipPrototype) { if (internalDeepEqual === undefined) { - internalDeepEqual = require('internal/util/comparisons') - .isDeepStrictEqual; + internalDeepEqual = require('internal/util/comparisons').isDeepStrictEqual; } - return internalDeepEqual(a, b); + return internalDeepEqual(a, b, skipPrototype); }, promisify, stripVTControlCharacters, diff --git a/test/parallel/test-assert-class.js b/test/parallel/test-assert-class.js index 91b4ce8feac12b..41271af65ffcbe 100644 --- a/test/parallel/test-assert-class.js +++ b/test/parallel/test-assert-class.js @@ -478,3 +478,163 @@ test('Assert class non strict with simple diff', () => { ); } }); + +// Shared setup for skipPrototype tests +{ + const message = 'Expected values to be strictly deep-equal:\n' + + '+ actual - expected\n' + + '\n' + + ' [\n' + + ' 1,\n' + + ' 2,\n' + + ' 3,\n' + + ' 4,\n' + + ' 5,\n' + + '+ 6,\n' + + '- 9,\n' + + ' 7\n' + + ' ]\n'; + + function CoolClass(name) { this.name = name; } + + function AwesomeClass(name) { this.name = name; } + + class Modern { constructor(value) { this.value = value; } } + class Legacy { constructor(value) { this.value = value; } } + + const cool = new CoolClass('Assert is inspiring'); + const awesome = new AwesomeClass('Assert is inspiring'); + const modern = new Modern(42); + const legacy = new Legacy(42); + + test('Assert class strict with skipPrototype', () => { + const assertInstance = new Assert({ skipPrototype: true }); + + assert.throws( + () => assertInstance.deepEqual([1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 9, 7]), + { message } + ); + + assertInstance.deepEqual(cool, awesome); + assertInstance.deepStrictEqual(cool, awesome); + assertInstance.deepEqual(modern, legacy); + assertInstance.deepStrictEqual(modern, legacy); + + const cool2 = new CoolClass('Soooo coooool'); + assert.throws( + () => assertInstance.deepStrictEqual(cool, cool2), + { code: 'ERR_ASSERTION' } + ); + + const nested1 = { obj: new CoolClass('test'), arr: [1, 2, 3] }; + const nested2 = { obj: new AwesomeClass('test'), arr: [1, 2, 3] }; + assertInstance.deepStrictEqual(nested1, nested2); + + const arr = new Uint8Array([1, 2, 3]); + const buf = Buffer.from([1, 2, 3]); + assertInstance.deepStrictEqual(arr, buf); + }); + + test('Assert class non strict with skipPrototype', () => { + const assertInstance = new Assert({ strict: false, skipPrototype: true }); + + assert.throws( + () => assertInstance.deepStrictEqual([1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 9, 7]), + { message } + ); + + assertInstance.deepStrictEqual(cool, awesome); + assertInstance.deepStrictEqual(modern, legacy); + }); + + test('Assert class skipPrototype with complex objects', () => { + const assertInstance = new Assert({ skipPrototype: true }); + + function ComplexAwesomeClass(name, age) { + this.name = name; + this.age = age; + this.settings = { + theme: 'dark', + lang: 'en' + }; + } + + function ComplexCoolClass(name, age) { + this.name = name; + this.age = age; + this.settings = { + theme: 'dark', + lang: 'en' + }; + } + + const awesome1 = new ComplexAwesomeClass('Foo', 30); + const cool1 = new ComplexCoolClass('Foo', 30); + + assertInstance.deepStrictEqual(awesome1, cool1); + + const cool2 = new ComplexCoolClass('Foo', 30); + cool2.settings.theme = 'light'; + + assert.throws( + () => assertInstance.deepStrictEqual(awesome1, cool2), + { code: 'ERR_ASSERTION' } + ); + }); + + test('Assert class skipPrototype with arrays and special objects', () => { + const assertInstance = new Assert({ skipPrototype: true }); + + const arr1 = [1, 2, 3]; + const arr2 = new Array(1, 2, 3); + assertInstance.deepStrictEqual(arr1, arr2); + + const date1 = new Date('2023-01-01'); + const date2 = new Date('2023-01-01'); + assertInstance.deepStrictEqual(date1, date2); + + const regex1 = /test/g; + const regex2 = new RegExp('test', 'g'); + assertInstance.deepStrictEqual(regex1, regex2); + + const date3 = new Date('2023-01-02'); + assert.throws( + () => assertInstance.deepStrictEqual(date1, date3), + { code: 'ERR_ASSERTION' } + ); + }); + + test('Assert class skipPrototype with notDeepStrictEqual', () => { + const assertInstance = new Assert({ skipPrototype: true }); + + assert.throws( + () => assertInstance.notDeepStrictEqual(cool, awesome), + { code: 'ERR_ASSERTION' } + ); + + const notAwesome = new AwesomeClass('Not so awesome'); + assertInstance.notDeepStrictEqual(cool, notAwesome); + + const defaultAssertInstance = new Assert({ skipPrototype: false }); + defaultAssertInstance.notDeepStrictEqual(cool, awesome); + }); + + test('Assert class skipPrototype with mixed types', () => { + const assertInstance = new Assert({ skipPrototype: true }); + + const obj1 = { value: 42, nested: { prop: 'test' } }; + + function CustomObj(value, nested) { + this.value = value; + this.nested = nested; + } + + const obj2 = new CustomObj(42, { prop: 'test' }); + assertInstance.deepStrictEqual(obj1, obj2); + + assert.throws( + () => assertInstance.deepStrictEqual({ num: 42 }, { num: '42' }), + { code: 'ERR_ASSERTION' } + ); + }); +} diff --git a/test/parallel/test-util-isDeepStrictEqual.js b/test/parallel/test-util-isDeepStrictEqual.js index 48b3116061932f..458d1446aea5d4 100644 --- a/test/parallel/test-util-isDeepStrictEqual.js +++ b/test/parallel/test-util-isDeepStrictEqual.js @@ -6,6 +6,7 @@ require('../common'); const assert = require('assert'); const util = require('util'); +const { test } = require('node:test'); function utilIsDeepStrict(a, b) { assert.strictEqual(util.isDeepStrictEqual(a, b), true); @@ -92,3 +93,65 @@ function notUtilIsDeepStrict(a, b) { boxedStringA[symbol1] = true; utilIsDeepStrict(a, b); } + +// Handle `skipPrototype` for isDeepStrictEqual +{ + test('util.isDeepStrictEqual with skipPrototype', () => { + function ClassA(value) { this.value = value; } + + function ClassB(value) { this.value = value; } + + const objA = new ClassA(42); + const objB = new ClassB(42); + + assert.strictEqual(util.isDeepStrictEqual(objA, objB), false); + assert.strictEqual(util.isDeepStrictEqual(objA, objB, true), true); + + const objC = new ClassB(99); + assert.strictEqual(util.isDeepStrictEqual(objA, objC, true), false); + + const nestedA = { obj: new ClassA('test'), num: 123 }; + const nestedB = { obj: new ClassB('test'), num: 123 }; + + assert.strictEqual(util.isDeepStrictEqual(nestedA, nestedB), false); + assert.strictEqual(util.isDeepStrictEqual(nestedA, nestedB, true), true); + + const uint8Array = new Uint8Array([1, 2, 3]); + const buffer = Buffer.from([1, 2, 3]); + + assert.strictEqual(util.isDeepStrictEqual(uint8Array, buffer), false); + assert.strictEqual(util.isDeepStrictEqual(uint8Array, buffer, true), true); + }); + + test('util.isDeepStrictEqual skipPrototype with complex scenarios', () => { + class Parent { constructor(x) { this.x = x; } } + class Child extends Parent { constructor(x, y) { super(x); this.y = y; } } + + function LegacyParent(x) { this.x = x; } + + function LegacyChild(x, y) { this.x = x; this.y = y; } + + const modernParent = new Parent(1); + const legacyParent = new LegacyParent(1); + + assert.strictEqual(util.isDeepStrictEqual(modernParent, legacyParent), false); + assert.strictEqual(util.isDeepStrictEqual(modernParent, legacyParent, true), true); + + const modern = new Child(1, 2); + const legacy = new LegacyChild(1, 2); + + assert.strictEqual(util.isDeepStrictEqual(modern, legacy), false); + assert.strictEqual(util.isDeepStrictEqual(modern, legacy, true), true); + + const literal = { name: 'test', values: [1, 2, 3] }; + function Constructor(name, values) { this.name = name; this.values = values; } + const constructed = new Constructor('test', [1, 2, 3]); + + assert.strictEqual(util.isDeepStrictEqual(literal, constructed), false); + assert.strictEqual(util.isDeepStrictEqual(literal, constructed, true), true); + + assert.strictEqual(util.isDeepStrictEqual(literal, constructed, false), false); + assert.strictEqual(util.isDeepStrictEqual(literal, constructed, null), false); + assert.strictEqual(util.isDeepStrictEqual(literal, constructed, undefined), false); + }); +}