From e9d74575fdc615df49f4ac196518782429528227 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 16 Apr 2026 18:42:48 +0100 Subject: [PATCH 1/6] fix(commons): use ancestor-chain tracking in deepMerge to correctly handle shared references deepMerge used a WeakSet to detect circular references, which incorrectly skipped shared (non-circular) object references. Replace with an ancestor array (push/pop stack) so only true cycles are detected. --- packages/commons/src/deepMerge.ts | 50 +++--- packages/commons/tests/unit/deepMerge.test.ts | 162 ++++++++++++++++-- 2 files changed, 176 insertions(+), 36 deletions(-) diff --git a/packages/commons/src/deepMerge.ts b/packages/commons/src/deepMerge.ts index d0abbcf6e2..8961fa6ae4 100644 --- a/packages/commons/src/deepMerge.ts +++ b/packages/commons/src/deepMerge.ts @@ -24,7 +24,7 @@ const isPlainObject = (value: unknown): value is Record => { const mergeArrayItemsByIndex = ( targetArray: unknown[], sourceArray: unknown[], - seen: WeakSet + ancestors: object[] ): void => { for (let i = 0; i < sourceArray.length; i++) { const srcItem = sourceArray[i]; @@ -32,15 +32,16 @@ const mergeArrayItemsByIndex = ( const isSrcPlainObject = isPlainObject(srcItem); - // Skip already-seen objects to prevent circular references - if (isSrcPlainObject && seen.has(srcItem)) { + // Skip objects in the current ancestor chain to prevent circular references + if (isSrcPlainObject && ancestors.includes(srcItem)) { continue; } // Merge nested plain objects recursively if (isSrcPlainObject && isPlainObject(tgtItem)) { - seen.add(srcItem); - mergeRecursive(tgtItem, srcItem, seen); + ancestors.push(srcItem); + mergeRecursive(tgtItem, srcItem, ancestors); + ancestors.pop(); continue; } @@ -61,14 +62,14 @@ const handleArrayMerge = ( key: string, sourceArray: unknown[], targetValue: unknown, - seen: WeakSet + ancestors: object[] ): void => { if (!Array.isArray(targetValue)) { target[key] = [...sourceArray]; return; } - mergeArrayItemsByIndex(targetValue, sourceArray, seen); + mergeArrayItemsByIndex(targetValue, sourceArray, ancestors); }; /** @@ -81,15 +82,15 @@ const handleObjectMerge = ( key: string, sourceObject: Record, targetValue: unknown, - seen: WeakSet + ancestors: object[] ): void => { if (isPlainObject(targetValue)) { - mergeRecursive(targetValue, sourceObject, seen); + mergeRecursive(targetValue, sourceObject, ancestors); return; } const newTarget: Record = {}; - mergeRecursive(newTarget, sourceObject, seen); + mergeRecursive(newTarget, sourceObject, ancestors); target[key] = newTarget; }; @@ -101,7 +102,7 @@ const handleObjectMerge = ( const mergeRecursive = ( target: Record, source: Record, - seen: WeakSet + ancestors: object[] ): void => { for (const key of Object.keys(source)) { if (UNSAFE_KEYS.has(key)) { @@ -112,16 +113,18 @@ const mergeRecursive = ( const targetValue = target[key]; if (Array.isArray(sourceValue)) { - if (seen.has(sourceValue)) continue; - seen.add(sourceValue); - handleArrayMerge(target, key, sourceValue, targetValue, seen); + if (ancestors.includes(sourceValue)) continue; + ancestors.push(sourceValue); + handleArrayMerge(target, key, sourceValue, targetValue, ancestors); + ancestors.pop(); continue; } if (isPlainObject(sourceValue)) { - if (seen.has(sourceValue)) continue; - seen.add(sourceValue); - handleObjectMerge(target, key, sourceValue, targetValue, seen); + if (ancestors.includes(sourceValue)) continue; + ancestors.push(sourceValue); + handleObjectMerge(target, key, sourceValue, targetValue, ancestors); + ancestors.pop(); continue; } @@ -135,8 +138,9 @@ const mergeRecursive = ( * Recursively merge properties from source objects into the target object, mutating it. * * Nested plain objects are merged recursively, arrays are merged by index (e.g., `[1, 2]` + `[3]` → `[3, 2]`), - * and class instances (Date, RegExp, custom classes) are assigned by reference. Circular references and - * prototype pollution attempts (`__proto__`, `constructor`) are safely skipped. + * and class instances (Date, RegExp, custom classes) are assigned by reference. Circular references are + * detected via ancestor-chain tracking and safely skipped, while shared (non-circular) object references + * are merged correctly. Prototype pollution attempts (`__proto__`, `constructor`) are also skipped. * * @example * ```typescript @@ -155,13 +159,13 @@ const deepMerge = >( target: T, ...sources: Array | undefined | null> ): T => { - const seen = new WeakSet(); - seen.add(target); + const ancestors: object[] = [target]; for (const source of sources) { if (source != null) { - seen.add(source); - mergeRecursive(target, source, seen); + ancestors.push(source); + mergeRecursive(target, source, ancestors); + ancestors.pop(); } } diff --git a/packages/commons/tests/unit/deepMerge.test.ts b/packages/commons/tests/unit/deepMerge.test.ts index b14f9bc5e5..c2cd443977 100644 --- a/packages/commons/tests/unit/deepMerge.test.ts +++ b/packages/commons/tests/unit/deepMerge.test.ts @@ -303,7 +303,7 @@ describe('Function: deepMerge', () => { expect((result.arr as unknown[])[1]).toBe(2); }); - it('skips already-seen arrays to prevent duplication', () => { + it('merges shared array references into all properties', () => { // Prepare const target = {}; const sharedArray = [1, 2, 3]; @@ -315,16 +315,14 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - shared array is copied on first occurrence, - // skipped on subsequent occurrences + // Assess - shared (non-circular) array is merged into both properties expect(result).toEqual({ first: [1, 2, 3], - second: undefined, + second: [1, 2, 3], }); - expect(result).not.toHaveProperty('second'); }); - it('skips already-seen objects within arrays', () => { + it('merges shared objects within arrays', () => { // Prepare const sharedObj = { shared: true }; const target = { @@ -338,17 +336,85 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - sharedObj is merged into prop first, then skipped when - // encountered again in the array merge + // Assess - sharedObj is merged into both prop and arr[0] expect(result.prop).toEqual({ shared: true }); - expect((result.arr as Record[])[0]).toEqual({ a: 1 }); + expect((result.arr as Record[])[0]).toEqual({ + a: 1, + shared: true, + }); + expect((result.arr as Record[])[1]).toEqual({ + b: 2, + c: 3, + }); + }); + + it('merges shared objects into all referencing properties', () => { + // Prepare + const target = {}; + const shared = { value: 42 }; + const source = { + first: shared, + second: { nested: shared }, + }; + + // Act + const result = deepMerge(target, source); + + // Assess - shared (non-circular) object appears in both locations + expect(result).toEqual({ + first: { value: 42 }, + second: { nested: { value: 42 } }, + }); + }); + }); + + describe('Shared (non-circular) references', () => { + it('correctly merges shared array references into both properties', () => { + // Prepare + const target = {}; + const sharedArray = [1, 2, 3]; + const source = { + first: sharedArray, + second: sharedArray, + }; + + // Act + const result = deepMerge(target, source); + + // Assess - shared array should be merged into both properties + expect(result).toEqual({ + first: [1, 2, 3], + second: [1, 2, 3], + }); + }); + + it('correctly merges shared objects referenced in arrays', () => { + // Prepare + const sharedObj = { shared: true }; + const target = { + arr: [{ a: 1 }, { b: 2 }], + }; + const source = { + prop: sharedObj, + arr: [sharedObj, { c: 3 }], + }; + + // Act + const result = deepMerge(target, source); + + // Assess - sharedObj should be merged into both prop and arr[0] + expect(result.prop).toEqual({ shared: true }); + expect((result.arr as Record[])[0]).toEqual({ + a: 1, + shared: true, + }); expect((result.arr as Record[])[1]).toEqual({ b: 2, c: 3, }); }); - it('skips already-seen objects to prevent duplication', () => { + it('correctly merges shared objects into all referencing properties', () => { // Prepare const target = {}; const shared = { value: 42 }; @@ -360,11 +426,81 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - shared object is merged on first occurrence, - // skipped on subsequent occurrences to prevent infinite recursion + // Assess - shared object should appear in both locations expect(result).toEqual({ first: { value: 42 }, - second: {}, + second: { nested: { value: 42 } }, + }); + }); + + it('correctly merges shared objects across multiple sources', () => { + // Prepare + const shared = { x: 1 }; + const target = {}; + const source1 = { a: shared }; + const source2 = { b: shared }; + + // Act + const result = deepMerge(target, source1, source2); + + // Assess + expect(result).toEqual({ a: { x: 1 }, b: { x: 1 } }); + }); + + it('correctly merges diamond-shaped shared references', () => { + // Prepare + const shared = { value: 'shared' }; + const target = {}; + const source = { + branch1: { leaf: shared }, + branch2: { leaf: shared }, + }; + + // Act + const result = deepMerge(target, source); + + // Assess - same object at different depths in two branches + expect(result).toEqual({ + branch1: { leaf: { value: 'shared' } }, + branch2: { leaf: { value: 'shared' } }, + }); + }); + + it('merges a shared object that itself contains a circular reference', () => { + // Prepare + const shared: Record = { value: 1 }; + shared.self = shared; + const target = {}; + const source = { + first: shared, + second: { nested: shared }, + }; + + // Act + const result = deepMerge(target, source); + + // Assess - shared object is merged in both locations, + // but the circular self-reference within it is skipped + expect(result).toEqual({ + first: { value: 1 }, + second: { nested: { value: 1 } }, + }); + }); + + it('merges when source references an object from the target', () => { + // Prepare + const inner = { x: 1 }; + const target: Record = { a: inner }; + const source = { b: inner }; + + // Act + const result = deepMerge(target, source); + + // Assess - inner is not in the ancestor chain when processing source, + // so it should be merged into both properties + expect(result).toEqual({ + a: { x: 1 }, + b: { x: 1 }, }); }); }); From 7e99c3d2bf143e39b32cf4576b1ded27fc2251cc Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 16 Apr 2026 18:45:33 +0100 Subject: [PATCH 2/6] test(commons): add coverage for circular array/object ancestor checks in deepMerge --- packages/commons/tests/unit/deepMerge.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/commons/tests/unit/deepMerge.test.ts b/packages/commons/tests/unit/deepMerge.test.ts index c2cd443977..1fa4051358 100644 --- a/packages/commons/tests/unit/deepMerge.test.ts +++ b/packages/commons/tests/unit/deepMerge.test.ts @@ -303,6 +303,38 @@ describe('Function: deepMerge', () => { expect((result.arr as unknown[])[1]).toBe(2); }); + it('skips array that references an ancestor array', () => { + // Prepare - an array contains an object whose property points back to the array + const arr: unknown[] = []; + const inner: Record = { backRef: arr }; + arr.push(inner); + const target = { arr: [{ a: 1 }] }; + const source = { arr }; + + // Act + const result = deepMerge(target, source); + + // Assess - inner.backRef is the same array that's in the ancestor chain, + // so the circular array reference is skipped during mergeRecursive + expect(result.arr).toBeDefined(); + expect((result.arr as Record[])[0]).not.toHaveProperty( + 'backRef' + ); + }); + + it('skips circular plain objects inside arrays', () => { + // Prepare + const target = { arr: [{ a: 1 }] }; + const source: Record = { b: 2 }; + source.arr = [source]; + + // Act + const result = deepMerge(target, source); + + // Assess - source inside its own array is a circular ref and is skipped + expect(result).toEqual({ arr: [{ a: 1 }], b: 2 }); + }); + it('merges shared array references into all properties', () => { // Prepare const target = {}; From 7461dee68ee60c960169cfdbc5a4884de56f7534 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 16 Apr 2026 19:09:17 +0100 Subject: [PATCH 3/6] test(commons): add lodash merge parity tests for deepMerge Cross-referenced against lodash _.merge test suite to close coverage gaps: multi-source array-of-objects merging, indirect prototype pollution via toString.constructor.prototype, function-replaced-by-object across sources, and self-merge safety. --- packages/commons/tests/unit/deepMerge.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/commons/tests/unit/deepMerge.test.ts b/packages/commons/tests/unit/deepMerge.test.ts index 1fa4051358..8f35af8de3 100644 --- a/packages/commons/tests/unit/deepMerge.test.ts +++ b/packages/commons/tests/unit/deepMerge.test.ts @@ -146,6 +146,28 @@ describe('Function: deepMerge', () => { }); }); + describe('Multi-source merging', () => { + it('merges three sources with arrays of objects', () => { + // Prepare + const names = { characters: [{ name: 'barney' }, { name: 'fred' }] }; + const ages = { characters: [{ age: 36 }, { age: 40 }] }; + const heights = { + characters: [{ height: '5\'4"' }, { height: '5\'5"' }], + }; + + // Act + const result = deepMerge({}, names, ages, heights); + + // Assess + expect(result).toEqual({ + characters: [ + { name: 'barney', age: 36, height: '5\'4"' }, + { name: 'fred', age: 40, height: '5\'5"' }, + ], + }); + }); + }); + describe('Array merging (index-based)', () => { it('merges arrays by index', () => { // Prepare @@ -238,6 +260,16 @@ describe('Function: deepMerge', () => { expect(result.constructor).toBe(Object); }); + it('does not indirectly pollute via toString.constructor.prototype', () => { + // Prepare & Act + deepMerge({}, { + toString: { constructor: { prototype: { polluted: true } } }, + } as Record); + + // Assess + expect('polluted' in Function.prototype).toBe(false); + }); + it('handles nested __proto__ keys', () => { // Prepare const target = { nested: { a: 1 } }; @@ -692,5 +724,31 @@ describe('Function: deepMerge', () => { expect(result.fn).toBe(fn); expect((result.fn as () => number)()).toBe(42); }); + + it('replaces function target value with object from later source', () => { + // Prepare + const fn = () => 42; + const source1 = { a: fn } as Record; + const source2 = { a: { b: 2 } }; + + // Act + const result = deepMerge({}, source1, source2); + + // Assess - object source overwrites function, not merged into it + expect(result).toEqual({ a: { b: 2 } }); + expect('b' in (source1.a as object)).toBe(false); + }); + + it('handles self-merge without infinite loop', () => { + // Prepare + const object: Record = { a: 1, b: { c: 2 } }; + + // Act + const result = deepMerge(object, object); + + // Assess - should be a no-op + expect(result).toBe(object); + expect(result).toEqual({ a: 1, b: { c: 2 } }); + }); }); }); From 0fa558a80820b6c52e7c98374fdb11ebe51d2a1c Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 16 Apr 2026 22:12:03 +0100 Subject: [PATCH 4/6] remove verbose comments from test stage markers in deepMerge tests --- packages/commons/tests/unit/deepMerge.test.ts | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/commons/tests/unit/deepMerge.test.ts b/packages/commons/tests/unit/deepMerge.test.ts index 8f35af8de3..0084381f3d 100644 --- a/packages/commons/tests/unit/deepMerge.test.ts +++ b/packages/commons/tests/unit/deepMerge.test.ts @@ -296,7 +296,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - self-reference is skipped entirely + // Assess expect(result).toEqual({ a: 1, b: 2 }); expect(result).not.toHaveProperty('self'); }); @@ -313,7 +313,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - circular reference is skipped + // Assess expect(result).toEqual({ a: 1, b: 2, nested: { c: 3 } }); }); @@ -327,8 +327,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - circular array reference is preserved in shallow copy - // (the array itself is copied, but the circular element points to original) + // Assess expect(result.a).toBe(1); expect(Array.isArray(result.arr)).toBe(true); expect((result.arr as unknown[])[0]).toBe(1); @@ -336,7 +335,7 @@ describe('Function: deepMerge', () => { }); it('skips array that references an ancestor array', () => { - // Prepare - an array contains an object whose property points back to the array + // Prepare const arr: unknown[] = []; const inner: Record = { backRef: arr }; arr.push(inner); @@ -346,8 +345,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - inner.backRef is the same array that's in the ancestor chain, - // so the circular array reference is skipped during mergeRecursive + // Assess expect(result.arr).toBeDefined(); expect((result.arr as Record[])[0]).not.toHaveProperty( 'backRef' @@ -363,7 +361,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - source inside its own array is a circular ref and is skipped + // Assess expect(result).toEqual({ arr: [{ a: 1 }], b: 2 }); }); @@ -379,7 +377,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - shared (non-circular) array is merged into both properties + // Assess expect(result).toEqual({ first: [1, 2, 3], second: [1, 2, 3], @@ -400,7 +398,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - sharedObj is merged into both prop and arr[0] + // Assess expect(result.prop).toEqual({ shared: true }); expect((result.arr as Record[])[0]).toEqual({ a: 1, @@ -424,7 +422,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - shared (non-circular) object appears in both locations + // Assess expect(result).toEqual({ first: { value: 42 }, second: { nested: { value: 42 } }, @@ -433,7 +431,7 @@ describe('Function: deepMerge', () => { }); describe('Shared (non-circular) references', () => { - it('correctly merges shared array references into both properties', () => { + it('merges shared array references into both properties', () => { // Prepare const target = {}; const sharedArray = [1, 2, 3]; @@ -445,14 +443,14 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - shared array should be merged into both properties + // Assess expect(result).toEqual({ first: [1, 2, 3], second: [1, 2, 3], }); }); - it('correctly merges shared objects referenced in arrays', () => { + it('merges shared objects referenced in arrays', () => { // Prepare const sharedObj = { shared: true }; const target = { @@ -466,7 +464,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - sharedObj should be merged into both prop and arr[0] + // Assess expect(result.prop).toEqual({ shared: true }); expect((result.arr as Record[])[0]).toEqual({ a: 1, @@ -478,7 +476,7 @@ describe('Function: deepMerge', () => { }); }); - it('correctly merges shared objects into all referencing properties', () => { + it('merges shared objects into all referencing properties', () => { // Prepare const target = {}; const shared = { value: 42 }; @@ -490,14 +488,14 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - shared object should appear in both locations + // Assess expect(result).toEqual({ first: { value: 42 }, second: { nested: { value: 42 } }, }); }); - it('correctly merges shared objects across multiple sources', () => { + it('merges shared objects across multiple sources', () => { // Prepare const shared = { x: 1 }; const target = {}; @@ -511,7 +509,7 @@ describe('Function: deepMerge', () => { expect(result).toEqual({ a: { x: 1 }, b: { x: 1 } }); }); - it('correctly merges diamond-shaped shared references', () => { + it('merges diamond-shaped shared references', () => { // Prepare const shared = { value: 'shared' }; const target = {}; @@ -523,7 +521,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - same object at different depths in two branches + // Assess expect(result).toEqual({ branch1: { leaf: { value: 'shared' } }, branch2: { leaf: { value: 'shared' } }, @@ -543,8 +541,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - shared object is merged in both locations, - // but the circular self-reference within it is skipped + // Assess expect(result).toEqual({ first: { value: 1 }, second: { nested: { value: 1 } }, @@ -560,8 +557,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(target, source); - // Assess - inner is not in the ancestor chain when processing source, - // so it should be merged into both properties + // Assess expect(result).toEqual({ a: { x: 1 }, b: { x: 1 }, @@ -734,7 +730,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge({}, source1, source2); - // Assess - object source overwrites function, not merged into it + // Assess expect(result).toEqual({ a: { b: 2 } }); expect('b' in (source1.a as object)).toBe(false); }); @@ -746,7 +742,7 @@ describe('Function: deepMerge', () => { // Act const result = deepMerge(object, object); - // Assess - should be a no-op + // Assess expect(result).toBe(object); expect(result).toEqual({ a: 1, b: { c: 2 } }); }); From d98d362596a225778e2c10262d6dde8ca05b0dff Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 16 Apr 2026 22:19:25 +0100 Subject: [PATCH 5/6] fix(commons): widen target types in deepMerge tests to fix TS errors --- packages/commons/tests/unit/deepMerge.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/commons/tests/unit/deepMerge.test.ts b/packages/commons/tests/unit/deepMerge.test.ts index 0084381f3d..ecb799ff42 100644 --- a/packages/commons/tests/unit/deepMerge.test.ts +++ b/packages/commons/tests/unit/deepMerge.test.ts @@ -319,7 +319,7 @@ describe('Function: deepMerge', () => { it('handles circular arrays', () => { // Prepare - const target = { a: 1 }; + const target: Record = { a: 1 }; const circularArr: unknown[] = [1, 2]; circularArr.push(circularArr); const source = { arr: circularArr }; @@ -387,7 +387,7 @@ describe('Function: deepMerge', () => { it('merges shared objects within arrays', () => { // Prepare const sharedObj = { shared: true }; - const target = { + const target: Record = { arr: [{ a: 1 }, { b: 2 }], }; const source = { @@ -453,7 +453,7 @@ describe('Function: deepMerge', () => { it('merges shared objects referenced in arrays', () => { // Prepare const sharedObj = { shared: true }; - const target = { + const target: Record = { arr: [{ a: 1 }, { b: 2 }], }; const source = { @@ -691,7 +691,7 @@ describe('Function: deepMerge', () => { it('handles special number values', () => { // Prepare - const target = {}; + const target: Record = {}; const source = { inf: Number.POSITIVE_INFINITY, negInf: Number.NEGATIVE_INFINITY, @@ -710,7 +710,7 @@ describe('Function: deepMerge', () => { it('handles function values', () => { // Prepare const fn = () => 42; - const target = {}; + const target: Record = {}; const source = { fn }; // Act From 8e2a18f1808768caa0e59e356bfb17b6676f2aaa Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 16 Apr 2026 23:08:48 +0100 Subject: [PATCH 6/6] test(commons): simplify assertions and remove unnecessary type casts in deepMerge tests Replace verbose as-casts with type annotations, use direct value checks (toBe, toEqual, toHaveProperty, in-operator) instead of intermediate casts and property lookups. --- packages/commons/tests/unit/deepMerge.test.ts | 78 ++++++++----------- 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/packages/commons/tests/unit/deepMerge.test.ts b/packages/commons/tests/unit/deepMerge.test.ts index ecb799ff42..bcac1f4457 100644 --- a/packages/commons/tests/unit/deepMerge.test.ts +++ b/packages/commons/tests/unit/deepMerge.test.ts @@ -123,7 +123,7 @@ describe('Function: deepMerge', () => { it('replaces non-object target values with source objects', () => { // Prepare - const target = { a: 'string' } as Record; + const target: Record = { a: 'string' }; const source = { a: { nested: 1 } }; // Act @@ -207,7 +207,7 @@ describe('Function: deepMerge', () => { it('replaces non-array target with array source', () => { // Prepare - const target = { arr: 'not an array' } as Record; + const target: Record = { arr: 'not an array' }; const source = { arr: [1, 2, 3] }; // Act @@ -241,16 +241,16 @@ describe('Function: deepMerge', () => { // Assess expect(result).toEqual({ a: 1, b: 2 }); - expect(({} as Record).polluted).toBeUndefined(); + expect({}).not.toHaveProperty('polluted'); }); it('skips constructor keys from source', () => { // Prepare const target = { a: 1 }; - const source = { constructor: { polluted: true }, b: 2 } as Record< - string, - unknown - >; + const source: Record = { + constructor: { polluted: true }, + b: 2, + }; // Act const result = deepMerge(target, source); @@ -262,12 +262,13 @@ describe('Function: deepMerge', () => { it('does not indirectly pollute via toString.constructor.prototype', () => { // Prepare & Act - deepMerge({}, { + const source: Record = { toString: { constructor: { prototype: { polluted: true } } }, - } as Record); + }; + deepMerge({}, source); // Assess - expect('polluted' in Function.prototype).toBe(false); + expect(Function.prototype).not.toHaveProperty('polluted'); }); it('handles nested __proto__ keys', () => { @@ -282,7 +283,7 @@ describe('Function: deepMerge', () => { // Assess expect(result).toEqual({ nested: { a: 1, b: 2 } }); - expect(({} as Record).polluted).toBeUndefined(); + expect({}).not.toHaveProperty('polluted'); }); }); @@ -304,11 +305,9 @@ describe('Function: deepMerge', () => { it('handles deep circular references', () => { // Prepare const target = { a: 1 }; - const source: Record = { - b: 2, - nested: { c: 3 }, - }; - (source.nested as Record).circular = source; + const nested: Record = { c: 3 }; + const source: Record = { b: 2, nested }; + nested.circular = source; // Act const result = deepMerge(target, source); @@ -328,10 +327,9 @@ describe('Function: deepMerge', () => { const result = deepMerge(target, source); // Assess - expect(result.a).toBe(1); - expect(Array.isArray(result.arr)).toBe(true); - expect((result.arr as unknown[])[0]).toBe(1); - expect((result.arr as unknown[])[1]).toBe(2); + expect(result).toHaveProperty('a', 1); + expect(result).toHaveProperty('arr[0]', 1); + expect(result).toHaveProperty('arr[1]', 2); }); it('skips array that references an ancestor array', () => { @@ -346,10 +344,7 @@ describe('Function: deepMerge', () => { const result = deepMerge(target, source); // Assess - expect(result.arr).toBeDefined(); - expect((result.arr as Record[])[0]).not.toHaveProperty( - 'backRef' - ); + expect(result).toEqual({ arr: [{ a: 1 }] }); }); it('skips circular plain objects inside arrays', () => { @@ -399,14 +394,12 @@ describe('Function: deepMerge', () => { const result = deepMerge(target, source); // Assess - expect(result.prop).toEqual({ shared: true }); - expect((result.arr as Record[])[0]).toEqual({ - a: 1, - shared: true, - }); - expect((result.arr as Record[])[1]).toEqual({ - b: 2, - c: 3, + expect(result).toEqual({ + prop: { shared: true }, + arr: [ + { a: 1, shared: true }, + { b: 2, c: 3 }, + ], }); }); @@ -465,14 +458,12 @@ describe('Function: deepMerge', () => { const result = deepMerge(target, source); // Assess - expect(result.prop).toEqual({ shared: true }); - expect((result.arr as Record[])[0]).toEqual({ - a: 1, - shared: true, - }); - expect((result.arr as Record[])[1]).toEqual({ - b: 2, - c: 3, + expect(result).toEqual({ + prop: { shared: true }, + arr: [ + { a: 1, shared: true }, + { b: 2, c: 3 }, + ], }); }); @@ -674,7 +665,7 @@ describe('Function: deepMerge', () => { // Assess expect(result).toEqual({ a: 1, b: 2 }); - expect((result as Record)[sym]).toBeUndefined(); + expect(sym in result).toBe(false); }); it('handles numeric keys', () => { @@ -718,13 +709,12 @@ describe('Function: deepMerge', () => { // Assess expect(result.fn).toBe(fn); - expect((result.fn as () => number)()).toBe(42); }); it('replaces function target value with object from later source', () => { // Prepare const fn = () => 42; - const source1 = { a: fn } as Record; + const source1: Record = { a: fn }; const source2 = { a: { b: 2 } }; // Act @@ -732,7 +722,7 @@ describe('Function: deepMerge', () => { // Assess expect(result).toEqual({ a: { b: 2 } }); - expect('b' in (source1.a as object)).toBe(false); + expect(source1.a).toBe(fn); }); it('handles self-merge without infinite loop', () => {