Skip to content

Commit 8722fbd

Browse files
puskin94synapse
andcommitted
assert: add partialDeepStrictEqual
Fixes: nodejs#50399 Co-Authored-By: Cristian Barlutiu <cristian.barlutiu@gmail.com>
1 parent 6a6c957 commit 8722fbd

File tree

4 files changed

+803
-2
lines changed

4 files changed

+803
-2
lines changed

doc/api/assert.md

+91
Original file line numberDiff line numberDiff line change
@@ -2548,6 +2548,96 @@ assert.throws(throwingFirst, /Second$/);
25482548
Due to the confusing error-prone notation, avoid a string as the second
25492549
argument.
25502550

2551+
## `assert.partialDeepStrictEqual(actual, expected[, message])`
2552+
2553+
<!-- YAML
2554+
added: REPLACEME
2555+
-->
2556+
2557+
> Stability: 1.0 - Early development
2558+
2559+
* `actual` {any}
2560+
* `expected` {any}
2561+
* `message` {string|Error}
2562+
2563+
[`assert.partialDeepStrictEqual()`][] Asserts the equivalence between the `actual` and `expected` parameters through a
2564+
deep comparison, ensuring that all properties in the `expected` parameter are
2565+
present in the `actual` parameter with equivalent values, not allowing type coercion.
2566+
The main difference with [`assert.deepStrictEqual()`][] is that [`assert.partialDeepStrictEqual()`][] does not require
2567+
all properties in the `actual` parameter to be present in the `expected` parameter.
2568+
This method should always pass the same test cases as [`assert.deepStrictEqual()`][], behaving as a super set of it.
2569+
2570+
```mjs
2571+
import assert from 'node:assert';
2572+
2573+
assert.partialDeepStrictEqual({ a: 1, b: 2 }, { a: 1, b: 2 });
2574+
// OK
2575+
2576+
assert.partialDeepStrictEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } });
2577+
// OK
2578+
2579+
assert.partialDeepStrictEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 });
2580+
// OK
2581+
2582+
assert.partialDeepStrictEqual(new Set(['value1', 'value2']), new Set(['value1', 'value2']));
2583+
// OK
2584+
2585+
assert.partialDeepStrictEqual(new Map([['key1', 'value1']]), new Map([['key1', 'value1']]));
2586+
// OK
2587+
2588+
assert.partialDeepStrictEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]));
2589+
// OK
2590+
2591+
assert.partialDeepStrictEqual(/abc/, /abc/);
2592+
// OK
2593+
2594+
assert.partialDeepStrictEqual([{ a: 5 }, { b: 5 }], [{ a: 5 }]);
2595+
// OK
2596+
2597+
assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 1 }]), new Set([{ a: 1 }]));
2598+
// OK
2599+
2600+
assert.partialDeepStrictEqual(new Date(0), new Date(0));
2601+
// OK
2602+
2603+
assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 });
2604+
// AssertionError
2605+
2606+
assert.partialDeepStrictEqual({ a: 1, b: '2' }, { a: 1, b: 2 });
2607+
// AssertionError
2608+
2609+
assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } });
2610+
// AssertionError
2611+
```
2612+
2613+
```cjs
2614+
const assert = require('node:assert');
2615+
2616+
assert.partialDeepStrictEqual({ a: 1, b: 2 }, { a: 1, b: 2 });
2617+
// OK
2618+
2619+
assert.partialDeepStrictEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } });
2620+
// OK
2621+
2622+
assert.partialDeepStrictEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 });
2623+
// OK
2624+
2625+
assert.partialDeepStrictEqual([{ a: 5 }, { b: 5 }], [{ a: 5 }]);
2626+
// OK
2627+
2628+
assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 1 }]), new Set([{ a: 1 }]));
2629+
// OK
2630+
2631+
assert.partialDeepStrictEqual({ a: 1 }, { a: 1, b: 2 });
2632+
// AssertionError
2633+
2634+
assert.partialDeepStrictEqual({ a: 1, b: '2' }, { a: 1, b: 2 });
2635+
// AssertionError
2636+
2637+
assert.partialDeepStrictEqual({ a: { b: 2 } }, { a: { b: '2' } });
2638+
// AssertionError
2639+
```
2640+
25512641
[Object wrappers]: https://developer.mozilla.org/en-US/docs/Glossary/Primitive#Primitive_wrapper_objects_in_JavaScript
25522642
[Object.prototype.toString()]: https://tc39.github.io/ecma262/#sec-object.prototype.tostring
25532643
[`!=` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality
@@ -2576,6 +2666,7 @@ argument.
25762666
[`assert.notEqual()`]: #assertnotequalactual-expected-message
25772667
[`assert.notStrictEqual()`]: #assertnotstrictequalactual-expected-message
25782668
[`assert.ok()`]: #assertokvalue-message
2669+
[`assert.partialDeepStrictEqual()`]: #assertpartialdeepstrictequalactual-expected-message
25792670
[`assert.strictEqual()`]: #assertstrictequalactual-expected-message
25802671
[`assert.throws()`]: #assertthrowsfn-error-message
25812672
[`getColorDepth()`]: tty.md#writestreamgetcolordepthenv

lib/assert.js

+210-2
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,35 @@
2121
'use strict';
2222

2323
const {
24+
ArrayFrom,
25+
ArrayIsArray,
2426
ArrayPrototypeIndexOf,
2527
ArrayPrototypeJoin,
2628
ArrayPrototypePush,
2729
ArrayPrototypeSlice,
2830
Error,
31+
FunctionPrototypeCall,
32+
MapPrototypeDelete,
33+
MapPrototypeGet,
34+
MapPrototypeHas,
35+
MapPrototypeSet,
2936
NumberIsNaN,
3037
ObjectAssign,
3138
ObjectIs,
3239
ObjectKeys,
3340
ObjectPrototypeIsPrototypeOf,
3441
ReflectApply,
42+
ReflectHas,
43+
ReflectOwnKeys,
3544
RegExpPrototypeExec,
45+
SafeMap,
46+
SafeSet,
47+
SafeWeakSet,
3648
String,
3749
StringPrototypeIndexOf,
3850
StringPrototypeSlice,
3951
StringPrototypeSplit,
52+
SymbolIterator,
4053
} = primordials;
4154

4255
const {
@@ -50,8 +63,18 @@ const {
5063
} = require('internal/errors');
5164
const AssertionError = require('internal/assert/assertion_error');
5265
const { inspect } = require('internal/util/inspect');
53-
const { isPromise, isRegExp } = require('internal/util/types');
54-
const { isError, deprecate } = require('internal/util');
66+
const { Buffer } = require('buffer');
67+
const {
68+
isKeyObject,
69+
isPromise,
70+
isRegExp,
71+
isMap,
72+
isSet,
73+
isDate,
74+
isWeakSet,
75+
isWeakMap,
76+
} = require('internal/util/types');
77+
const { isError, deprecate, emitExperimentalWarning } = require('internal/util');
5578
const { innerOk } = require('internal/assert/utils');
5679

5780
const CallTracker = require('internal/assert/calltracker');
@@ -341,6 +364,191 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
341364
}
342365
};
343366

