Skip to content

Commit

Permalink
assert: add partialDeepStrictEqual
Browse files Browse the repository at this point in the history
Fixes: #50399

Co-Authored-By: Cristian Barlutiu <[email protected]>
  • Loading branch information
puskin94 and synapse committed Nov 7, 2024
1 parent 6a6c957 commit af0b9a0
Show file tree
Hide file tree
Showing 4 changed files with 712 additions and 1 deletion.
78 changes: 78 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -2548,6 +2548,83 @@ assert.throws(throwingFirst, /Second$/);
Due to the confusing error-prone notation, avoid a string as the second
argument.

## `assert.partialDeepStrictEqual(actual, expected[, message])`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental
* `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.

```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(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: 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
Expand Down Expand Up @@ -2576,6 +2653,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
Expand Down
177 changes: 176 additions & 1 deletion lib/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -50,7 +63,7 @@ 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 { isPromise, isRegExp, isMap, isSet, isDate } = require('internal/util/types');
const { isError, deprecate } = require('internal/util');
const { innerOk } = require('internal/assert/utils');

Expand Down Expand Up @@ -341,6 +354,168 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
}
};

function isSpecial(obj) {
return obj == null || typeof obj !== 'object' || isError(obj) || isRegExp(obj) || isDate(obj);
}

/**
* 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;
}

// 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;
}

// Create a map to count occurrences of each element in the expected array
const expectedCounts = new SafeMap();
for (const item of expected) {
const count = MapPrototypeGet(expectedCounts, item) || 0;
MapPrototypeSet(expectedCounts, item, count + 1);
}

// Create a map to count occurrences of relevant elements in the actual array
for (const item of actual) {
if (MapPrototypeHas(expectedCounts, item)) {
const count = MapPrototypeGet(expectedCounts, item);
if (typeof count !== 'undefined') {
if (count === 1) {
MapPrototypeDelete(expectedCounts, item);
} else {
MapPrototypeSet(expectedCounts, item, count - 1);
}
}
}
}

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,
) {
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) {
Expand Down
1 change: 1 addition & 0 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function lazyAssertObject(harness) {
'notDeepStrictEqual',
'notEqual',
'notStrictEqual',
'partialDeepStrictEqual',
'rejects',
'strictEqual',
'throws',
Expand Down
Loading

0 comments on commit af0b9a0

Please sign in to comment.