lib: update isDeepStrictEqual to support options

PR-URL: https://github.com/nodejs/node/pull/59762
Reviewed-By: Jordan Harband <ljharb@gmail.com>
Reviewed-By: Ruben Bridgewater <ruben@bridgewater.de>
This commit is contained in:
Miguel Marcondes Filho 2025-09-13 21:39:49 -03:00 committed by GitHub
parent 5ed1a47bab
commit 4ed9d21880
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 315 additions and 13 deletions

View File

@ -229,11 +229,20 @@ The `Assert` class allows creating independent assertion instances with custom o
### `new assert.Assert([options])`
<!-- YAML
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59762
description: Added `skipPrototype` option.
-->
* `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

View File

@ -1574,19 +1574,56 @@ inspect.defaultOptions.maxArrayLength = null;
console.log(arr); // logs the full array
```
## `util.isDeepStrictEqual(val1, val2)`
## `util.isDeepStrictEqual(val1, val2[, options])`
<!-- YAML
added: v9.0.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59762
description: Added `options` parameter to allow skipping prototype comparison.
-->
* `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.

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -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' }
);
});
}

View File

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