367+
function isSpecial(obj) {
368+
return obj == null || typeof obj !== 'object' || isError(obj) || isRegExp(obj) || isDate(obj);
369+
}
370+
371+
const typesToCallDeepStrictEqualWith = [
372+
isKeyObject, isWeakSet, isWeakMap, Buffer.isBuffer,
373+
];
374+
375+
/**
376+
* Compares two objects or values recursively to check if they are equal.
377+
* @param {any} actual - The actual value to compare.
378+
* @param {any} expected - The expected value to compare.
379+
* @param {Set} [comparedObjects=new Set()] - Set to track compared objects for handling circular references.
380+
* @returns {boolean} - Returns `true` if the actual value matches the expected value, otherwise `false`.
381+
* @example
382+
* compareBranch({a: 1, b: 2, c: 3}, {a: 1, b: 2}); // true
383+
*/
384+
function compareBranch(
385+
actual,
386+
expected,
387+
comparedObjects,
388+
) {
389+
// Check for Map object equality
390+
if (isMap(actual) && isMap(expected)) {
391+
if (actual.size !== expected.size) {
392+
return false;
393+
}
394+
const safeIterator = FunctionPrototypeCall(SafeMap.prototype[SymbolIterator], actual);
395+
396+
comparedObjects ??= new SafeWeakSet();
397+
398+
for (const { 0: key, 1: val } of safeIterator) {
399+
if (!MapPrototypeHas(expected, key)) {
400+
return false;
401+
}
402+
if (!compareBranch(val, MapPrototypeGet(expected, key), comparedObjects)) {
403+
return false;
404+
}
405+
}
406+
return true;
407+
}
408+
409+
for (const type of typesToCallDeepStrictEqualWith) {
410+
if (type(actual) || type(expected)) {
411+
if (isDeepStrictEqual === undefined) lazyLoadComparison();
412+
return isDeepStrictEqual(actual, expected);
413+
}
414+
}
415+
416+
// Check for Set object equality
417+
// TODO(aduh95): switch to `SetPrototypeIsSubsetOf` when it's available
418+
if (isSet(actual) && isSet(expected)) {
419+
if (expected.size > actual.size) {
420+
return false; // `expected` can't be a subset if it has more elements
421+
}
422+
423+
if (isDeepEqual === undefined) lazyLoadComparison();
424+
425+
const actualArray = ArrayFrom(actual);
426+
const expectedArray = ArrayFrom(expected);
427+
const usedIndices = new SafeSet();
428+
429+
for (let expectedIdx = 0; expectedIdx < expectedArray.length; expectedIdx++) {
430+
const expectedItem = expectedArray[expectedIdx];
431+
let found = false;
432+
433+
for (let actualIdx = 0; actualIdx < actualArray.length; actualIdx++) {
434+
if (!usedIndices.has(actualIdx) && isDeepStrictEqual(actualArray[actualIdx], expectedItem)) {
435+
usedIndices.add(actualIdx);
436+
found = true;
437+
break;
438+
}
439+
}
440+
441+
if (!found) {
442+
return false;
443+
}
444+
}
445+
446+
return true;
447+
}
448+
449+
// Check if expected array is a subset of actual array
450+
if (ArrayIsArray(actual) && ArrayIsArray(expected)) {
451+
if (expected.length > actual.length) {
452+
return false;
453+
}
454+
455+
if (isDeepEqual === undefined) lazyLoadComparison();
456+
457+
// Create a map to count occurrences of each element in the expected array
458+
const expectedCounts = new SafeMap();
459+
for (const expectedItem of expected) {
460+
let found = false;
461+
for (const { 0: key, 1: count } of expectedCounts) {
462+
if (isDeepStrictEqual(key, expectedItem)) {
463+
MapPrototypeSet(expectedCounts, key, count + 1);
464+
found = true;
465+
break;
466+
}
467+
}
468+
if (!found) {
469+
MapPrototypeSet(expectedCounts, expectedItem, 1);
470+
}
471+
}
472+
473+
// Create a map to count occurrences of relevant elements in the actual array
474+
for (const actualItem of actual) {
475+
for (const { 0: key, 1: count } of expectedCounts) {
476+
if (isDeepStrictEqual(key, actualItem)) {
477+
if (count === 1) {
478+
MapPrototypeDelete(expectedCounts, key);
479+
} else {
480+
MapPrototypeSet(expectedCounts, key, count - 1);
481+
}
482+
break;
483+
}
484+
}
485+
}
486+
487+
return !expectedCounts.size;
488+
}
489+
490+
// Comparison done when at least one of the values is not an object
491+
if (isSpecial(actual) || isSpecial(expected)) {
492+
if (isDeepEqual === undefined) {
493+
lazyLoadComparison();
494+
}
495+
return isDeepStrictEqual(actual, expected);
496+
}
497+
498+
// Use Reflect.ownKeys() instead of Object.keys() to include symbol properties
499+
const keysExpected = ReflectOwnKeys(expected);
500+
501+
comparedObjects ??= new SafeWeakSet();
502+
503+
// Handle circular references
504+
if (comparedObjects.has(actual)) {
505+
return true;
506+
}
507+
comparedObjects.add(actual);
508+
509+
// Check if all expected keys and values match
510+
for (let i = 0; i < keysExpected.length; i++) {
511+
const key = keysExpected[i];
512+
assert(
513+
ReflectHas(actual, key),
514+
new AssertionError({ message: `Expected key ${String(key)} not found in actual object` }),
515+
);
516+
if (!compareBranch(actual[key], expected[key], comparedObjects)) {
517+
return false;
518+
}
519+
}
520+
521+
return true;
522+
}
523+
524+
/**
525+
* The strict equivalence assertion test between two objects
526+
* @param {any} actual
527+
* @param {any} expected
528+
* @param {string | Error} [message]
529+
* @returns {void}
530+
*/
531+
assert.partialDeepStrictEqual = function partialDeepStrictEqual(
532+
actual,
533+
expected,
534+
message,
535+
) {
536+
emitExperimentalWarning('assert.partialDeepStrictEqual');
537+
if (arguments.length < 2) {
538+
throw new ERR_MISSING_ARGS('actual', 'expected');
539+
}
540+
541+
if (!compareBranch(actual, expected)) {
542+
innerFail({
543+
actual,
544+
expected,
545+
message,
546+
operator: 'partialDeepStrictEqual',
547+
stackStartFn: partialDeepStrictEqual,
548+
});
549+
}
550+
};
551+
344552
class Comparison {
345553
constructor(obj, keys, actual) {
346554
for (const key of keys) {

lib/internal/test_runner/test.js

+1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ function lazyAssertObject(harness) {
114114
'notDeepStrictEqual',
115115
'notEqual',
116116
'notStrictEqual',
117+
'partialDeepStrictEqual',
117118
'rejects',
118119
'strictEqual',
119120
'throws',

0 commit comments

Comments
 (0)