diff --git a/doc/api/assert.md b/doc/api/assert.md index 3d070db468f..2cb7cb8313b 100644 --- a/doc/api/assert.md +++ b/doc/api/assert.md @@ -2548,6 +2548,96 @@ assert.throws(throwingFirst, /Second$/); Due to the confusing error-prone notation, avoid a string as the second argument. +## `assert.partialDeepStrictEqual(actual, expected[, message])` + + + +> Stability: 1.0 - Early development + +* `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'; + +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 }); +// OK + +assert.partialDeepStrictEqual(new Set(['value1', 'value2']), new Set(['value1', 'value2'])); +// OK + +assert.partialDeepStrictEqual(new Map([['key1', 'value1']]), new Map([['key1', 'value1']])); +// OK + +assert.partialDeepStrictEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3])); +// OK + +assert.partialDeepStrictEqual(/abc/, /abc/); +// OK + +assert.partialDeepStrictEqual([{ a: 5 }, { b: 5 }], [{ a: 5 }]); +// OK + +assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 1 }]), new Set([{ a: 1 }])); +// OK + +assert.partialDeepStrictEqual(new Date(0), new Date(0)); +// OK + +assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 }); +// AssertionError + +assert.partialDeepStrictEqual({ a: 1, b: '2' }, { a: 1, b: 2 }); +// AssertionError + +assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } }); +// AssertionError +``` + +```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 }); +// OK + +assert.partialDeepStrictEqual([{ a: 5 }, { b: 5 }], [{ a: 5 }]); +// OK + +assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 1 }]), new Set([{ a: 1 }])); +// OK + +assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 }); +// AssertionError + +assert.partialDeepStrictEqual({ a: 1, b: '2' }, { a: 1, b: 2 }); +// AssertionError + +assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } }); +// AssertionError +``` + [Object wrappers]: https://developer.mozilla.org/en-US/docs/Glossary/Primitive#Primitive_wrapper_objects_in_JavaScript [Object.prototype.toString()]: https://tc39.github.io/ecma262/#sec-object.prototype.tostring [`!=` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality @@ -2576,6 +2666,7 @@ argument. [`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 2a65457b393..cef7c16f809 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -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) { diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index abb8ed276df..34e19009799 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -119,6 +119,7 @@ function lazyAssertObject(harness) { 'notDeepStrictEqual', 'notEqual', 'notStrictEqual', + 'partialDeepStrictEqual', 'rejects', 'strictEqual', 'throws', diff --git a/test/parallel/test-assert-objects.js b/test/parallel/test-assert-objects.js new file mode 100644 index 00000000000..d1c8bb854ba --- /dev/null +++ b/test/parallel/test-assert-objects.js @@ -0,0 +1,501 @@ +'use strict'; + +const common = require('../common'); +const vm = require('node:vm'); +const assert = require('node:assert'); +const { describe, it } = require('node:test'); + +function createCircularObject() { + const obj = {}; + obj.self = obj; + return obj; +} + +function createDeepNestedObject() { + return { level1: { level2: { level3: 'deepValue' } } }; +} + +async function generateCryptoKey() { + const { KeyObject } = require('node:crypto'); + const { subtle } = globalThis.crypto; + + const cryptoKey = await subtle.generateKey( + { + name: 'HMAC', + hash: 'SHA-256', + length: 256, + }, + true, + ['sign', 'verify'] + ); + + const keyObject = KeyObject.from(cryptoKey); + + return { cryptoKey, keyObject }; +} + +describe('Object Comparison Tests', () => { + describe('partialDeepStrictEqual', () => { + describe('throws an error', () => { + const tests = [ + { + description: 'throws when only one argument is provided', + actual: { a: 1 }, + expected: undefined, + }, + { + description: 'throws when expected has more properties than actual', + actual: [1, 'two'], + expected: [1, 'two', true], + }, + { + description: 'throws because expected has seven 2 while actual has six one', + actual: [1, 2, 2, 2, 2, 2, 2, 3], + expected: [1, 2, 2, 2, 2, 2, 2, 2], + }, + { + description: 'throws when comparing two different sets with objects', + actual: new Set([{ a: 1 }]), + expected: new Set([{ a: 1 }, { b: 1 }]), + }, + + { + description: 'throws when comparing two WeakSet objects', + actual: new WeakSet(), + expected: new WeakSet(), + }, + { + description: 'throws when comparing two WeakMap objects', + actual: new WeakMap(), + expected: new WeakMap(), + }, + { + description: 'throws when comparing two different objects', + actual: { a: 1, b: 'string' }, + expected: { a: 2, b: 'string' }, + }, + { + description: + 'throws when comparing two objects with different nested objects', + actual: createDeepNestedObject(), + expected: { level1: { level2: { level3: 'differentValue' } } }, + }, + { + description: + 'throws when comparing two objects with different RegExp properties', + actual: { pattern: /abc/ }, + expected: { pattern: /def/ }, + }, + { + description: + 'throws when comparing two arrays with different elements', + actual: [1, 'two', true], + expected: [1, 'two', false], + }, + { + description: + 'throws when comparing two Date objects with different times', + actual: new Date(0), + expected: new Date(1), + }, + { + description: + 'throws when comparing two objects with different large number of properties', + actual: Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [`key${i}`, i]) + ), + expected: Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [`key${i}`, i + 1]) + ), + }, + { + description: + 'throws when comparing two objects with different Symbols', + actual: { [Symbol('test')]: 'symbol' }, + expected: { [Symbol('test')]: 'symbol' }, + }, + { + description: + 'throws when comparing two objects with different array properties', + actual: { a: [1, 2, 3] }, + expected: { a: [1, 2, 4] }, + }, + { + description: + 'throws when comparing two objects with different function properties', + actual: { fn: () => {} }, + expected: { fn: () => {} }, + }, + { + description: + 'throws when comparing two objects with different Error instances', + actual: { error: new Error('Test error 1') }, + expected: { error: new Error('Test error 2') }, + }, + { + description: + 'throws when comparing two objects with different TypedArray instances and content', + actual: { typedArray: new Uint8Array([1, 2, 3]) }, + expected: { typedArray: new Uint8Array([4, 5, 6]) }, + }, + { + description: + 'throws when comparing two Map objects with different entries', + actual: new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]), + expected: new Map([ + ['key1', 'value1'], + ['key3', 'value3'], + ]), + }, + { + description: + 'throws when comparing two Map objects with different keys', + actual: new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]), + expected: new Map([ + ['key1', 'value1'], + ['key3', 'value2'], + ]), + }, + { + description: + 'throws when comparing two Map objects with different length', + actual: new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]), + expected: new Map([['key1', 'value1']]), + }, + { + description: + 'throws when comparing two TypedArray instances with different content', + actual: new Uint8Array(10), + expected: () => { + const typedArray2 = new Int8Array(10); + Object.defineProperty(typedArray2, Symbol.toStringTag, { + value: 'Uint8Array' + }); + Object.setPrototypeOf(typedArray2, Uint8Array.prototype); + + return typedArray2; + }, + }, + { + description: + 'throws when comparing two Set objects from different realms with different values', + actual: new vm.runInNewContext('new Set(["value1", "value2"])'), + expected: new Set(['value1', 'value3']), + }, + { + description: + 'throws when comparing two Set objects with different values', + actual: new Set(['value1', 'value2']), + expected: new Set(['value1', 'value3']), + }, + { + description: 'throws when comparing one subset object with another', + actual: { a: 1, b: 2, c: 3 }, + expected: { b: '2' }, + }, + { + description: 'throws when comparing one subset array with another', + actual: [1, 2, 3], + expected: ['2'], + }, + ]; + + if (common.hasCrypto) { + tests.push({ + description: + 'throws when comparing two objects with different CryptoKey instances objects', + actual: async () => { + return generateCryptoKey(); + }, + expected: async () => { + return generateCryptoKey(); + }, + }); + + const { createSecretKey } = require('node:crypto'); + + tests.push({ + description: + 'throws when comparing two objects with different KeyObject instances objects', + actual: createSecretKey(Buffer.alloc(1, 0)), + expected: createSecretKey(Buffer.alloc(1, 1)), + }); + } + + tests.forEach(({ description, actual, expected }) => { + it(description, () => { + assert.throws(() => assert.partialDeepStrictEqual(actual, expected), Error); + }); + }); + }); + }); + + describe('does not throw an error', () => { + const sym = Symbol('test'); + const func = () => {}; + + [ + { + description: 'compares two identical simple objects', + actual: { a: 1, b: 'string' }, + expected: { a: 1, b: 'string' }, + }, + { + description: 'compares two objects with different property order', + actual: { a: 1, b: 'string' }, + expected: { b: 'string', a: 1 }, + }, + { + description: 'compares two deeply nested objects with partial equality', + actual: { a: { nested: { property: true, some: 'other' } } }, + expected: { a: { nested: { property: true } } }, + }, + { + description: + 'compares plain objects from different realms', + actual: vm.runInNewContext(`({ + a: 1, + b: 2n, + c: "3", + d: /4/, + e: new Set([5]), + f: [6], + g: new Uint8Array() + })`), + expected: { b: 2n, e: new Set([5]), f: [6], g: new Uint8Array() }, + }, + { + description: 'compares two integers', + actual: 1, + expected: 1, + }, + { + description: 'compares two strings', + actual: '1', + expected: '1', + }, + { + description: 'compares two objects with nested objects', + actual: createDeepNestedObject(), + expected: createDeepNestedObject(), + }, + { + description: 'compares two objects with circular references', + actual: createCircularObject(), + expected: createCircularObject(), + }, + { + description: 'compares two arrays with identical elements', + actual: [1, 'two', true], + expected: [1, 'two', true], + }, + { + description: 'compares two Date objects with the same time', + actual: new Date(0), + expected: new Date(0), + }, + { + description: 'compares two objects with large number of properties', + actual: Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [`key${i}`, i]) + ), + expected: Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [`key${i}`, i]) + ), + }, + { + description: 'compares two objects with Symbol properties', + actual: { [sym]: 'symbol' }, + expected: { [sym]: 'symbol' }, + }, + { + description: 'compares two objects with RegExp properties', + actual: { pattern: /abc/ }, + expected: { pattern: /abc/ }, + }, + { + description: 'compares two objects with identical function properties', + actual: { fn: func }, + expected: { fn: func }, + }, + { + description: 'compares two objects with mixed types of properties', + actual: { num: 1, str: 'test', bool: true, sym }, + expected: { num: 1, str: 'test', bool: true, sym }, + }, + { + description: 'compares two objects with Buffers', + actual: { buf: Buffer.from('Node.js') }, + expected: { buf: Buffer.from('Node.js') }, + }, + { + description: 'compares two objects with identical Error properties', + actual: { error: new Error('Test error') }, + expected: { error: new Error('Test error') }, + }, + { + description: 'compares two objects with TypedArray instances with the same content', + actual: { typedArray: new Uint8Array([1, 2, 3]) }, + expected: { typedArray: new Uint8Array([1, 2, 3]) }, + }, + { + description: 'compares two Map objects with identical entries', + actual: new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]), + expected: new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]), + }, + { + describe: 'compares two array of objects', + actual: [{ a: 5 }], + expected: [{ a: 5 }], + }, + { + describe: 'compares two array of objects where expected is a subset of actual', + actual: [{ a: 5 }, { b: 5 }], + expected: [{ a: 5 }], + }, + { + description: 'compares two Set objects with identical objects', + actual: new Set([{ a: 1 }]), + expected: new Set([{ a: 1 }]), + }, + { + description: 'compares two Set objects where expected is a subset of actual', + actual: new Set([{ a: 1 }, { b: 1 }]), + expected: new Set([{ a: 1 }]), + }, + { + description: 'compares two Set objects with identical arrays', + actual: new Set(['value1', 'value2']), + expected: new Set(['value1', 'value2']), + }, + { + description: 'compares two Set objects', + actual: new Set(['value1', 'value2', 'value3']), + expected: new Set(['value1', 'value2']), + }, + { + description: + 'compares two Map objects from different realms with identical entries', + actual: new vm.runInNewContext( + 'new Map([["key1", "value1"], ["key2", "value2"]])' + ), + expected: new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]), + }, + { + description: + 'compares two objects with identical getter/setter properties', + actual: (() => { + let value = 'test'; + return Object.defineProperty({}, 'prop', { + get: () => value, + set: (newValue) => { + value = newValue; + }, + enumerable: true, + configurable: true, + }); + })(), + expected: (() => { + let value = 'test'; + return Object.defineProperty({}, 'prop', { + get: () => value, + set: (newValue) => { + value = newValue; + }, + enumerable: true, + configurable: true, + }); + })(), + }, + { + description: 'compares two objects with no prototype', + actual: { __proto__: null, prop: 'value' }, + expected: { __proto__: null, prop: 'value' }, + }, + { + description: + 'compares two objects with identical non-enumerable properties', + actual: (() => { + const obj = {}; + Object.defineProperty(obj, 'hidden', { + value: 'secret', + enumerable: false, + }); + return obj; + })(), + expected: (() => { + const obj = {}; + Object.defineProperty(obj, 'hidden', { + value: 'secret', + enumerable: false, + }); + return obj; + })(), + }, + { + description: 'compares two identical primitives, string', + actual: 'foo', + expected: 'foo', + }, + { + description: 'compares two identical primitives, number', + actual: 1, + expected: 1, + }, + { + description: 'compares two identical primitives, boolean', + actual: false, + expected: false, + }, + { + description: 'compares two identical primitives, null', + actual: null, + expected: null, + }, + { + description: 'compares two identical primitives, undefined', + actual: undefined, + expected: undefined, + }, + { + description: 'compares two identical primitives, Symbol', + actual: sym, + expected: sym, + }, + { + description: + 'compares one subset object with another', + actual: { a: 1, b: 2, c: 3 }, + expected: { b: 2 }, + }, + { + description: + 'compares one subset array with another', + actual: [1, 2, 3], + expected: [2], + }, + ].forEach(({ description, actual, expected }) => { + it(description, () => { + assert.partialDeepStrictEqual(actual, expected); + }); + }); + }); +});