Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

assert: add partialDeepStrictEqual #54630

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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])`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be marked as experimental.


<!-- 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.
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
Expand Down Expand Up @@ -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
Expand Down
209 changes: 208 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,17 @@ 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 { Buffer } = require('buffer');
const {
isKeyObject,
isPromise,
isRegExp,
isMap,
isSet,
isDate,
isWeakSet,
isWeakMap,
} = require('internal/util/types');
const { isError, deprecate } = require('internal/util');
const { innerOk } = require('internal/assert/utils');

Expand Down Expand Up @@ -341,6 +364,190 @@ 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) {
aduh95 marked this conversation as resolved.
Show resolved Hide resolved
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)) {
aduh95 marked this conversation as resolved.
Show resolved Hide resolved
return true;
}
comparedObjects.add(actual);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not verify that but I believe the circular structure won't always work this way. AFAIC we have to handle both sides similar to the current implementation.


// 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` }),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This message won't be ideal. If it's a deeper array, it is completely unclear what this belongs to. It was actually my main issue with my first attempt. To highlight what parts are missing is tricky.

);
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
Loading