diff --git a/doc/api/assert.md b/doc/api/assert.md index ec2771f4fe80b9..3bab1c49722cde 100644 --- a/doc/api/assert.md +++ b/doc/api/assert.md @@ -2539,6 +2539,96 @@ assert.throws(throwingFirst, /Second$/); // AssertionError [ERR_ASSERTION] ``` +## `assert.matchObject(actual, expected[, message])` + + + +* `actual` {any} +* `expected` {any} +* `message` {string|Error} + +Tests equivalence between the actual and expected parameters by performing +a deep comparison, ensuring that all properties and their values are equal, +allowing type coercion where necessary. + +**Strict assertion mode** + +An alias of [`assert.matchObjectStrict()`][]. + +```mjs +import assert from 'node:assert'; + +assert.matchObject({ a: 1 }, { a: 1 }); +// OK + +assert.matchObject({ b: 1 }, { b: 2 }); +// AssertionError: 1 != 2 + +assert.matchObject({ c: 1 }, { c: '1' }); +// OK +``` + +```cjs +const assert = require('node:assert'); + +assert.matchObject({ a: 1 }, { a: 1 }); +// OK + +assert.matchObject({ b: 1 }, { b: 2 }); +// AssertionError: 1 != 2 + +assert.matchObject({ c: 1 }, { c: '1' }); +// OK +``` + +If the values or keys are not equal, an [`AssertionError`][] is thrown with a `message` +property set equal to the value of the `message` parameter. If the `message` +parameter is undefined, a default error message is assigned. If the `message` +parameter is an instance of an [`Error`][] then it will be thrown instead of the +`AssertionError`. + +## `assert.matchObjectStrict(actual, expected[, message])` + + + +* `actual` {any} +* `expected` {any} +* `message` {string|Error} + +Tests strict equivalence between the actual and expected parameters by performing a +deep comparison, ensuring that all properties and their values are strictly equal, +without type coercion. + +```mjs +import assert from 'node:assert/strict'; + +assert.matchObjectStrict({ a: 1 }, { a: 1 }); +// OK + +assert.matchObjectStrict({ b: 1 }, { b: '1' }); +// AssertionError [ERR_ASSERTION]: Expected "actual" to be strictly unequal to: { b: '1' } + +assert.notStrictEqual({ a: 1, b: 'string' }, { b: 'string', a: 1 }); +// OK +``` + +```cjs +const assert = require('node:assert/strict'); + +assert.matchObjectStrict({ a: 1 }, { a: 1 }); +// OK + +assert.matchObjectStrict({ b: 1 }, { b: '1' }); +// AssertionError [ERR_ASSERTION]: Expected "actual" to be strictly unequal to: { b: '1' } + +assert.notStrictEqual({ a: 1, b: 'string' }, { b: 'string', a: 1 }); +// OK +``` + Due to the confusing error-prone notation, avoid a string as the second argument. diff --git a/lib/assert.js b/lib/assert.js index 9dfcf80a913942..0465952ec869f6 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -29,15 +29,24 @@ const { Error, ErrorCaptureStackTrace, FunctionPrototypeBind, + FunctionPrototypeCall, + MapPrototypeGet, + MapPrototypeHas, NumberIsNaN, ObjectAssign, + ObjectGetPrototypeOf, ObjectIs, ObjectKeys, + ObjectPrototype, ObjectPrototypeIsPrototypeOf, + ObjectPrototypeToString, ReflectApply, + ReflectOwnKeys, RegExpPrototypeExec, RegExpPrototypeSymbolReplace, SafeMap, + SafeSet, + SetPrototypeHas, String, StringPrototypeCharCodeAt, StringPrototypeIncludes, @@ -46,6 +55,7 @@ const { StringPrototypeSlice, StringPrototypeSplit, StringPrototypeStartsWith, + SymbolIterator, } = primordials; const { Buffer } = require('buffer'); @@ -63,7 +73,7 @@ const { const AssertionError = require('internal/assert/assertion_error'); const { openSync, closeSync, readSync } = require('fs'); const { inspect } = require('internal/util/inspect'); -const { isPromise, isRegExp } = require('internal/util/types'); +const { isPromise, isRegExp, isMap, isSet } = require('internal/util/types'); const { EOL } = require('internal/constants'); const { BuiltinModule } = require('internal/bootstrap/realm'); const { isError, deprecate } = require('internal/util'); @@ -608,6 +618,144 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) { } }; +/** + * 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 {boolean} [loose=false] - Whether to use loose comparison (==) or strict comparison (===). Defaults to false. + * @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 + * // Loose comparison (default) + * compareBranch({a: 1, b: 2}, {a: 1, b: '2'}); // true + * + * // Strict comparison + * compareBranch({a: 1, b: 2}, {a: 1, b: 2}, true); // true + */ +function compareBranch( + actual, + expected, + loose = false, + comparedObjects = new SafeSet(), +) { + function isPlainObject(obj) { + if (typeof obj !== 'object' || obj === null) return false; + const proto = ObjectGetPrototypeOf(obj); + return proto === ObjectPrototype || proto === null || ObjectPrototypeToString(obj) === '[object Object]'; + } + + // Check non object types equality + if (!isPlainObject(actual) || !isPlainObject(expected)) { + if (isDeepEqual === undefined) lazyLoadComparison(); + return loose ? isDeepEqual(actual, expected) : isDeepStrictEqual(actual, expected); + } + + // Check if actual and expected are null or not objects + if (actual == null || expected == null) { + return false; + } + + // Check for Map object equality + if (isMap(actual) && isMap(expected)) { + if (actual.size !== expected.size) return false; + const safeIterator = FunctionPrototypeCall(SafeMap.prototype[SymbolIterator], actual); + for (const { 0: key, 1: val } of safeIterator) { + if (!MapPrototypeHas(expected, key)) return false; + if (!compareBranch(val, MapPrototypeGet(expected, key), loose, comparedObjects)) + return false; + } + return true; + } + + // Check for Set object equality + if (isSet(actual) && isSet(expected)) { + if (actual.size !== expected.size) return false; + const safeIterator = FunctionPrototypeCall(SafeSet.prototype[SymbolIterator], actual); + for (const item of safeIterator) { + if (!SetPrototypeHas(expected, item)) return false; + } + return true; + } + + // Use Reflect.ownKeys() instead of Object.keys() to include symbol properties + const keysActual = ReflectOwnKeys(actual); + const keysExpected = ReflectOwnKeys(expected); + + // Check if number of keys are equal + if (keysActual.length !== keysExpected.length) { + return false; + } + + // Handle circular references + if (comparedObjects.has(actual)) { + return true; + } + comparedObjects.add(actual); + + // Check if all keys and values recursively match + for (let i = 0; i < keysActual.length; i++) { + const key = keysActual[i]; + if ( + !keysExpected.includes(key) || + !compareBranch(actual[key], expected[key], loose, 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.matchObjectStrict = function matchObjectStrict( + actual, + expected, + message, +) { + if (arguments.length < 2) { + throw new ERR_MISSING_ARGS('actual', 'expected'); + } + + if (!compareBranch(actual, expected)) { + innerFail({ + actual, + expected, + message, + operator: 'matchObjectStrict', + stackStartFn: matchObjectStrict, + }); + } +}; + +/** + * The equivalence assertion test between two objects + * @param {any} actual + * @param {any} expected + * @param {string | Error} [message] + * @returns {void} + */ +assert.matchObject = function matchObject(actual, expected, message) { + if (arguments.length < 2) { + throw new ERR_MISSING_ARGS('actual', 'expected'); + } + + if (!compareBranch(actual, expected, true)) { + innerFail({ + actual, + expected, + message, + operator: 'matchObject', + stackStartFn: matchObject, + }); + } +}; + class Comparison { constructor(obj, keys, actual) { for (const key of keys) { diff --git a/test/parallel/test-assert-objects.js b/test/parallel/test-assert-objects.js new file mode 100644 index 00000000000000..c8b2afb075159a --- /dev/null +++ b/test/parallel/test-assert-objects.js @@ -0,0 +1,368 @@ +'use strict'; + +require('../common'); +const assert = require('node:assert'); +const { describe, it } = require('node:test'); +const { KeyObject } = require('node:crypto'); +const { subtle } = globalThis.crypto; + +// Helper functions for testing +function createCircularObject() { + const obj = {}; + obj.self = obj; + return obj; +} + +function createDeepNestedObject() { + return { level1: { level2: { level3: 'deepValue' } } }; +} + +// Test cases +describe('Object Comparison Tests', function() { + it('should strictly compare two identical simple objects', function() { + const obj1 = { a: 1, b: 'string' }; + const obj2 = { a: 1, b: 'string' }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two different simple objects', function() { + const obj1 = { a: 1, b: 'string' }; + const obj2 = { a: 2, b: 'string' }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should loosely compare two similar objects with type coercion', function() { + const obj1 = { a: 1, b: '2' }; + const obj2 = { a: 1, b: 2 }; + assert.matchObject(obj1, obj2); + }); + + it('should strictly compare two objects with different property order', function() { + const obj1 = { a: 1, b: 'string' }; + const obj2 = { b: 'string', a: 1 }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should strictly compare two objects with nested objects', function() { + const obj1 = createDeepNestedObject(); + const obj2 = createDeepNestedObject(); + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different nested objects', function() { + const obj1 = createDeepNestedObject(); + const obj2 = { level1: { level2: { level3: 'differentValue' } } }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should loosely compare two objects with nested objects and type coercion', function() { + const obj1 = { level1: { level2: { level3: '42' } } }; + const obj2 = { level1: { level2: { level3: 42 } } }; + assert.matchObject(obj1, obj2); + }); + + it('should strictly compare two objects with circular references', function() { + const obj1 = createCircularObject(); + const obj2 = createCircularObject(); + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different circular references', function() { + const obj1 = createCircularObject(); + const obj2 = { self: {} }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should loosely compare two objects with circular references', function() { + const obj1 = createCircularObject(); + const obj2 = createCircularObject(); + assert.matchObject(obj1, obj2); + }); + + it('should strictly compare two arrays with identical elements', function() { + const arr1 = [1, 'two', true]; + const arr2 = [1, 'two', true]; + assert.matchObjectStrict(arr1, arr2); + }); + + it('should not strictly compare two arrays with different elements', function() { + const arr1 = [1, 'two', true]; + const arr2 = [1, 'two', false]; + assert.throws(() => assert.matchObjectStrict(arr1, arr2), Error); + }); + + it('should loosely compare two arrays with type coercion', function() { + const arr1 = [1, '2', true]; + const arr2 = [1, 2, 1]; + assert.matchObject(arr1, arr2); + }); + + it('should strictly compare two Date objects with the same time', function() { + const date1 = new Date('2024-06-10T12:00:00Z'); + const date2 = new Date('2024-06-10T12:00:00Z'); + assert.matchObjectStrict(date1, date2); + }); + + it('should not strictly compare two Date objects with different times', function() { + const date1 = new Date('2024-06-10T12:00:00Z'); + const date2 = new Date('2024-06-11T12:00:00Z'); + assert.throws(() => assert.matchObjectStrict(date1, date2), Error); + }); + + it('should strictly compare two objects with large number of properties', function() { + const obj1 = Array.from({ length: 100 }, (_, i) => ({ + [`key${i}`]: i, + })).reduce((acc, cur) => ({ ...acc, ...cur }), {}); + const obj2 = Array.from({ length: 100 }, (_, i) => ({ + [`key${i}`]: i, + })).reduce((acc, cur) => ({ ...acc, ...cur }), {}); + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different large number of properties', function() { + const obj1 = Array.from({ length: 100 }, (_, i) => ({ + [`key${i}`]: i, + })).reduce((acc, cur) => ({ ...acc, ...cur }), {}); + const obj2 = Array.from({ length: 100 }, (_, i) => ({ + [`key${i}`]: i + 1, + })).reduce((acc, cur) => ({ ...acc, ...cur }), {}); + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should strictly compare two objects with Symbol properties', function() { + const sym = Symbol('test'); + const obj1 = { [sym]: 'symbol' }; + const obj2 = { [sym]: 'symbol' }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different Symbols', function() { + const sym1 = Symbol('test1'); + const sym2 = Symbol('test2'); + const obj1 = { [sym1]: 'symbol' }; + const obj2 = { [sym2]: 'symbol' }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should loosely compare two objects with numeric string and number', function() { + const obj1 = { a: '100' }; + const obj2 = { a: 100 }; + assert.matchObject(obj1, obj2); + }); + + it('should not strictly compare two objects with different array properties', function() { + const obj1 = { a: [1, 2, 3] }; + const obj2 = { a: [1, 2, 4] }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should loosely compare two objects with boolean and numeric representations', function() { + const obj1 = { a: 1, b: 0 }; + const obj2 = { a: true, b: false }; + assert.matchObject(obj1, obj2); + }); + + it('should strictly compare two objects with RegExp properties', function() { + const obj1 = { pattern: /abc/ }; + const obj2 = { pattern: /abc/ }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different RegExp properties', function() { + const obj1 = { pattern: /abc/ }; + const obj2 = { pattern: /def/ }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should not loosely compare two objects with null and empty object', function() { + const obj1 = { a: null }; + const obj2 = { a: {} }; + assert.throws(() => assert.matchObject(obj1, obj2), Error); + }); + + it('should strictly compare two objects with identical function properties', function() { + const func = () => {}; + const obj1 = { fn: func }; + const obj2 = { fn: func }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different function properties', function() { + const obj1 = { fn: () => {} }; + const obj2 = { fn: () => {} }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should loosely compare two objects with undefined and missing properties', function() { + const obj1 = { a: undefined }; + const obj2 = {}; + assert.throws(() => assert.matchObject(obj1, obj2), Error); + }); + + it('should strictly compare two objects with mixed types of properties', function() { + const sym = Symbol('test'); + const obj1 = { num: 1, str: 'test', bool: true, sym: sym }; + const obj2 = { num: 1, str: 'test', bool: true, sym: sym }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should compare two objects with Buffers', function() { + const obj1 = { buf: Buffer.from('Node.js') }; + const obj2 = { buf: Buffer.from('Node.js') }; + assert.matchObjectStrict(obj1, obj2); + assert.matchObject(obj1, obj2); + }); + + it('should strictly compare two objects with identical Error properties', function() { + const error = new Error('Test error'); + const obj1 = { error: error }; + const obj2 = { error: error }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different Error instances', function() { + const obj1 = { error: new Error('Test error 1') }; + const obj2 = { error: new Error('Test error 2') }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should strictly compare two objects with identical Typed Array properties', function() { + const typedArray = new Uint8Array([1, 2, 3]); + const obj1 = { typedArray: typedArray }; + const obj2 = { typedArray: typedArray }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different Typed Array instances', function() { + const obj1 = { typedArray: new Uint8Array([1, 2, 3]) }; + const obj2 = { typedArray: new Uint8Array([4, 5, 6]) }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should strictly compare two Map objects with identical entries', function() { + const map1 = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + const map2 = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + assert.matchObjectStrict(map1, map2); + }); + + it('should not strictly compare two Map objects with different entries', function() { + const map1 = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + const map2 = new Map([ + ['key1', 'value1'], + ['key3', 'value3'], + ]); + assert.throws(() => assert.matchObjectStrict(map1, map2), Error); + }); + + it('should strictly compare two Set objects with identical values', function() { + const set1 = new Set(['value1', 'value2']); + const set2 = new Set(['value1', 'value2']); + assert.matchObjectStrict(set1, set2); + }); + + it('should not strictly compare two Set objects with different values', function() { + const set1 = new Set(['value1', 'value2']); + const set2 = new Set(['value1', 'value3']); + assert.throws(() => assert.matchObjectStrict(set1, set2), Error); + }); + + it('should strictly compare two objects with identical getter/setter properties', function() { + const createObjectWithGetterSetter = () => { + let value = 'test'; + return Object.defineProperty({}, 'prop', { + get: () => value, + set: (newValue) => { + value = newValue; + }, + enumerable: true, + configurable: true, + }); + }; + + const obj1 = createObjectWithGetterSetter(); + const obj2 = createObjectWithGetterSetter(); + assert.matchObjectStrict(obj1, obj2); + }); + + it('should strictly compare two objects with no prototype', function() { + const obj1 = { __proto__: null, prop: 'value' }; + const obj2 = { __proto__: null, prop: 'value' }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should strictly compare two objects with identical non-enumerable properties', function() { + const obj1 = {}; + Object.defineProperty(obj1, 'hidden', { + value: 'secret', + enumerable: false, + }); + const obj2 = {}; + Object.defineProperty(obj2, 'hidden', { + value: 'secret', + enumerable: false, + }); + assert.matchObjectStrict(obj1, obj2); + }); + + it('should compare two identical primitives (strings)', function() { + const obj1 = 'foo'; + const obj2 = 'foo'; + assert.matchObjectStrict(obj1, obj2); + assert.matchObject(obj1, obj2); + }); + + it('should compare two identical primitives (booleans)', function() { + const obj1 = true; + const obj2 = true; + assert.matchObjectStrict(obj1, obj2); + assert.matchObject(obj1, obj2); + }); + + it('should compare two non-identical primitives (number)', function() { + const obj1 = 1; + const obj2 = 2; + + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should compare two objects with different CryptoKey instances objects', async function() { + const { cryptoKey: cryptoKey1 } = await generateCryptoKey(); + const { cryptoKey: cryptoKey2 } = await generateCryptoKey(); + + const obj1 = { cryptoKey: cryptoKey1 }; + const obj2 = { cryptoKey: cryptoKey2 }; + + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); + + it('should compare two objects with different KeyObject objects', async function() { + const { keyObject: keyObject1 } = await generateCryptoKey(); + const { keyObject: keyObject2 } = await generateCryptoKey(); + + const obj1 = { keyObject: keyObject1 }; + const obj2 = { keyObject: keyObject2 }; + + assert.throws(() => assert.matchObjectStrict(obj1, obj2), Error); + }); +}); + +async function generateCryptoKey() { + const cryptoKey = await subtle.generateKey({ + name: 'HMAC', + hash: 'SHA-256', + length: 256, + }, true, ['sign', 'verify']); + + const keyObject = KeyObject.from(cryptoKey); + + return { cryptoKey, keyObject }; +}