assert: add partialDeepStrictEqual

Fixes: https://github.com/nodejs/node/issues/50399

Co-Authored-By: Cristian Barlutiu <cristian.barlutiu@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/54630
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Ruben Bridgewater <ruben@bridgewater.de>
Reviewed-By: Jithil P Ponnan <jithil@outlook.com>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
This commit is contained in:
Giovanni Bucci 2024-11-23 18:45:49 +01:00 committed by GitHub
parent a2a0c22fbf
commit d0d52092cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 803 additions and 2 deletions

View file

@ -21,22 +21,35 @@
'use strict';
const {
ArrayFrom,
ArrayIsArray,
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
ArrayPrototypePush,
ArrayPrototypeSlice,
Error,
FunctionPrototypeCall,
MapPrototypeDelete,
MapPrototypeGet,
MapPrototypeHas,
MapPrototypeSet,
NumberIsNaN,
ObjectAssign,
ObjectIs,
ObjectKeys,
ObjectPrototypeIsPrototypeOf,
ReflectApply,
ReflectHas,
ReflectOwnKeys,
RegExpPrototypeExec,
SafeMap,
SafeSet,
SafeWeakSet,
String,
StringPrototypeIndexOf,
StringPrototypeSlice,
StringPrototypeSplit,
SymbolIterator,
} = primordials;
const {
@ -50,8 +63,18 @@ const {
} = require('internal/errors');
const AssertionError = require('internal/assert/assertion_error');
const { inspect } = require('internal/util/inspect');
const { isPromise, isRegExp } = require('internal/util/types');
const { isError, deprecate } = require('internal/util');
const { Buffer } = require('buffer');
const {
isKeyObject,
isPromise,
isRegExp,
isMap,
isSet,
isDate,
isWeakSet,
isWeakMap,
} = require('internal/util/types');
const { isError, deprecate, emitExperimentalWarning } = require('internal/util');
const { innerOk } = require('internal/assert/utils');
const CallTracker = require('internal/assert/calltracker');
@ -341,6 +364,191 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
}
};
function isSpecial(obj) {
return obj == null || typeof obj !== 'object' || isError(obj) || isRegExp(obj) || isDate(obj);
}
const typesToCallDeepStrictEqualWith = [
isKeyObject, isWeakSet, isWeakMap, Buffer.isBuffer,
];
/**
* Compares two objects or values recursively to check if they are equal.
* @param {any} actual - The actual value to compare.
* @param {any} expected - The expected value to compare.
* @param {Set} [comparedObjects=new Set()] - Set to track compared objects for handling circular references.
* @returns {boolean} - Returns `true` if the actual value matches the expected value, otherwise `false`.
* @example
* compareBranch({a: 1, b: 2, c: 3}, {a: 1, b: 2}); // true
*/
function compareBranch(
actual,
expected,
comparedObjects,
) {
// Check for Map object equality
if (isMap(actual) && isMap(expected)) {
if (actual.size !== expected.size) {
return false;
}
const safeIterator = FunctionPrototypeCall(SafeMap.prototype[SymbolIterator], actual);
comparedObjects ??= new SafeWeakSet();
for (const { 0: key, 1: val } of safeIterator) {
if (!MapPrototypeHas(expected, key)) {
return false;
}
if (!compareBranch(val, MapPrototypeGet(expected, key), comparedObjects)) {
return false;
}
}
return true;
}
for (const type of typesToCallDeepStrictEqualWith) {
if (type(actual) || type(expected)) {
if (isDeepStrictEqual === undefined) lazyLoadComparison();
return isDeepStrictEqual(actual, expected);
}
}
// Check for Set object equality
// TODO(aduh95): switch to `SetPrototypeIsSubsetOf` when it's available
if (isSet(actual) && isSet(expected)) {
if (expected.size > actual.size) {
return false; // `expected` can't be a subset if it has more elements
}
if (isDeepEqual === undefined) lazyLoadComparison();
const actualArray = ArrayFrom(actual);
const expectedArray = ArrayFrom(expected);
const usedIndices = new SafeSet();
for (let expectedIdx = 0; expectedIdx < expectedArray.length; expectedIdx++) {
const expectedItem = expectedArray[expectedIdx];
let found = false;
for (let actualIdx = 0; actualIdx < actualArray.length; actualIdx++) {
if (!usedIndices.has(actualIdx) && isDeepStrictEqual(actualArray[actualIdx], expectedItem)) {
usedIndices.add(actualIdx);
found = true;
break;
}
}
if (!found) {
return false;
}
}
return true;
}
// Check if expected array is a subset of actual array
if (ArrayIsArray(actual) && ArrayIsArray(expected)) {
if (expected.length > actual.length) {
return false;
}
if (isDeepEqual === undefined) lazyLoadComparison();
// Create a map to count occurrences of each element in the expected array
const expectedCounts = new SafeMap();
for (const expectedItem of expected) {
let found = false;
for (const { 0: key, 1: count } of expectedCounts) {
if (isDeepStrictEqual(key, expectedItem)) {
MapPrototypeSet(expectedCounts, key, count + 1);
found = true;
break;
}
}
if (!found) {
MapPrototypeSet(expectedCounts, expectedItem, 1);
}
}
// Create a map to count occurrences of relevant elements in the actual array
for (const actualItem of actual) {
for (const { 0: key, 1: count } of expectedCounts) {
if (isDeepStrictEqual(key, actualItem)) {
if (count === 1) {
MapPrototypeDelete(expectedCounts, key);
} else {
MapPrototypeSet(expectedCounts, key, count - 1);
}
break;
}
}
}
return !expectedCounts.size;
}
// Comparison done when at least one of the values is not an object
if (isSpecial(actual) || isSpecial(expected)) {
if (isDeepEqual === undefined) {
lazyLoadComparison();
}
return isDeepStrictEqual(actual, expected);
}
// Use Reflect.ownKeys() instead of Object.keys() to include symbol properties
const keysExpected = ReflectOwnKeys(expected);
comparedObjects ??= new SafeWeakSet();
// Handle circular references
if (comparedObjects.has(actual)) {
return true;
}
comparedObjects.add(actual);
// Check if all expected keys and values match
for (let i = 0; i < keysExpected.length; i++) {
const key = keysExpected[i];
assert(
ReflectHas(actual, key),
new AssertionError({ message: `Expected key ${String(key)} not found in actual object` }),
);
if (!compareBranch(actual[key], expected[key], comparedObjects)) {
return false;
}
}
return true;
}
/**
* The strict equivalence assertion test between two objects
* @param {any} actual
* @param {any} expected
* @param {string | Error} [message]
* @returns {void}
*/
assert.partialDeepStrictEqual = function partialDeepStrictEqual(
actual,
expected,
message,
) {
emitExperimentalWarning('assert.partialDeepStrictEqual');
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
if (!compareBranch(actual, expected)) {
innerFail({
actual,
expected,
message,
operator: 'partialDeepStrictEqual',
stackStartFn: partialDeepStrictEqual,
});
}
};
class Comparison {
constructor(obj, keys, actual) {
for (const key of keys) {