From 4164f1051d9821762b0fbd4eb99e1a3c199c956e Mon Sep 17 00:00:00 2001 From: Cristian Barlutiu Date: Mon, 10 Jun 2024 15:19:12 +0200 Subject: [PATCH] assert: add matchObjectStrict() and matchObject() Co-authored-by: Antoine du Hamel --- doc/api/assert.md | 124 +++++++++ lib/assert.js | 146 +++++++++- test/parallel/test-assert-objects.js | 395 +++++++++++++++++++++++++++ 3 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-assert-objects.js diff --git a/doc/api/assert.md b/doc/api/assert.md index ec2771f4fe80b9..5e801c4eca6a58 100644 --- a/doc/api/assert.md +++ b/doc/api/assert.md @@ -2539,6 +2539,130 @@ assert.throws(throwingFirst, /Second$/); // AssertionError [ERR_ASSERTION] ``` +## `assert.matchObject(actual, expected[, message])` + + + +* `actual` {any} +* `expected` {any} +* `message` {string|Error} + +Evaluates the equivalence between the `actual` and `expected` parameters by +performing a deep comparison. This function ensures that all properties defined +in the `expected` parameter exactly match those in the `actual` parameter in +both value and type, without allowing type coercion. + +```mjs +import assert from 'node:assert'; + +assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: 1, b: '2', c: 3 }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: { b: { c: '1' } } }, { a: { b: { c: 1 } } }); +// OK + +assert.matchObject({ a: 1 }, { a: 1, b: 2 }); +// AssertionError + +assert.matchObject({ a: 1, b: true }, { a: 1, b: 'true' }); +// AssertionError + +assert.matchObject({ a: { b: 2 } }, { a: { b: 2, c: 3 } }); +// AssertionError +``` + +```cjs +const assert = require('node:assert'); + +assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: 1, b: '2', c: 3 }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: { b: { c: '1' } } }, { a: { b: { c: 1 } } }); +// OK + +assert.matchObject({ a: 1 }, { a: 1, b: 2 }); +// AssertionError: Expected key b + +assert.matchObject({ a: 1, b: true }, { a: 1, b: 'true' }); +// AssertionError + +assert.matchObject({ a: { b: 2, d: 4 } }, { a: { b: 2, c: 3 } }); +// AssertionError: Expected key c +``` + +If the values or keys are not equal in the `expected` parameter, 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} + +Assesses 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, permitting type coercion +where necessary. + +```mjs +import assert from 'node:assert'; + +assert.matchObject({ a: 1, b: 2 }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }); +// OK + +assert.matchObject({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: 1 }, { a: 1, b: 2 }); +// AssertionError + +assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 }); +// AssertionError + +assert.matchObject({ a: { b: 2 } }, { a: { b: '2' } }); +// AssertionError +``` + +```cjs +const assert = require('node:assert'); + +assert.matchObject({ a: 1, b: 2 }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }); +// OK + +assert.matchObject({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 }); +// OK + +assert.matchObject({ a: 1 }, { a: 1, b: 2 }); +// AssertionError + +assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 }); +// AssertionError + +assert.matchObject({ a: { b: 2 } }, { a: { b: '2' } }); +// AssertionError +``` + 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..ee468051280747 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -29,15 +29,25 @@ const { Error, ErrorCaptureStackTrace, FunctionPrototypeBind, + FunctionPrototypeCall, + MapPrototypeGet, + MapPrototypeHas, NumberIsNaN, ObjectAssign, + ObjectGetPrototypeOf, ObjectIs, ObjectKeys, + ObjectPrototype, ObjectPrototypeIsPrototypeOf, + ObjectPrototypeToString, ReflectApply, + ReflectHas, + ReflectOwnKeys, RegExpPrototypeExec, RegExpPrototypeSymbolReplace, SafeMap, + SafeSet, + SetPrototypeHas, String, StringPrototypeCharCodeAt, StringPrototypeIncludes, @@ -46,6 +56,7 @@ const { StringPrototypeSlice, StringPrototypeSplit, StringPrototypeStartsWith, + SymbolIterator, } = primordials; const { Buffer } = require('buffer'); @@ -63,7 +74,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 +619,139 @@ 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, c: 3}, {a: 1, b: '2'}); // true + * + * // Strict comparison + * compareBranch({a: 1, b: 2, c: 3}, {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 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; + } + + // 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; + } + + // Use Reflect.ownKeys() instead of Object.keys() to include symbol properties + const keysExpected = ReflectOwnKeys(expected); + + // 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], 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..e3351af2c360e6 --- /dev/null +++ b/test/parallel/test-assert-objects.js @@ -0,0 +1,395 @@ +'use strict'; + +require('../common'); +const vm = require('node:vm'); +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 non identical simple objects', function() { + const obj1 = { a: 1, b: 'foo', c: 'bar' }; + const obj2 = { a: 1, c: 'bar' }; + assert.matchObjectStrict(obj1, obj2); + }); + + it('should loosely compare two non identical simple objects', function() { + const obj1 = { a: 1, b: 'foo', c: '1' }; + const obj2 = { a: 1, c: 1 }; + assert.matchObject(obj1, obj2); + }); + + 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 strictly compare two objects with different circular references', function() { + const obj1 = createCircularObject(); + const obj2 = { self: {} }; + assert.matchObjectStrict(obj1, obj2); + }); + + 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(0); + const date2 = new Date(0); + assert.matchObjectStrict(date1, date2); + }); + + it('should not strictly compare two Date objects with different times', function() { + const date1 = new Date(0); + const date2 = new Date(1); + assert.throws(() => assert.matchObjectStrict(date1, date2), Error); + }); + + it('should strictly compare two objects with large number of properties', function() { + const obj1 = Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`key${i}`, i])); + const obj2 = Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`key${i}`, i])); + assert.matchObjectStrict(obj1, obj2); + }); + + it('should not strictly compare two objects with different large number of properties', function() { + const obj1 = Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`key${i}`, i])); + const obj2 = Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`key${i}`, i + 1])); + 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('test'); + const sym2 = Symbol('test'); + 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.matchObject(obj1, obj2); + }); + + 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 the same TypedArray instance', 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 TypedArray instances and content', 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 not strictly compare two objects with different TypedArray instances', function() { + const obj1 = { typedArray: new Uint8Array([1, 2, 3]) }; + const obj2 = { typedArray: new Uint8Array([1, 2, 3]) }; + assert.matchObjectStrict(obj1, obj2); + }); + + 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 strictly compare two Map objects from different realms with identical entries', function() { + const map1 = new vm.runInNewContext('new Map([["key1", "value1"], ["key2", "value2"]])'); + const map2 = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + assert.matchObjectStrict(map1, map2); + }); + + it('should not strictly compare two Set objects from different realms with different values', function() { + const set1 = new vm.runInNewContext('new Set(["value1", "value2"])'); + const set2 = new Set(['value1', 'value3']); + assert.throws(() => assert.matchObjectStrict(set1, set2), Error); + }); + + 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 compare plain objects from different realms', function() { + const obj1 = vm.runInNewContext(`({ + a: 1, + b: 2n, + c: "3", + d: /4/, + e: new Set([5]), + f: [6], + g: new Uint8Array() + })`); + const obj2 = { b: 2n, e: new Set([5]), f: [6], g: new Uint8Array() }; + assert.throws(() => assert.matchObjectStrict(obj1, obj2), 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', function() { + ['foo', 1, 1n, false, null, undefined, Symbol()].forEach((val) => { + assert.matchObject(val, val); + assert.matchObjectStrict(val, val); + }); + }); + + 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 }; +}