assert: add partialDeepStrictEqual · nodejs/node@05d6227
@@ -21,22 +21,35 @@
2121'use strict';
22222323const {
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;
41544255const {
@@ -50,8 +63,18 @@ const {
5063} = require('internal/errors');
5164const AssertionError = require('internal/assert/assertion_error');
5265const { 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');
5578const { innerOk } = require('internal/assert/utils');
56795780const CallTracker = require('internal/assert/calltracker');
@@ -341,6 +364,191 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
341364}
342365};
343366367+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+344552class Comparison {
345553constructor(obj, keys, actual) {
346554for (const key of keys) {