Skip to content

Commit bcf2eab

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

File tree

4 files changed

+803
-1
lines changed

4 files changed

+803
-1
lines changed

doc/api/assert.md

Lines changed: 91 additions & 0 deletions
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 - Experimental
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

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,36 @@
2121
'use strict';
2222

2323
const {
24+
ArrayBufferIsView,
25+
ArrayFrom,
26+
ArrayIsArray,
2427
ArrayPrototypeIndexOf,
2528
ArrayPrototypeJoin,
2629
ArrayPrototypePush,
2730
ArrayPrototypeSlice,
2831
Error,
32+
FunctionPrototypeCall,
33+
MapPrototypeDelete,
34+
MapPrototypeGet,
35+
MapPrototypeHas,
36+
MapPrototypeSet,
2937
NumberIsNaN,
3038
ObjectAssign,
3139
ObjectIs,
3240
ObjectKeys,
3341
ObjectPrototypeIsPrototypeOf,
3442
ReflectApply,
43+
ReflectHas,
44+
ReflectOwnKeys,
3545
RegExpPrototypeExec,
46+
SafeMap,
47+
SafeSet,
48+
SafeWeakSet,
3649
String,
3750
StringPrototypeIndexOf,
3851
StringPrototypeSlice,
3952
StringPrototypeSplit,
53+
SymbolIterator,
4054
} = primordials;
4155

4256
const {
@@ -50,7 +64,17 @@ const {
5064
} = require('internal/errors');
5165
const AssertionError = require('internal/assert/assertion_error');
5266
const { inspect } = require('internal/util/inspect');
53-
const { isPromise, isRegExp } = require('internal/util/types');
67+
const { Buffer } = require('buffer');
68+
const {
69+
isKeyObject,
70+
isPromise,
71+
isRegExp,
72+
isMap,
73+
isSet,
74+
isDate,
75+
isWeakSet,
76+
isWeakMap,
77+
} = require('internal/util/types');
5478
const { isError, deprecate } = require('internal/util');
5579
const { innerOk } = require('internal/assert/utils');
5680

@@ -261,6 +285,7 @@ assert.deepStrictEqual = function deepStrictEqual(actual, expected, message) {
261285
throw new ERR_MISSING_ARGS('actual', 'expected');
262286
}
263287
if (isDeepEqual === undefined) lazyLoadComparison();
288+
// if (!compareBranch(actual, expected) || !compareBranch(expected, actual)) {
264289
if (!isDeepStrictEqual(actual, expected)) {
265290
innerFail({
266291
actual,
@@ -341,6 +366,190 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
341366
}
342367
};
343368

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

lib/internal/test_runner/test.js

Lines changed: 1 addition & 0 deletions
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)