From e92deb6bde10209833e73d108a5e761ab505edda Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Fri, 7 Mar 2025 14:18:55 +0100 Subject: [PATCH 1/9] assert: improve partialDeepStrictEqual This significantly improves the assert.partialDeepStrictEqual implementation by reusing the already existing algorithm. It is significantly faster and handles edge cases like symbols identical as the deepStrictEqual algorithm. This is crucial to remove the experimental status from the implementation. --- doc/api/assert.md | 82 +++--- lib/assert.js | 317 +---------------------- lib/internal/util/comparisons.js | 371 ++++++++++++++++++--------- test/parallel/test-assert-objects.js | 188 +++++++++++++- 4 files changed, 492 insertions(+), 466 deletions(-) diff --git a/doc/api/assert.md b/doc/api/assert.md index 89e255771408d0..f59b3f0b4bbacb 100644 --- a/doc/api/assert.md +++ b/doc/api/assert.md @@ -2594,56 +2594,68 @@ added: - v22.13.0 --> -> Stability: 1.0 - Early development +> Stability: 1.2 - Release candidate * `actual` {any} * `expected` {any} * `message` {string|Error} -[`assert.partialDeepStrictEqual()`][] Asserts the equivalence between the `actual` and `expected` parameters through a -deep comparison, ensuring that all properties in the `expected` parameter are -present in the `actual` parameter with equivalent values, not allowing type coercion. -The main difference with [`assert.deepStrictEqual()`][] is that [`assert.partialDeepStrictEqual()`][] does not require -all properties in the `actual` parameter to be present in the `expected` parameter. -This method should always pass the same test cases as [`assert.deepStrictEqual()`][], behaving as a super set of it. - -```mjs -import assert from 'node:assert'; +Tests for partial deep equality between the `actual` and `expected` parameters. +"Deep" equality means that the enumerable "own" properties of child objects +are recursively evaluated also by the following rules. "Partial" equality means +that only properties that exist on the `expected` parameter are going to be +compared. -assert.partialDeepStrictEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); -// OK +This method always passes the same test cases as [`assert.deepStrictEqual()`][], +behaving as a super set of it. -assert.partialDeepStrictEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }); -// OK +### Comparison details -assert.partialDeepStrictEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 }); -// OK +* Primitive values are compared using [`Object.is()`][]. +* [Type tags][Object.prototype.toString()] of objects should be the same. +* [`[[Prototype]]`][prototype-spec] of objects are not compared. +* Only [enumerable "own" properties][] are considered. +* {Error} names, messages, causes, and errors are always compared, + even if these are not enumerable properties. + `errors` is also compared. +* Enumerable own {Symbol} properties are compared as well. +* [Object wrappers][] are compared both as objects and unwrapped values. +* `Object` properties are compared unordered. +* {Map} keys and {Set} items are compared unordered. +* Recursion stops when both sides differ or both sides encounter a circular + reference. +* {WeakMap} and {WeakSet} instances are **not** compared structurally. + They are only equal if they reference the same object. Any comparison between + different `WeakMap` or `WeakSet` instances will result in inequality, + even if they contain the same entries. +* {RegExp} lastIndex, flags, and source are always compared, even if these + are not enumerable properties. -assert.partialDeepStrictEqual(new Set(['value1', 'value2']), new Set(['value1', 'value2'])); -// OK +```mjs +import assert from 'node:assert'; -assert.partialDeepStrictEqual(new Map([['key1', 'value1']]), new Map([['key1', 'value1']])); +assert.partialDeepStrictEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }); // OK -assert.partialDeepStrictEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3])); +assert.partialDeepStrictEqual({ a: 1, b: 2, c: 3 }, { a: 1 }); // OK -assert.partialDeepStrictEqual(/abc/, /abc/); +assert.partialDeepStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], [4, 5, 8]); // OK -assert.partialDeepStrictEqual([{ a: 5 }, { b: 5 }], [{ a: 5 }]); +assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 1 }]), new Set([{ a: 1 }])); // OK -assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 1 }]), new Set([{ a: 1 }])); +assert.partialDeepStrictEqual(new Map([['key1', 'value1'], ['key2', 'value2']]), new Map([['key2', 'value2']])); // OK -assert.partialDeepStrictEqual(new Date(0), new Date(0)); +assert.partialDeepStrictEqual(123n, 123n); // OK -assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 }); +assert.partialDeepStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], [5, 4, 8]); // AssertionError -assert.partialDeepStrictEqual({ a: 1, b: '2' }, { a: 1, b: 2 }); +assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 }); // AssertionError assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } }); @@ -2653,25 +2665,28 @@ assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } }); ```cjs const assert = require('node:assert'); -assert.partialDeepStrictEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); -// OK - assert.partialDeepStrictEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }); // OK -assert.partialDeepStrictEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 }); +assert.partialDeepStrictEqual({ a: 1, b: 2, c: 3 }, { a: 1 }); // OK -assert.partialDeepStrictEqual([{ a: 5 }, { b: 5 }], [{ a: 5 }]); +assert.partialDeepStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], [4, 5, 8]); // OK assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 1 }]), new Set([{ a: 1 }])); // OK -assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 }); +assert.partialDeepStrictEqual(new Map([['key1', 'value1'], ['key2', 'value2']]), new Map([['key2', 'value2']])); +// OK + +assert.partialDeepStrictEqual(123n, 123n); +// OK + +assert.partialDeepStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], [5, 4, 8]); // AssertionError -assert.partialDeepStrictEqual({ a: 1, b: '2' }, { a: 1, b: 2 }); +assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 }); // AssertionError assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } }); @@ -2698,7 +2713,6 @@ assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } }); [`assert.notEqual()`]: #assertnotequalactual-expected-message [`assert.notStrictEqual()`]: #assertnotstrictequalactual-expected-message [`assert.ok()`]: #assertokvalue-message -[`assert.partialDeepStrictEqual()`]: #assertpartialdeepstrictequalactual-expected-message [`assert.strictEqual()`]: #assertstrictequalactual-expected-message [`assert.throws()`]: #assertthrowsfn-error-message [`getColorDepth()`]: tty.md#writestreamgetcolordepthenv diff --git a/lib/assert.js b/lib/assert.js index 603f2a026313c9..c0c4f026e68646 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -21,45 +21,22 @@ 'use strict'; const { - ArrayBufferIsView, - ArrayBufferPrototypeGetByteLength, - ArrayFrom, - ArrayIsArray, ArrayPrototypeIndexOf, ArrayPrototypeJoin, ArrayPrototypePush, ArrayPrototypeSlice, - DataViewPrototypeGetBuffer, - DataViewPrototypeGetByteLength, - DataViewPrototypeGetByteOffset, Error, - FunctionPrototypeCall, - MapPrototypeGet, - MapPrototypeGetSize, - MapPrototypeHas, NumberIsNaN, ObjectAssign, ObjectIs, ObjectKeys, ObjectPrototypeIsPrototypeOf, - ObjectPrototypeToString, ReflectApply, - ReflectHas, - ReflectOwnKeys, RegExpPrototypeExec, - SafeArrayIterator, - SafeMap, - SafeSet, - SafeWeakSet, - SetPrototypeGetSize, String, StringPrototypeIndexOf, StringPrototypeSlice, StringPrototypeSplit, - Symbol, - SymbolIterator, - TypedArrayPrototypeGetLength, - Uint8Array, } = primordials; const { @@ -73,20 +50,9 @@ const { } = require('internal/errors'); const AssertionError = require('internal/assert/assertion_error'); const { inspect } = require('internal/util/inspect'); -const { Buffer } = require('buffer'); const { - isArrayBuffer, - isDataView, - isKeyObject, isPromise, isRegExp, - isMap, - isSet, - isDate, - isWeakSet, - isWeakMap, - isSharedArrayBuffer, - isAnyArrayBuffer, } = require('internal/util/types'); const { isError, deprecate, emitExperimentalWarning } = require('internal/util'); const { innerOk } = require('internal/assert/utils'); @@ -95,15 +61,16 @@ const CallTracker = require('internal/assert/calltracker'); const { validateFunction, } = require('internal/validators'); -const { isURL } = require('internal/url'); let isDeepEqual; let isDeepStrictEqual; +let isPartialStrictEqual; function lazyLoadComparison() { const comparison = require('internal/util/comparisons'); isDeepEqual = comparison.isDeepEqual; isDeepStrictEqual = comparison.isDeepStrictEqual; + isPartialStrictEqual = comparison.isPartialStrictEqual; } let warned = false; @@ -379,282 +346,6 @@ 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, isSharedArrayBuffer, isURL, -]; - -function partiallyCompareMaps(actual, expected, comparedObjects) { - if (MapPrototypeGetSize(expected) > MapPrototypeGetSize(actual)) { - return false; - } - - comparedObjects ??= new SafeWeakSet(); - const expectedIterator = FunctionPrototypeCall(SafeMap.prototype[SymbolIterator], expected); - - for (const { 0: key, 1: expectedValue } of expectedIterator) { - if (!MapPrototypeHas(actual, key)) { - return false; - } - - const actualValue = MapPrototypeGet(actual, key); - - if (!compareBranch(actualValue, expectedValue, comparedObjects)) { - return false; - } - } - - return true; -} - -function partiallyCompareArrayBuffersOrViews(actual, expected) { - let actualView, expectedView, expectedViewLength; - - if (!ArrayBufferIsView(actual)) { - let actualViewLength; - - if (isArrayBuffer(actual) && isArrayBuffer(expected)) { - actualViewLength = ArrayBufferPrototypeGetByteLength(actual); - expectedViewLength = ArrayBufferPrototypeGetByteLength(expected); - } else if (isSharedArrayBuffer(actual) && isSharedArrayBuffer(expected)) { - actualViewLength = actual.byteLength; - expectedViewLength = expected.byteLength; - } else { - // Cannot compare ArrayBuffers with SharedArrayBuffers - return false; - } - - if (expectedViewLength > actualViewLength) { - return false; - } - actualView = new Uint8Array(actual); - expectedView = new Uint8Array(expected); - - } else if (isDataView(actual)) { - if (!isDataView(expected)) { - return false; - } - const actualByteLength = DataViewPrototypeGetByteLength(actual); - expectedViewLength = DataViewPrototypeGetByteLength(expected); - if (expectedViewLength > actualByteLength) { - return false; - } - - actualView = new Uint8Array( - DataViewPrototypeGetBuffer(actual), - DataViewPrototypeGetByteOffset(actual), - actualByteLength, - ); - expectedView = new Uint8Array( - DataViewPrototypeGetBuffer(expected), - DataViewPrototypeGetByteOffset(expected), - expectedViewLength, - ); - } else { - if (ObjectPrototypeToString(actual) !== ObjectPrototypeToString(expected)) { - return false; - } - actualView = actual; - expectedView = expected; - expectedViewLength = TypedArrayPrototypeGetLength(expected); - - if (expectedViewLength > TypedArrayPrototypeGetLength(actual)) { - return false; - } - } - - for (let i = 0; i < expectedViewLength; i++) { - if (!ObjectIs(actualView[i], expectedView[i])) { - return false; - } - } - - return true; -} - -function partiallyCompareSets(actual, expected, comparedObjects) { - if (SetPrototypeGetSize(expected) > SetPrototypeGetSize(actual)) { - return false; // `expected` can't be a subset if it has more elements - } - - if (isDeepEqual === undefined) lazyLoadComparison(); - - const actualArray = ArrayFrom(FunctionPrototypeCall(SafeSet.prototype[SymbolIterator], actual)); - const expectedIterator = FunctionPrototypeCall(SafeSet.prototype[SymbolIterator], expected); - const usedIndices = new SafeSet(); - - expectedIteration: for (const expectedItem of expectedIterator) { - for (let actualIdx = 0; actualIdx < actualArray.length; actualIdx++) { - if (!usedIndices.has(actualIdx) && isDeepStrictEqual(actualArray[actualIdx], expectedItem)) { - usedIndices.add(actualIdx); - continue expectedIteration; - } - } - return false; - } - - return true; -} - -const minusZeroSymbol = Symbol('-0'); -const zeroSymbol = Symbol('0'); - -// Helper function to get a unique key for 0, -0 to avoid collisions -function getZeroKey(item) { - if (item === 0) { - return ObjectIs(item, -0) ? minusZeroSymbol : zeroSymbol; - } - return item; -} - -function partiallyCompareArrays(actual, expected, comparedObjects) { - 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(); - const safeExpected = new SafeArrayIterator(expected); - - for (const expectedItem of safeExpected) { - // Check if the item is a zero or a -0, as these need to be handled separately - if (expectedItem === 0) { - const zeroKey = getZeroKey(expectedItem); - expectedCounts.set(zeroKey, (expectedCounts.get(zeroKey)?.count || 0) + 1); - } else { - let found = false; - for (const { 0: key, 1: count } of expectedCounts) { - if (isDeepStrictEqual(key, expectedItem)) { - expectedCounts.set(key, count + 1); - found = true; - break; - } - } - if (!found) { - expectedCounts.set(expectedItem, 1); - } - } - } - - const safeActual = new SafeArrayIterator(actual); - - for (const actualItem of safeActual) { - // Check if the item is a zero or a -0, as these need to be handled separately - if (actualItem === 0) { - const zeroKey = getZeroKey(actualItem); - - if (expectedCounts.has(zeroKey)) { - const count = expectedCounts.get(zeroKey); - if (count === 1) { - expectedCounts.delete(zeroKey); - } else { - expectedCounts.set(zeroKey, count - 1); - } - } - } else { - for (const { 0: expectedItem, 1: count } of expectedCounts) { - if (isDeepStrictEqual(expectedItem, actualItem)) { - if (count === 1) { - expectedCounts.delete(expectedItem); - } else { - expectedCounts.set(expectedItem, count - 1); - } - break; - } - } - } - } - - return expectedCounts.size === 0; -} - -/** - * 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, -) { - // Checking for the simplest case possible. - if (actual === expected) { - return true; - } - // Check for Map object equality - if (isMap(actual) && isMap(expected)) { - return partiallyCompareMaps(actual, expected, comparedObjects); - } - - if ( - ArrayBufferIsView(actual) || - isAnyArrayBuffer(actual) || - ArrayBufferIsView(expected) || - isAnyArrayBuffer(expected) - ) { - return partiallyCompareArrayBuffersOrViews(actual, expected); - } - - for (const type of typesToCallDeepStrictEqualWith) { - if (type(actual) || type(expected)) { - if (isDeepStrictEqual === undefined) lazyLoadComparison(); - return isDeepStrictEqual(actual, expected); - } - } - - // Check for Set object equality - if (isSet(actual) && isSet(expected)) { - return partiallyCompareSets(actual, expected, comparedObjects); - } - - // Check if expected array is a subset of actual array - if (ArrayIsArray(actual) && ArrayIsArray(expected)) { - return partiallyCompareArrays(actual, expected, comparedObjects); - } - - // 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]; - if (!ReflectHas(actual, key)) { - return false; - } - if (!compareBranch(actual[key], expected[key], comparedObjects)) { - return false; - } - } - - return true; -} - /** * The strict equivalence assertion test between two objects * @param {any} actual @@ -671,8 +362,8 @@ assert.partialDeepStrictEqual = function partialDeepStrictEqual( if (arguments.length < 2) { throw new ERR_MISSING_ARGS('actual', 'expected'); } - - if (!compareBranch(actual, expected)) { + if (isDeepEqual === undefined) lazyLoadComparison(); + if (!isPartialStrictEqual(actual, expected)) { innerFail({ actual, expected, diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 554e513b96e9fe..59dd145924c73b 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -59,8 +59,9 @@ const { getOwnNonIndexProperties, } = internalBinding('util'); -const kStrict = true; -const kLoose = false; +const kStrict = 1; +const kLoose = 0; +const kPartial = 2; const kNoIterator = 0; const kIsArray = 1; @@ -76,6 +77,35 @@ function areSimilarRegExps(a, b) { a.lastIndex === b.lastIndex; } +function isPartialUint8Array(a, b) { + const lenA = TypedArrayPrototypeGetByteLength(a); + const lenB = TypedArrayPrototypeGetByteLength(b); + if (lenA < lenB) { + return false; + } + let offsetA = 0; + for (let offsetB = 0; offsetB < lenB; offsetB++) { + while (!ObjectIs(a[offsetA], b[offsetB])) { + offsetA++; + if (offsetA > lenA - lenB + offsetB) { + return false; + } + } + offsetA++; + } + return true; +} + +function isPartialArrayBufferView(a, b) { + if (a.byteLength < b.byteLength) { + return false; + } + return isPartialUint8Array( + new Uint8Array(a.buffer, a.byteOffset, a.byteLength), + new Uint8Array(b.buffer, b.byteOffset, b.byteLength), + ); +} + function areSimilarFloatArrays(a, b) { if (a.byteLength !== b.byteLength) { return false; @@ -140,14 +170,14 @@ function isEqualBoxedPrimitive(val1, val2) { // a) The same built-in type tag. // b) The same prototypes. -function innerDeepEqual(val1, val2, strict, memos) { +function innerDeepEqual(val1, val2, mode, memos) { // All identical values are equivalent, as determined by ===. if (val1 === val2) { - return val1 !== 0 || ObjectIs(val1, val2) || !strict; + return val1 !== 0 || ObjectIs(val1, val2) || mode === kLoose; } // Check more closely if val1 and val2 are equal. - if (strict) { + if (mode !== kLoose) { if (typeof val1 === 'number') { return NumberIsNaN(val1) && NumberIsNaN(val2); } @@ -155,7 +185,7 @@ function innerDeepEqual(val1, val2, strict, memos) { typeof val1 !== 'object' || val1 === null || val2 === null || - ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2)) { + (mode === kStrict && ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2))) { return false; } } else { @@ -179,18 +209,20 @@ function innerDeepEqual(val1, val2, strict, memos) { if (ArrayIsArray(val1)) { // Check for sparse arrays and general fast path - if (!ArrayIsArray(val2) || val1.length !== val2.length) { + if (!ArrayIsArray(val2) || + (val1.length !== val2.length && (mode !== kPartial || val1.length < val2.length))) { return false; } - const filter = strict ? ONLY_ENUMERABLE : ONLY_ENUMERABLE | SKIP_SYMBOLS; - const keys1 = getOwnNonIndexProperties(val1, filter); + + const filter = mode !== kLoose ? ONLY_ENUMERABLE : ONLY_ENUMERABLE | SKIP_SYMBOLS; const keys2 = getOwnNonIndexProperties(val2, filter); - if (keys1.length !== keys2.length) { + if (mode !== kPartial && + keys2.length !== getOwnNonIndexProperties(val1, filter).length) { return false; } - return keyCheck(val1, val2, strict, memos, kIsArray, keys1); + return keyCheck(val1, val2, mode, memos, kIsArray, keys2); } else if (val1Tag === '[object Object]') { - return keyCheck(val1, val2, strict, memos, kNoIterator); + return keyCheck(val1, val2, mode, memos, kNoIterator); } else if (isDate(val1)) { if (!isDate(val2) || DatePrototypeGetTime(val1) !== DatePrototypeGetTime(val2)) { @@ -205,7 +237,11 @@ function innerDeepEqual(val1, val2, strict, memos) { TypedArrayPrototypeGetSymbolToStringTag(val2)) { return false; } - if (!strict && (isFloat32Array(val1) || isFloat64Array(val1))) { + if (mode === kPartial) { + if (!isPartialArrayBufferView(val1, val2)) { + return false; + } + } else if (mode === kLoose && (isFloat32Array(val1) || isFloat64Array(val1))) { if (!areSimilarFloatArrays(val1, val2)) { return false; } @@ -215,25 +251,35 @@ function innerDeepEqual(val1, val2, strict, memos) { // Buffer.compare returns true, so val1.length === val2.length. If they both // only contain numeric keys, we don't need to exam further than checking // the symbols. - const filter = strict ? ONLY_ENUMERABLE : ONLY_ENUMERABLE | SKIP_SYMBOLS; - const keys1 = getOwnNonIndexProperties(val1, filter); + const filter = mode !== kLoose ? ONLY_ENUMERABLE : ONLY_ENUMERABLE | SKIP_SYMBOLS; const keys2 = getOwnNonIndexProperties(val2, filter); - if (keys1.length !== keys2.length) { + if (mode !== kPartial && + keys2.length !== getOwnNonIndexProperties(val1, filter).length) { return false; } - return keyCheck(val1, val2, strict, memos, kNoIterator, keys1); + return keyCheck(val1, val2, mode, memos, kNoIterator, keys2); } else if (isSet(val1)) { - if (!isSet(val2) || val1.size !== val2.size) { + if (!isSet(val2) || + (val1.size !== val2.size && (mode !== kPartial || val1.size < val2.size))) { return false; } - return keyCheck(val1, val2, strict, memos, kIsSet); + const result = keyCheck(val1, val2, mode, memos, kIsSet); + return result; } else if (isMap(val1)) { - if (!isMap(val2) || val1.size !== val2.size) { + if (!isMap(val2) || + (val1.size !== val2.size && (mode !== kPartial || val1.size < val2.size))) { return false; } - return keyCheck(val1, val2, strict, memos, kIsMap); + return keyCheck(val1, val2, mode, memos, kIsMap); } else if (isAnyArrayBuffer(val1)) { - if (!isAnyArrayBuffer(val2) || !areEqualArrayBuffers(val1, val2)) { + if (!isAnyArrayBuffer(val2)) { + return false; + } + if (mode !== kPartial) { + if (!areEqualArrayBuffers(val1, val2)) { + return false; + } + } else if (!isPartialUint8Array(new Uint8Array(val1), new Uint8Array(val2))) { return false; } } else if (isError(val1)) { @@ -245,6 +291,7 @@ function innerDeepEqual(val1, val2, strict, memos) { const message1Enumerable = ObjectPrototypePropertyIsEnumerable(val1, 'message'); const name1Enumerable = ObjectPrototypePropertyIsEnumerable(val1, 'name'); + // TODO(BridgeAR): Adjust cause and errors properties for partial mode. const cause1Enumerable = ObjectPrototypePropertyIsEnumerable(val1, 'cause'); const errors1Enumerable = ObjectPrototypePropertyIsEnumerable(val1, 'errors'); @@ -255,9 +302,9 @@ function innerDeepEqual(val1, val2, strict, memos) { (cause1Enumerable !== ObjectPrototypePropertyIsEnumerable(val2, 'cause') || (!cause1Enumerable && ( ObjectPrototypeHasOwnProperty(val1, 'cause') !== ObjectPrototypeHasOwnProperty(val2, 'cause') || - !innerDeepEqual(val1.cause, val2.cause, strict, memos)))) || + !innerDeepEqual(val1.cause, val2.cause, mode, memos)))) || (errors1Enumerable !== ObjectPrototypePropertyIsEnumerable(val2, 'errors') || - (!errors1Enumerable && !innerDeepEqual(val1.errors, val2.errors, strict, memos)))) { + (!errors1Enumerable && !innerDeepEqual(val1.errors, val2.errors, mode, memos)))) { return false; } } else if (isBoxedPrimitive(val1)) { @@ -283,9 +330,9 @@ function innerDeepEqual(val1, val2, strict, memos) { kKeyObject ??= require('internal/crypto/util').kKeyObject; if (!isCryptoKey(val2) || val1.extractable !== val2.extractable || - !innerDeepEqual(val1.algorithm, val2.algorithm, strict, memos) || - !innerDeepEqual(val1.usages, val2.usages, strict, memos) || - !innerDeepEqual(val1[kKeyObject], val2[kKeyObject], strict, memos) + !innerDeepEqual(val1.algorithm, val2.algorithm, mode, memos) || + !innerDeepEqual(val1.usages, val2.usages, mode, memos) || + !innerDeepEqual(val1[kKeyObject], val2[kKeyObject], mode, memos) ) { return false; } @@ -297,7 +344,7 @@ function innerDeepEqual(val1, val2, strict, memos) { } } - return keyCheck(val1, val2, strict, memos, kNoIterator); + return keyCheck(val1, val2, mode, memos, kNoIterator); } function getEnumerables(val, keys) { @@ -307,7 +354,7 @@ function getEnumerables(val, keys) { ); } -function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { +function keyCheck(val1, val2, mode, memos, iterationType, keys2) { // For all remaining Object pairs, including Array, objects and Maps, // equivalence is determined by having: // a) The same number of owned enumerable properties @@ -315,16 +362,16 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { // c) Equivalent values for every corresponding key/index // d) For Sets and Maps, equal contents // Note: this accounts for both named and indexed properties on Arrays. - const isArrayLikeObject = aKeys !== undefined; + const isArrayLikeObject = keys2 !== undefined; - if (aKeys === undefined) { - aKeys = ObjectKeys(val1); + if (keys2 === undefined) { + keys2 = ObjectKeys(val2); } // Cheap key test - if (aKeys.length > 0) { - for (const key of aKeys) { - if (!ObjectPrototypePropertyIsEnumerable(val2, key)) { + if (keys2.length > 0) { + for (const key of keys2) { + if (!ObjectPrototypePropertyIsEnumerable(val1, key)) { return false; } } @@ -332,11 +379,23 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { if (!isArrayLikeObject) { // The pair must have the same number of owned properties. - if (aKeys.length !== ObjectKeys(val2).length) { + if (mode === kPartial) { + const symbolKeys = ObjectGetOwnPropertySymbols(val2); + if (symbolKeys.length !== 0) { + for (const key of symbolKeys) { + if (ObjectPrototypePropertyIsEnumerable(val2, key)) { + if (!ObjectPrototypePropertyIsEnumerable(val1, key)) { + return false; + } + ArrayPrototypePush(keys2, key); + } + } + } + } else if (keys2.length !== ObjectKeys(val1).length) { return false; } - if (strict) { + if (mode === kStrict) { const symbolKeysA = ObjectGetOwnPropertySymbols(val1); if (symbolKeysA.length !== 0) { let count = 0; @@ -345,7 +404,7 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { if (!ObjectPrototypePropertyIsEnumerable(val2, key)) { return false; } - ArrayPrototypePush(aKeys, key); + ArrayPrototypePush(keys2, key); count++; } else if (ObjectPrototypePropertyIsEnumerable(val2, key)) { return false; @@ -366,10 +425,10 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { } } - if (aKeys.length === 0 && + if (keys2.length === 0 && (iterationType === kNoIterator || - (iterationType === kIsArray && val1.length === 0) || - val1.size === 0)) { + (iterationType === kIsArray && val2.length === 0) || + val2.size === 0)) { return true; } @@ -384,7 +443,7 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { deep: false, deleteFailures: false, }; - return objEquiv(val1, val2, strict, aKeys, memos, iterationType); + return objEquiv(val1, val2, mode, keys2, memos, iterationType); } if (memos.set === undefined) { @@ -395,7 +454,7 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { memos.c = val1; memos.d = val2; memos.deep = true; - const result = objEquiv(val1, val2, strict, aKeys, memos, iterationType); + const result = objEquiv(val1, val2, mode, keys2, memos, iterationType); memos.deep = false; return result; } @@ -415,7 +474,7 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { return true; } - const areEq = objEquiv(val1, val2, strict, aKeys, memos, iterationType); + const areEq = objEquiv(val1, val2, mode, keys2, memos, iterationType); if (areEq || memos.deleteFailures) { set.delete(val1); @@ -425,13 +484,13 @@ function keyCheck(val1, val2, strict, memos, iterationType, aKeys) { return areEq; } -function setHasEqualElement(set, val2, strict, memo) { +function setHasEqualElement(set, val1, mode, memo) { const { deleteFailures } = memo; memo.deleteFailures = true; - for (const val1 of set) { - if (innerDeepEqual(val1, val2, strict, memo)) { + for (const val2 of set) { + if (innerDeepEqual(val1, val2, mode, memo)) { // Remove the matching element to make sure we do not check that again. - set.delete(val1); + set.delete(val2); memo.deleteFailures = deleteFailures; return true; } @@ -471,56 +530,72 @@ function setMightHaveLoosePrim(a, b, prim) { if (altValue != null) return altValue; - return b.has(altValue) && !a.has(altValue); + return !b.has(altValue) && a.has(altValue); } -function mapMightHaveLoosePrim(a, b, prim, item, memo) { +function mapMightHaveLoosePrim(a, b, prim, item2, memo) { const altValue = findLooseMatchingPrimitives(prim); if (altValue != null) { return altValue; } - const curB = b.get(altValue); - if ((curB === undefined && !b.has(altValue)) || - !innerDeepEqual(item, curB, false, memo)) { + const item1 = a.get(altValue); + if ((item1 === undefined && !a.has(altValue)) || + !innerDeepEqual(item1, item2, kLoose, memo)) { return false; } - return !a.has(altValue) && innerDeepEqual(item, curB, false, memo); + return !b.has(altValue) && innerDeepEqual(item1, item2, kLoose, memo); +} + +function partialObjectSetEquiv(a, b, mode, set, memo) { + let aPos = 0; + for (const val of a) { + aPos++; + if (!b.has(val) && setHasEqualElement(set, val, mode, memo) && set.size === 0) { + return true; + } + if (a.size - aPos < set.size) { + return false; + } + } } -function setEquiv(a, b, strict, memo) { +function setEquiv(a, b, mode, memo) { // This is a lazily initiated Set of entries which have to be compared // pairwise. let set = null; - for (const val of a) { - if (!b.has(val)) { + for (const val of b) { + if (!a.has(val)) { if ((typeof val !== 'object' || val === null) && - (strict || !setMightHaveLoosePrim(a, b, val))) { + (mode !== kLoose || !setMightHaveLoosePrim(a, b, val))) { return false; } if (set === null) { - if (b.size === 1) { - return innerDeepEqual(val, b.values().next().value, strict, memo); + if (a.size === 1) { + return innerDeepEqual(a.values().next().value, val, mode, memo); } set = new SafeSet(); } // If the specified value doesn't exist in the second set it's a object // (or in loose mode: a non-matching primitive). Find the - // deep-(strict-)equal element in a set copy to reduce duplicate checks. + // deep-(mode-)equal element in a set copy to reduce duplicate checks. set.add(val); } } if (set !== null) { - for (const val of b) { + if (mode === kPartial) { + return partialObjectSetEquiv(a, b, mode, set, memo); + } + for (const val of a) { // Primitive values have already been handled above. if (typeof val === 'object' && val !== null) { - if (!a.has(val) && !setHasEqualElement(set, val, strict, memo)) { + if (!b.has(val) && !setHasEqualElement(set, val, mode, memo)) { return false; } - } else if (!strict && - !a.has(val) && - !setHasEqualElement(set, val, strict, memo)) { + } else if (mode === kLoose && + !b.has(val) && + !setHasEqualElement(set, val, mode, memo)) { return false; } } @@ -530,16 +605,16 @@ function setEquiv(a, b, strict, memo) { return true; } -function mapHasEqualEntry(set, map, key2, item2, strict, memo) { +function mapHasEqualEntry(set, map, key1, item1, mode, memo) { // To be able to handle cases like: // Map([[{}, 'a'], [{}, 'b']]) vs Map([[{}, 'b'], [{}, 'a']]) // ... we need to consider *all* matching keys, not just the first we find. const { deleteFailures } = memo; memo.deleteFailures = true; - for (const key1 of set) { - if (innerDeepEqual(key1, key2, strict, memo) && - innerDeepEqual(map.get(key1), item2, strict, memo)) { - set.delete(key1); + for (const key2 of set) { + if (innerDeepEqual(key1, key2, mode, memo) && + innerDeepEqual(item1, map.get(key2), mode, memo)) { + set.delete(key2); memo.deleteFailures = deleteFailures; return true; } @@ -549,49 +624,68 @@ function mapHasEqualEntry(set, map, key2, item2, strict, memo) { return false; } -function mapEquiv(a, b, strict, memo) { +function partialObjectMapEquiv(a, b, mode, set, memo) { + let aPos = 0; + for (const { 0: key1, 1: item1 } of a) { + aPos++; + if (typeof key1 === 'object' && + key1 !== null && + mapHasEqualEntry(set, b, key1, item1, mode, memo) && + set.size === 0) { + return true; + } + if (a.size - aPos < set.size) { + return false; + } + } +} + +function mapEquiv(a, b, mode, memo) { let set = null; - for (const { 0: key, 1: item1 } of a) { - if (typeof key === 'object' && key !== null) { + for (const { 0: key2, 1: item2 } of b) { + if (typeof key2 === 'object' && key2 !== null) { if (set === null) { - if (b.size === 1) { - const { 0: key2, 1: item2 } = b.entries().next().value; - return innerDeepEqual(key, key2, strict, memo) && - innerDeepEqual(item1, item2, strict, memo); + if (a.size === 1) { + const { 0: key1, 1: item1 } = a.entries().next().value; + return innerDeepEqual(key1, key2, mode, memo) && + innerDeepEqual(item1, item2, mode, memo); } set = new SafeSet(); } - set.add(key); + set.add(key2); } else { - // By directly retrieving the value we prevent another b.has(key) check in + // By directly retrieving the value we prevent another b.has(key2) check in // almost all possible cases. - const item2 = b.get(key); - if (((item2 === undefined && !b.has(key)) || - !innerDeepEqual(item1, item2, strict, memo))) { - if (strict) + const item1 = a.get(key2); + if (((item1 === undefined && !a.has(key2)) || + !innerDeepEqual(item1, item2, mode, memo))) { + if (mode !== kLoose) return false; // Fast path to detect missing string, symbol, undefined and null // keys. - if (!mapMightHaveLoosePrim(a, b, key, item1, memo)) + if (!mapMightHaveLoosePrim(a, b, key2, item2, memo)) return false; if (set === null) { set = new SafeSet(); } - set.add(key); + set.add(key2); } } } if (set !== null) { - for (const { 0: key, 1: item } of b) { - if (typeof key === 'object' && key !== null) { - if (!mapHasEqualEntry(set, a, key, item, strict, memo)) + if (mode === kPartial) { + return partialObjectMapEquiv(a, b, mode, set, memo); + } + for (const { 0: key1, 1: item1 } of a) { + if (typeof key1 === 'object' && key1 !== null) { + if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) return false; - } else if (!strict && - (!a.has(key) || - !innerDeepEqual(a.get(key), item, strict, memo)) && - !mapHasEqualEntry(set, a, key, item, strict, memo)) { + } else if (mode === kLoose && + (!b.has(key1) || + !innerDeepEqual(item1, b.get(key1), mode, memo)) && + !mapHasEqualEntry(set, b, key1, item1, mode, memo)) { return false; } } @@ -601,32 +695,76 @@ function mapEquiv(a, b, strict, memo) { return true; } -function objEquiv(a, b, strict, keys, memos, iterationType) { +function partialSparseArrayEquiv(a, b, mode, memos) { + let aPos = 0; + // The comparison has to start from the beginning again, due to not knowing + // what entries where skipped before. + const keysA = ObjectKeys(a); + const keysB = ObjectKeys(b); + if (keysA.length < keysB.length) { + return false; + } + for (let i = 0; i < keysB.length; i++) { + const keyB = keysB[i]; + while (!innerDeepEqual(a[keysA[aPos]], b[keyB], mode, memos)) { + aPos++; + if (aPos > keysA.length - keysB.length + i) { + return false; + } + } + aPos++; + } + return true; +} + +function partialArrayEquiv(a, b, mode, memos) { + let aPos = 0; + for (let i = 0; i < b.length; i++) { + const isOwnProperty = ObjectPrototypeHasOwnProperty(b, i); + if (!isOwnProperty) { + return partialSparseArrayEquiv(a, b, mode, memos); + } + while (!innerDeepEqual(a[aPos], b[i], mode, memos) || + ObjectPrototypeHasOwnProperty(a, aPos) !== isOwnProperty) { + aPos++; + if (aPos > a.length - b.length + i) { + return false; + } + } + aPos++; + } + return true; +} + +function objEquiv(a, b, mode, keys2, memos, iterationType) { // The pair must have equivalent values for every corresponding key. - if (keys.length > 0) { - for (const key of keys) { - if (!innerDeepEqual(a[key], b[key], strict, memos)) { + if (keys2.length > 0) { + for (const key of keys2) { + if (!innerDeepEqual(a[key], b[key], mode, memos)) { return false; } } } if (iterationType === kIsArray) { + if (mode === kPartial) { + return partialArrayEquiv(a, b, mode, memos); + } for (let i = 0; i < a.length; i++) { - if (ObjectPrototypeHasOwnProperty(a, i)) { - if (!ObjectPrototypeHasOwnProperty(b, i) || - !innerDeepEqual(a[i], b[i], strict, memos)) { - return false; - } - } else if (ObjectPrototypeHasOwnProperty(b, i)) { + if (!innerDeepEqual(a[i], b[i], mode, memos)) { return false; - } else { + } + const isOwnProperty = ObjectPrototypeHasOwnProperty(a, i); + if (isOwnProperty !== ObjectPrototypeHasOwnProperty(b, i)) { + return false; + } + if (!isOwnProperty) { // Array is sparse. const keysA = ObjectKeys(a); for (; i < keysA.length; i++) { const key = keysA[i]; if (!ObjectPrototypeHasOwnProperty(b, key) || - !innerDeepEqual(a[key], b[key], strict, memos)) { + !innerDeepEqual(a[key], b[key], mode, memos)) { return false; } } @@ -634,11 +772,11 @@ function objEquiv(a, b, strict, keys, memos, iterationType) { } } } else if (iterationType === kIsSet) { - if (!setEquiv(a, b, strict, memos)) { + if (!setEquiv(a, b, mode, memos)) { return false; } } else if (iterationType === kIsMap) { - if (!mapEquiv(a, b, strict, memos)) { + if (!mapEquiv(a, b, mode, memos)) { return false; } } @@ -646,15 +784,14 @@ function objEquiv(a, b, strict, keys, memos, iterationType) { return true; } -function isDeepEqual(val1, val2) { - return innerDeepEqual(val1, val2, kLoose); -} - -function isDeepStrictEqual(val1, val2) { - return innerDeepEqual(val1, val2, kStrict); -} - module.exports = { - isDeepEqual, - isDeepStrictEqual, + isDeepEqual(val1, val2) { + return innerDeepEqual(val1, val2, kLoose); + }, + isDeepStrictEqual(val1, val2) { + return innerDeepEqual(val1, val2, kStrict); + }, + isPartialStrictEqual(val1, val2) { + return innerDeepEqual(val1, val2, kPartial); + }, }; diff --git a/test/parallel/test-assert-objects.js b/test/parallel/test-assert-objects.js index 4cf8f424f91913..d1307e9a5b3604 100644 --- a/test/parallel/test-assert-objects.js +++ b/test/parallel/test-assert-objects.js @@ -5,9 +5,12 @@ const vm = require('node:vm'); const assert = require('node:assert'); const { describe, it } = require('node:test'); +const x = ['x']; + function createCircularObject() { const obj = {}; obj.self = obj; + obj.set = new Set([x, ['y']]); return obj; } @@ -43,6 +46,11 @@ describe('Object Comparison Tests', () => { actual: { a: 1 }, expected: undefined, }, + { + description: 'throws when unequal zeros are compared', + actual: 0, + expected: -0, + }, { description: 'throws when only expected is provided', actual: undefined, @@ -261,11 +269,86 @@ describe('Object Comparison Tests', () => { actual: [1, 2, 3], expected: ['2'], }, + { + description: 'throws when comparing an array with symbol properties not matching', + actual: (() => { + const array = [1, 2, 3]; + array[Symbol.for('test')] = 'test'; + return array; + })(), + expected: (() => { + const array = [1, 2, 3]; + array[Symbol.for('test')] = 'different'; + return array; + })(), + }, + { + description: 'throws when comparing an array with extra properties not matching', + actual: (() => { + const array = [1, 2, 3]; + array.extra = 'test'; + return array; + })(), + expected: (() => { + const array = [1, 2, 3]; + array.extra = 'different'; + return array; + })(), + }, + { + description: 'throws when comparing an array with symbol properties matching but other enumerability', + actual: (() => { + const array = [1, 2, 3]; + array[Symbol.for('abc')] = 'test'; + Object.defineProperty(array, Symbol.for('test'), { + value: 'test', + enumerable: false, + }); + array[Symbol.for('other')] = 'test'; + return array; + })(), + expected: (() => { + const array = [1, 2, 3]; + array[Symbol.for('test')] = 'test'; + return array; + })(), + }, + { + description: 'throws comparing an array with extra properties matching but other enumerability', + actual: (() => { + const array = [1, 2, 3]; + array.alsoIgnored = [{ nested: { property: true } }]; + Object.defineProperty(array, 'extra', { + value: 'test', + enumerable: false, + }); + array.ignored = 'test'; + return array; + })(), + expected: (() => { + const array = [1, 2, 3]; + array.extra = 'test'; + return array; + })(), + }, { description: 'throws when comparing an ArrayBuffer with a Uint8Array', actual: new ArrayBuffer(3), expected: new Uint8Array(3), }, + { + description: 'throws when comparing an TypedArrays with symbol properties not matching', + actual: (() => { + const typed = new Uint8Array(3); + typed[Symbol.for('test')] = 'test'; + return typed; + })(), + expected: (() => { + const typed = new Uint8Array(3); + typed[Symbol.for('test')] = 'different'; + return typed; + })(), + }, { description: 'throws when comparing a ArrayBuffer with a SharedArrayBuffer', actual: new ArrayBuffer(3), @@ -445,6 +528,44 @@ describe('Object Comparison Tests', () => { actual: [0, -0, 0], expected: [0, 0], }, + { + description: 'comparing an array with symbol properties matching', + actual: (() => { + const array = [1, 2, 3]; + array[Symbol.for('abc')] = 'test'; + array[Symbol.for('test')] = 'test'; + Object.defineProperty(array, Symbol.for('hidden'), { + value: 'hidden', + enumerable: false, + }); + return array; + })(), + expected: (() => { + const array = [1, 2, 3]; + array[Symbol.for('test')] = 'test'; + return array; + })(), + }, + { + description: 'comparing an array with extra properties matching', + actual: (() => { + const array = [1, 2, 3]; + array.alsoIgnored = [{ nested: { property: true } }]; + array.extra = 'test'; + array.ignored = 'test'; + return array; + })(), + expected: (() => { + const array = [1, 2, 3]; + array.extra = 'test'; + Object.defineProperty(array, 'ignored', { enumerable: false }); + Object.defineProperty(array, Symbol.for('hidden'), { + value: 'hidden', + enumerable: false, + }); + return array; + })(), + }, { description: 'compares two Date objects with the same time', actual: new Date(0), @@ -597,6 +718,27 @@ describe('Object Comparison Tests', () => { ['key3', new Uint8Array([1, 2, 3])], ]) }, + { + describe: 'compares two big sparse arrays', + actual: (() => { + const array = new Array(150_000_000); + array[0] = 1; + array[1] = 2; + array[100] = 100n; + array[200_000] = 3; + array[1_200_000] = 4; + array[120_200_000] = []; + return array; + })(), + expected: (() => { + const array = new Array(100_000_000); + array[0] = 1; + array[1] = 2; + array[200_000] = 3; + array[1_200_000] = 4; + return array; + })(), + }, { describe: 'compares two array of objects', actual: [{ a: 5 }], @@ -617,6 +759,16 @@ describe('Object Comparison Tests', () => { actual: new Set([{ a: 1 }, { b: 1 }]), expected: new Set([{ a: 1 }]), }, + { + description: 'compares two Sets with mixed entries', + actual: new Set([{ b: 1 }, [], 1, { a: 1 }, 2, []]), + expected: new Set([{ a: 1 }, 2, []]), + }, + { + description: 'compares two Sets with mixed entries different order', + actual: new Set([{ a: 1 }, 1, { b: 1 }, [], 2, { a: 1 }]), + expected: new Set([{ a: 1 }, [], 2, { a: 1 }]), + }, { description: 'compares two Set objects with identical arrays', actual: new Set(['value1', 'value2']), @@ -785,8 +937,8 @@ describe('Object Comparison Tests', () => { { description: 'compares one subset array with another', - actual: [1, 2, 3], - expected: [2], + actual: [1, 2, 3, 4, 5, 6, 7, 8, 9], + expected: [2, 5, 6, 7, 8], }, { description: 'ensures that File extends Blob', @@ -803,6 +955,38 @@ describe('Object Comparison Tests', () => { actual: new URL('http://foo'), expected: new URL('http://foo'), }, + { + description: 'compares a more complex object with additional parts on the actual', + actual: [{ + foo: 'yarp', + nope: { + bar: '123', + a: [ 1, 2, 0 ], + c: {}, + b: [ + { + foo: 'yarp', + nope: { bar: '123', a: [ 1, 2, 0 ], c: {}, b: [] } + }, + { + foo: 'yarp', + nope: { bar: '123', a: [ 1, 2, 1 ], c: {}, b: [] } + }, + ], + } + }], + expected: [{ + foo: 'yarp', + nope: { + bar: '123', + c: {}, + b: [ + { foo: 'yarp', nope: { bar: '123', c: {}, b: [] } }, + { foo: 'yarp', nope: { bar: '123', c: {}, b: [] } }, + ], + } + }] + }, ].forEach(({ description, actual, expected }) => { it(description, () => { assert.partialDeepStrictEqual(actual, expected); From 1d06fc3281845f391919cdb5687c7703b29f95df Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 02:52:49 +0100 Subject: [PATCH 2/9] benchmark: add assert partialDeepStrictEqual benchmark The current settings deactivate the extraProps handling, due to the current implementation failing on these cases. --- benchmark/assert/partial-deep-equal.js | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 benchmark/assert/partial-deep-equal.js diff --git a/benchmark/assert/partial-deep-equal.js b/benchmark/assert/partial-deep-equal.js new file mode 100644 index 00000000000000..eee88167525cee --- /dev/null +++ b/benchmark/assert/partial-deep-equal.js @@ -0,0 +1,150 @@ +'use strict'; + +const common = require('../common.js'); +const assert = require('assert'); + +const bench = common.createBenchmark(main, { + n: [25], + size: [500], + extraProps: [0], + datasetName: [ + 'objects', + 'sets', + 'maps', + 'circularRefs', + 'typedArrays', + 'arrayBuffers', + 'dataViewArrayBuffers', + 'array', + ], +}); + +function createArray(length, extraProps) { + if (extraProps) { + return Array.from({ length: length * 4 }, (_, i) => i); + } + return Array.from({ length }, (_, i) => i * 4); +} + +function createObjects(length, extraProps, depth = 0) { + return Array.from({ length }, (_, i) => ({ + foo: 'yarp', + nope: { + bar: '123', + ...extraProps ? { a: [1, 2, i] } : {}, + c: {}, + b: !depth ? createObjects(2, extraProps, depth + 1) : [], + }, + })); +} + +function createSets(length, extraProps, depth = 0) { + return Array.from({ length }, (_, i) => new Set([ + 'yarp', + ...extraProps ? ['123', 1, 2] : [], + i + 3, + null, + { + simple: 'object', + number: i, + }, + ['array', 'with', 'values'], + !depth ? new Set([1, 2, { nested: i }]) : new Set(), + !depth ? createSets(2, extraProps, depth + 1) : null, + ])); +} + +function createMaps(length, extraProps, depth = 0) { + return Array.from({ length }, (_, i) => new Map([ + ...extraProps ? [['primitiveKey', 'primitiveValue']] : [], + [42, 'numberKey'], + ['objectValue', { a: 1, b: i }], + ['arrayValue', [1, 2, i]], + ['nestedMap', new Map([['a', i], ['b', { deep: true }]])], + [{ objectKey: true }, 'value from object key'], + [[1, i, 3], 'value from array key'], + [!depth ? createMaps(2, extraProps, depth + 1) : null, 'recursive value' + i], + ])); +} + +function createCircularRefs(length, extraProps) { + return Array.from({ length }, (_, i) => { + const circularSet = new Set(); + const circularMap = new Map(); + const circularObj = { name: 'circular object' }; + + circularSet.add('some value' + i); + circularSet.add(circularSet); + + circularMap.set('self', circularMap); + circularMap.set('value', 'regular value'); + + circularObj.self = circularObj; + + const objA = { name: 'A' }; + const objB = { name: 'B' }; + objA.ref = objB; + objB.ref = objA; + + circularSet.add(objA); + circularMap.set('objB', objB); + + return { + circularSet, + circularMap, + ...extraProps ? { extra: i } : {}, + circularObj, + objA, + objB, + }; + }); +} + +function createTypedArrays(length, extraParts) { + const extra = extraParts ? [9, 8, 7] : []; + return Array.from({ length }, (_, i) => { + return { + uint8: new Uint8Array(new ArrayBuffer(32), 4, 4), + int16: new Int16Array([1, 2, ...extra, 3]), + uint32: new Uint32Array([i + 1, i + 2, ...extra, i + 3]), + float64: new Float64Array([1.1, 2.2, ...extra, i + 3.3]), + bigUint64: new BigUint64Array([1n, 2n, 3n]), + }; + }); +} + +function createArrayBuffers(length, extra) { + return Array.from({ length }, (_, n) => new ArrayBuffer(n + extra ? 1 : 0)); +} + +function createDataViewArrayBuffers(length, extra) { + return Array.from({ length }, (_, n) => new DataView(new ArrayBuffer(n + extra ? 1 : 0))); +} + +const datasetMappings = { + objects: createObjects, + sets: createSets, + maps: createMaps, + circularRefs: createCircularRefs, + typedArrays: createTypedArrays, + arrayBuffers: createArrayBuffers, + dataViewArrayBuffers: createDataViewArrayBuffers, + array: createArray, +}; + +function getDatasets(datasetName, size, extra) { + return { + actual: datasetMappings[datasetName](size, true), + expected: datasetMappings[datasetName](size, !extra), + }; +} + +function main({ size, n, datasetName, extraProps }) { + const { actual, expected } = getDatasets(datasetName, size, extraProps); + + bench.start(); + for (let i = 0; i < n; ++i) { + assert.partialDeepStrictEqual(actual, expected); + } + bench.end(n); +} From d1f0d4bf4ac5f1b56e2ee2aba8366416da97610f Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 15:30:53 +0100 Subject: [PATCH 3/9] benchmark: skip running some assert benchmarks by default These benchmarks are not frequently needed and just slow down the default benchmark suite. They are kept for users who want to run them but deactivated by default. --- benchmark/assert/match.js | 9 ++++++++- benchmark/assert/rejects.js | 9 ++++++++- benchmark/assert/strictequal.js | 8 +++++++- benchmark/assert/throws.js | 8 +++++++- ...sert-objects.js => test-assert-partial-deep-equal.js} | 0 5 files changed, 30 insertions(+), 4 deletions(-) rename test/parallel/{test-assert-objects.js => test-assert-partial-deep-equal.js} (100%) diff --git a/benchmark/assert/match.js b/benchmark/assert/match.js index fab86a23944c59..6d90a0d76510b8 100644 --- a/benchmark/assert/match.js +++ b/benchmark/assert/match.js @@ -4,8 +4,15 @@ const common = require('../common.js'); const assert = require('assert'); const bench = common.createBenchmark(main, { - n: [25, 2e7], + n: [2e7], method: ['match', 'doesNotMatch'], +}, { + combinationFilter() { + // These benchmarks purposefully do not run by default. They do not provide + // might insight, due to only being a small wrapper around a native regexp + // call. + return false; + }, }); function main({ n, method }) { diff --git a/benchmark/assert/rejects.js b/benchmark/assert/rejects.js index 43ec500177a625..62ac2e98a2daf6 100644 --- a/benchmark/assert/rejects.js +++ b/benchmark/assert/rejects.js @@ -4,8 +4,15 @@ const common = require('../common.js'); const assert = require('assert'); const bench = common.createBenchmark(main, { - n: [25, 2e5], + n: [2e5], method: ['rejects', 'doesNotReject'], +}, { + combinationFilter() { + // These benchmarks purposefully do not run by default. They do not provide + // much insight, due to only being a small wrapper around a native promise + // with a few extra checks. + return false; + }, }); async function main({ n, method }) { diff --git a/benchmark/assert/strictequal.js b/benchmark/assert/strictequal.js index 21a77f0472c5fc..abc1413cfa5ca5 100644 --- a/benchmark/assert/strictequal.js +++ b/benchmark/assert/strictequal.js @@ -4,9 +4,15 @@ const common = require('../common.js'); const assert = require('assert'); const bench = common.createBenchmark(main, { - n: [25, 2e5], + n: [2e5], type: ['string', 'object', 'number'], method: ['strictEqual', 'notStrictEqual'], +}, { + combinationFilter() { + // These benchmarks purposefully do not run by default. They do not provide + // much insight, due to only being a small wrapper around `Object.is()`. + return false; + }, }); function main({ type, n, method }) { diff --git a/benchmark/assert/throws.js b/benchmark/assert/throws.js index 9c070ac8281551..58b675929c5aa0 100644 --- a/benchmark/assert/throws.js +++ b/benchmark/assert/throws.js @@ -4,8 +4,14 @@ const common = require('../common.js'); const assert = require('assert'); const bench = common.createBenchmark(main, { - n: [25, 2e5], + n: [2e5], method: ['throws', 'doesNotThrow'], +}, { + combinationFilter() { + // These benchmarks purposefully do not run by default. They do not provide + // much insight, due to only being a small wrapper around a try / catch. + return false; + }, }); function main({ n, method }) { diff --git a/test/parallel/test-assert-objects.js b/test/parallel/test-assert-partial-deep-equal.js similarity index 100% rename from test/parallel/test-assert-objects.js rename to test/parallel/test-assert-partial-deep-equal.js From 0d38ac1f575468a0df304f8c206a2ff107a4b817 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 16:24:40 +0100 Subject: [PATCH 4/9] fixup! assert: improve partialDeepStrictEqual This fixes holey array handling. These were a bit more tricky with the partial implementation. It also adds multiple tests and makes sure the implementation always checks for being a drop-in implementation for assert.deepStrictEqual() --- doc/api/assert.md | 1 + lib/internal/util/comparisons.js | 23 ++-- test/parallel/test-assert-deep.js | 26 +++- .../test-assert-partial-deep-equal.js | 118 ++++++++++++++++++ 4 files changed, 155 insertions(+), 13 deletions(-) diff --git a/doc/api/assert.md b/doc/api/assert.md index f59b3f0b4bbacb..6201243af8516f 100644 --- a/doc/api/assert.md +++ b/doc/api/assert.md @@ -2630,6 +2630,7 @@ behaving as a super set of it. even if they contain the same entries. * {RegExp} lastIndex, flags, and source are always compared, even if these are not enumerable properties. +* Holes in sparse arrays are ignored. ```mjs import assert from 'node:assert'; diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 59dd145924c73b..3c69b2763f7774 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -695,12 +695,10 @@ function mapEquiv(a, b, mode, memo) { return true; } -function partialSparseArrayEquiv(a, b, mode, memos) { +function partialSparseArrayEquiv(a, b, mode, memos, startA, startB) { let aPos = 0; - // The comparison has to start from the beginning again, due to not knowing - // what entries where skipped before. - const keysA = ObjectKeys(a); - const keysB = ObjectKeys(b); + const keysA = ObjectKeys(a).slice(startA); + const keysB = ObjectKeys(b).slice(startB); if (keysA.length < keysB.length) { return false; } @@ -720,17 +718,20 @@ function partialSparseArrayEquiv(a, b, mode, memos) { function partialArrayEquiv(a, b, mode, memos) { let aPos = 0; for (let i = 0; i < b.length; i++) { - const isOwnProperty = ObjectPrototypeHasOwnProperty(b, i); - if (!isOwnProperty) { - return partialSparseArrayEquiv(a, b, mode, memos); + let isSparse = b[i] === undefined && !ObjectPrototypeHasOwnProperty(b, i); + if (isSparse) { + return partialSparseArrayEquiv(a, b, mode, memos, aPos, i); } - while (!innerDeepEqual(a[aPos], b[i], mode, memos) || - ObjectPrototypeHasOwnProperty(a, aPos) !== isOwnProperty) { + while (!(isSparse = a[aPos] === undefined && !ObjectPrototypeHasOwnProperty(a, aPos)) && + !innerDeepEqual(a[aPos], b[i], mode, memos)) { aPos++; if (aPos > a.length - b.length + i) { return false; } } + if (isSparse) { + return partialSparseArrayEquiv(a, b, mode, memos, aPos, i); + } aPos++; } return true; @@ -760,6 +761,8 @@ function objEquiv(a, b, mode, keys2, memos, iterationType) { } if (!isOwnProperty) { // Array is sparse. + // TODO(BridgeAR): Use internal method to only get index properties. The + // same applies to the partial implementation. const keysA = ObjectKeys(a); for (; i < keysA.length; i++) { const key = keysA[i]; diff --git a/test/parallel/test-assert-deep.js b/test/parallel/test-assert-deep.js index 93cc248160e6a1..922ec8a811c817 100644 --- a/test/parallel/test-assert-deep.js +++ b/test/parallel/test-assert-deep.js @@ -198,12 +198,14 @@ test('deepEqual should pass for these weird cases', () => { function assertDeepAndStrictEqual(a, b) { assert.deepEqual(a, b); assert.deepStrictEqual(a, b); + assert.partialDeepStrictEqual(a, b); assert.deepEqual(b, a); assert.deepStrictEqual(b, a); + assert.partialDeepStrictEqual(b, a); } -function assertNotDeepOrStrict(a, b, err) { +function assertNotDeepOrStrict(a, b, err, options) { assert.throws( () => assert.deepEqual(a, b), err || re`${a}\n\nshould loosely deep-equal\n\n${b}` @@ -221,6 +223,15 @@ function assertNotDeepOrStrict(a, b, err) { () => assert.deepStrictEqual(b, a), err || { code: 'ERR_ASSERTION' } ); + const partial = () => { + assert.partialDeepStrictEqual(b, a); + assert.partialDeepStrictEqual(a, b); + } + if (options?.partial === 'pass') { + partial(); + } else { + assert.throws(partial, err || { code: 'ERR_ASSERTION' }); + } } function assertOnlyDeepEqual(a, b, err) { @@ -598,16 +609,17 @@ test('Handle sparse arrays', () => { /* eslint-disable no-sparse-arrays */ assertDeepAndStrictEqual([1, , , 3], [1, , , 3]); assertNotDeepOrStrict([1, , , 3], [1, , , 3, , , ]); + assertNotDeepOrStrict([1, , , 3], [1, undefined, , 3]); /* eslint-enable no-sparse-arrays */ const a = new Array(3); const b = new Array(3); a[2] = true; b[1] = true; - assertNotDeepOrStrict(a, b); + assertNotDeepOrStrict(a, b, AssertionError, { partial: "pass" }); b[2] = true; assertNotDeepOrStrict(a, b); a[0] = true; - assertNotDeepOrStrict(a, b); + assertNotDeepOrStrict(a, b, AssertionError, { partial: "pass" }); }); test('Handle different error messages', () => { @@ -1246,6 +1258,14 @@ test('Verify object types being identical on both sides', () => { }); assertNotDeepOrStrict(a, b); + a = new ArrayBuffer(3); + b = new Uint8Array(3); + Object.setPrototypeOf(b, ArrayBuffer.prototype); + Object.defineProperty(b, Symbol.toStringTag, { + value: 'ArrayBuffer' + }); + assertNotDeepOrStrict(a, b); + a = new Date(2000); b = Object.create( Object.getPrototypeOf(a), diff --git a/test/parallel/test-assert-partial-deep-equal.js b/test/parallel/test-assert-partial-deep-equal.js index d1307e9a5b3604..913e615d62fc54 100644 --- a/test/parallel/test-assert-partial-deep-equal.js +++ b/test/parallel/test-assert-partial-deep-equal.js @@ -233,6 +233,46 @@ describe('Object Comparison Tests', () => { ['key1', ['value3']], ]), }, + { + description: 'throws for maps with object keys and different values', + actual: new Map([ + [{ a: 1 }, 'value1'], + [{ b: 2 }, 'value2'], + [{ b: 2 }, 'value4'], + ]), + expected: new Map([ + [{ a: 1 }, 'value1'], + [{ b: 2 }, 'value3'], + ]), + }, + { + description: 'throws for maps with multiple identical object keys, just not enough', + actual: new Map([ + [{ a: 1 }, 'value1'], + [{ b: 1 }, 'value2'], + [{ a: 1 }, 'value1'], + ]), + expected: new Map([ + [{ a: 1 }, 'value1'], + [{ a: 1 }, 'value1'], + [{ a: 1 }, 'value1'], + ]), + }, + { + description: 'throws for sets with different object values', + actual: new Set([ + { a: 1 }, + { a: 2 }, + { a: 1 }, + { a: 2 }, + ]), + expected: new Set([ + { a: 1 }, + { a: 2 }, + { a: 1 }, + { a: 1 }, + ]), + }, { description: 'throws when comparing two TypedArray instances with different content', @@ -295,6 +335,44 @@ describe('Object Comparison Tests', () => { return array; })(), }, + { + description: 'throws when comparing a non matching sparse array', + actual: (() => { + const array = new Array(1000); + array[90] = 1; + array[92] = 2; + array[95] = 1; + array[96] = 2; + array.foo = 'bar'; + array.extra = 'test'; + return array; + })(), + expected: (() => { + const array = new Array(1000); + array[90] = 1; + array[92] = 1; + array[95] = 1; + array.extra = 'test'; + array.foo = 'bar'; + return array; + })(), + }, + { + description: 'throws when comparing a same length sparse array with actual less keys', + actual: (() => { + const array = new Array(1000); + array[90] = 1; + array[92] = 1; + return array; + })(), + expected: (() => { + const array = new Array(1000); + array[90] = 1; + array[92] = 1; + array[95] = 1; + return array; + })(), + }, { description: 'throws when comparing an array with symbol properties matching but other enumerability', actual: (() => { @@ -718,6 +796,41 @@ describe('Object Comparison Tests', () => { ['key3', new Uint8Array([1, 2, 3])], ]) }, + { + description: 'compares maps with object keys', + actual: new Map([ + [{ a: 1 }, 'value1'], + [{ a: 2 }, 'value2'], + [{ a: 2 }, 'value3'], + [{ a: 2 }, 'value3'], + [{ a: 2 }, 'value4'], + [{ a: 1 }, 'value2'], + ]), + expected: new Map([ + [{ a: 2 }, 'value3'], + [{ a: 1 }, 'value1'], + [{ a: 2 }, 'value3'], + [{ a: 1 }, 'value2'], + ]), + }, + { + describe: 'compares two simple sparse arrays', + actual: new Array(1_000), + expected: new Array(100), + }, + { + describe: 'compares two identical sparse arrays', + actual: (() => { + const array = new Array(100); + array[1] = 2; + return array; + })(), + expected: (() => { + const array = new Array(100); + array[1] = 2; + return array; + })(), + }, { describe: 'compares two big sparse arrays', actual: (() => { @@ -769,6 +882,11 @@ describe('Object Comparison Tests', () => { actual: new Set([{ a: 1 }, 1, { b: 1 }, [], 2, { a: 1 }]), expected: new Set([{ a: 1 }, [], 2, { a: 1 }]), }, + { + description: 'compares two Sets with mixed entries different order 2', + actual: new Set([{ a: 1 }, { a: 1 }, 1, { b: 1 }, [], 2, { a: 1 }]), + expected: new Set([{ a: 1 }, [], 2, { a: 1 }]), + }, { description: 'compares two Set objects with identical arrays', actual: new Set(['value1', 'value2']), From cffebc368bc5d81d3f4db8e6ab7b927fae901815 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 18:01:04 +0100 Subject: [PATCH 5/9] benchmark: adjust assert runtimes Each file should have a reasonable runtime while having a good accuracy. This adjust those up and down to have minimal runtimes with a good accuracy. --- benchmark/assert/deepequal-map.js | 2 +- benchmark/assert/deepequal-object.js | 4 ++-- benchmark/assert/deepequal-set.js | 2 +- benchmark/assert/deepequal-simple-array-and-set.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/benchmark/assert/deepequal-map.js b/benchmark/assert/deepequal-map.js index fb3f7cd316028f..4f651551c58c82 100644 --- a/benchmark/assert/deepequal-map.js +++ b/benchmark/assert/deepequal-map.js @@ -5,7 +5,7 @@ const { deepEqual, deepStrictEqual, notDeepEqual, notDeepStrictEqual } = require('assert'); const bench = common.createBenchmark(main, { - n: [5e3], + n: [2e3], len: [5e2], strict: [0, 1], method: [ diff --git a/benchmark/assert/deepequal-object.js b/benchmark/assert/deepequal-object.js index c480faf10cbae8..e1d1baf838d9c6 100644 --- a/benchmark/assert/deepequal-object.js +++ b/benchmark/assert/deepequal-object.js @@ -4,12 +4,12 @@ const common = require('../common.js'); const assert = require('assert'); const bench = common.createBenchmark(main, { - n: [25, 2e2], + n: [50, 2e2], size: [1e2, 1e4], method: ['deepEqual', 'notDeepEqual', 'deepStrictEqual', 'notDeepStrictEqual'], }, { combinationFilter: (p) => { - return p.size === 1e4 && p.n === 25 || + return p.size === 1e4 && p.n === 50 || p.size === 1e3 && p.n === 2e2 || p.size === 1e2 && p.n === 2e3 || p.size === 1; diff --git a/benchmark/assert/deepequal-set.js b/benchmark/assert/deepequal-set.js index 27ca7c92bce1b0..e771c81928a897 100644 --- a/benchmark/assert/deepequal-set.js +++ b/benchmark/assert/deepequal-set.js @@ -5,7 +5,7 @@ const { deepEqual, deepStrictEqual, notDeepEqual, notDeepStrictEqual } = require('assert'); const bench = common.createBenchmark(main, { - n: [5e2], + n: [1e3], len: [5e2], strict: [0, 1], method: [ diff --git a/benchmark/assert/deepequal-simple-array-and-set.js b/benchmark/assert/deepequal-simple-array-and-set.js index a1f6820696d7b8..08bbc87a1c5b1c 100644 --- a/benchmark/assert/deepequal-simple-array-and-set.js +++ b/benchmark/assert/deepequal-simple-array-and-set.js @@ -5,7 +5,7 @@ const { deepEqual, deepStrictEqual, notDeepEqual, notDeepStrictEqual } = require('assert'); const bench = common.createBenchmark(main, { - n: [5e2], + n: [1e3], len: [1e4], strict: [1], method: [ From 7e3262edd59e66161cff55d5b21a85efbd555533 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 18:02:42 +0100 Subject: [PATCH 6/9] assert,util: improve performance This improves the performance for array comparison by making the sparse array detection simpler. On top of that it adds a fast path for sets and maps that only contain objects as key. --- lib/internal/util/comparisons.js | 129 ++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 46 deletions(-) diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 3c69b2763f7774..67307d722bf372 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -559,6 +559,35 @@ function partialObjectSetEquiv(a, b, mode, set, memo) { } } +function setObjectEquiv(a, b, mode, set, memo) { + if (mode === kPartial) { + return partialObjectSetEquiv(a, b, mode, set, memo); + } + // Fast path for objects only + if (mode === kStrict && set.size === a.size) { + for (const val of a) { + if (!setHasEqualElement(set, val, mode, memo)) { + return false; + } + } + return true; + } + + for (const val of a) { + // Primitive values have already been handled above. + if (typeof val === 'object') { + if (!b.has(val) && !setHasEqualElement(set, val, mode, memo)) { + return false; + } + } else if (mode === kLoose && + !b.has(val) && + !setHasEqualElement(set, val, mode, memo)) { + return false; + } + } + return set.size === 0; +} + function setEquiv(a, b, mode, memo) { // This is a lazily initiated Set of entries which have to be compared // pairwise. @@ -584,22 +613,7 @@ function setEquiv(a, b, mode, memo) { } if (set !== null) { - if (mode === kPartial) { - return partialObjectSetEquiv(a, b, mode, set, memo); - } - for (const val of a) { - // Primitive values have already been handled above. - if (typeof val === 'object' && val !== null) { - if (!b.has(val) && !setHasEqualElement(set, val, mode, memo)) { - return false; - } - } else if (mode === kLoose && - !b.has(val) && - !setHasEqualElement(set, val, mode, memo)) { - return false; - } - } - return set.size === 0; + return setObjectEquiv(a, b, mode, set, memo); } return true; @@ -640,6 +654,35 @@ function partialObjectMapEquiv(a, b, mode, set, memo) { } } +function mapObjectEquivalence(a, b, mode, set, memo) { + if (mode === kPartial) { + return partialObjectMapEquiv(a, b, mode, set, memo); + } + // Fast path for objects only + if (mode === kStrict && set.size === a.size) { + for (const { 0: key1, 1: item1 } of a) { + if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) { + return false; + } + } + return true; + } + for (const { 0: key1, 1: item1 } of a) { + if (typeof key1 === 'object' && key1 !== null) { + if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) + return false; + } else if (set.size === 0) { + return true; + } else if (mode === kLoose && + (!b.has(key1) || + !innerDeepEqual(item1, b.get(key1), mode, memo)) && + !mapHasEqualEntry(set, b, key1, item1, mode, memo)) { + return false; + } + } + return set.size === 0; +} + function mapEquiv(a, b, mode, memo) { let set = null; @@ -675,21 +718,7 @@ function mapEquiv(a, b, mode, memo) { } if (set !== null) { - if (mode === kPartial) { - return partialObjectMapEquiv(a, b, mode, set, memo); - } - for (const { 0: key1, 1: item1 } of a) { - if (typeof key1 === 'object' && key1 !== null) { - if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) - return false; - } else if (mode === kLoose && - (!b.has(key1) || - !innerDeepEqual(item1, b.get(key1), mode, memo)) && - !mapHasEqualEntry(set, b, key1, item1, mode, memo)) { - return false; - } - } - return set.size === 0; + return mapObjectEquivalence(a, b, mode, set, memo); } return true; @@ -737,6 +766,24 @@ function partialArrayEquiv(a, b, mode, memos) { return true; } +function sparseArrayEquiv(a, b, mode, memos, i) { + // TODO(BridgeAR): Use internal method to only get index properties. The + // same applies to the partial implementation. + const keysA = ObjectKeys(a); + const keysB = ObjectKeys(b); + if (keysA.length !== keysB.length) { + return false; + } + for (; i < keysA.length; i++) { + const key = keysA[i]; + if (!ObjectPrototypeHasOwnProperty(b, key) || + !innerDeepEqual(a[key], b[key], mode, memos)) { + return false; + } + } + return true; +} + function objEquiv(a, b, mode, keys2, memos, iterationType) { // The pair must have equivalent values for every corresponding key. if (keys2.length > 0) { @@ -755,23 +802,13 @@ function objEquiv(a, b, mode, keys2, memos, iterationType) { if (!innerDeepEqual(a[i], b[i], mode, memos)) { return false; } - const isOwnProperty = ObjectPrototypeHasOwnProperty(a, i); - if (isOwnProperty !== ObjectPrototypeHasOwnProperty(b, i)) { + const isSparseA = a[i] === undefined && !ObjectPrototypeHasOwnProperty(a, i); + const isSparseB = b[i] === undefined && !ObjectPrototypeHasOwnProperty(b, i); + if (isSparseA !== isSparseB) { return false; } - if (!isOwnProperty) { - // Array is sparse. - // TODO(BridgeAR): Use internal method to only get index properties. The - // same applies to the partial implementation. - const keysA = ObjectKeys(a); - for (; i < keysA.length; i++) { - const key = keysA[i]; - if (!ObjectPrototypeHasOwnProperty(b, key) || - !innerDeepEqual(a[key], b[key], mode, memos)) { - return false; - } - } - return keysA.length === ObjectKeys(b).length; + if (isSparseA) { + return sparseArrayEquiv(a, b, mode, memos, i); } } } else if (iterationType === kIsSet) { From 6d35bb9d431644ac7fe762d502870969a299fa4b Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 18:15:43 +0100 Subject: [PATCH 7/9] fixup! assert: improve partialDeepStrictEqual --- test/parallel/test-assert-deep.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/parallel/test-assert-deep.js b/test/parallel/test-assert-deep.js index 922ec8a811c817..03de8fafe25676 100644 --- a/test/parallel/test-assert-deep.js +++ b/test/parallel/test-assert-deep.js @@ -226,7 +226,7 @@ function assertNotDeepOrStrict(a, b, err, options) { const partial = () => { assert.partialDeepStrictEqual(b, a); assert.partialDeepStrictEqual(a, b); - } + }; if (options?.partial === 'pass') { partial(); } else { @@ -615,11 +615,11 @@ test('Handle sparse arrays', () => { const b = new Array(3); a[2] = true; b[1] = true; - assertNotDeepOrStrict(a, b, AssertionError, { partial: "pass" }); + assertNotDeepOrStrict(a, b, AssertionError, { partial: 'pass' }); b[2] = true; assertNotDeepOrStrict(a, b); a[0] = true; - assertNotDeepOrStrict(a, b, AssertionError, { partial: "pass" }); + assertNotDeepOrStrict(a, b, AssertionError, { partial: 'pass' }); }); test('Handle different error messages', () => { From 0d414db5d53ef4759b39afb95113d609cf40bdac Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 18:59:56 +0100 Subject: [PATCH 8/9] fixup! benchmark: adjust assert runtimes --- benchmark/assert/deepequal-typedarrays.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/benchmark/assert/deepequal-typedarrays.js b/benchmark/assert/deepequal-typedarrays.js index 86826d6588ef86..5684cd520d258b 100644 --- a/benchmark/assert/deepequal-typedarrays.js +++ b/benchmark/assert/deepequal-typedarrays.js @@ -16,6 +16,12 @@ const bench = common.createBenchmark(main, { 'notDeepEqual', ], len: [1e2, 5e3], +}, { + combinationFilter(p) { + return p.strict === 1 || + p.type !== 'Float32Array' || + p.len === 1e2; + }, }); function main({ type, n, len, method, strict }) { From bc255c47ca15cf2137a33b0e856c5202e33d9996 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 8 Mar 2025 19:00:44 +0100 Subject: [PATCH 9/9] fixup! benchmark: skip running some assert benchmarks by default --- benchmark/assert/match.js | 4 ++-- benchmark/assert/rejects.js | 4 ++-- benchmark/assert/strictequal.js | 4 ++-- benchmark/assert/throws.js | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/benchmark/assert/match.js b/benchmark/assert/match.js index 6d90a0d76510b8..5ad9292c4b012b 100644 --- a/benchmark/assert/match.js +++ b/benchmark/assert/match.js @@ -7,11 +7,11 @@ const bench = common.createBenchmark(main, { n: [2e7], method: ['match', 'doesNotMatch'], }, { - combinationFilter() { + combinationFilter(p) { // These benchmarks purposefully do not run by default. They do not provide // might insight, due to only being a small wrapper around a native regexp // call. - return false; + return p.n === 1; }, }); diff --git a/benchmark/assert/rejects.js b/benchmark/assert/rejects.js index 62ac2e98a2daf6..d8a6d6f4bb8058 100644 --- a/benchmark/assert/rejects.js +++ b/benchmark/assert/rejects.js @@ -7,11 +7,11 @@ const bench = common.createBenchmark(main, { n: [2e5], method: ['rejects', 'doesNotReject'], }, { - combinationFilter() { + combinationFilter(p) { // These benchmarks purposefully do not run by default. They do not provide // much insight, due to only being a small wrapper around a native promise // with a few extra checks. - return false; + return p.n === 1; }, }); diff --git a/benchmark/assert/strictequal.js b/benchmark/assert/strictequal.js index abc1413cfa5ca5..fef74ffb1ecb5b 100644 --- a/benchmark/assert/strictequal.js +++ b/benchmark/assert/strictequal.js @@ -8,10 +8,10 @@ const bench = common.createBenchmark(main, { type: ['string', 'object', 'number'], method: ['strictEqual', 'notStrictEqual'], }, { - combinationFilter() { + combinationFilter(p) { // These benchmarks purposefully do not run by default. They do not provide // much insight, due to only being a small wrapper around `Object.is()`. - return false; + return p.n === 1; }, }); diff --git a/benchmark/assert/throws.js b/benchmark/assert/throws.js index 58b675929c5aa0..df2fdf2dbf0e07 100644 --- a/benchmark/assert/throws.js +++ b/benchmark/assert/throws.js @@ -7,10 +7,10 @@ const bench = common.createBenchmark(main, { n: [2e5], method: ['throws', 'doesNotThrow'], }, { - combinationFilter() { + combinationFilter(p) { // These benchmarks purposefully do not run by default. They do not provide // much insight, due to only being a small wrapper around a try / catch. - return false; + return p.n === 1; }, });