diff --git a/CHANGELOG.md b/CHANGELOG.md index 735053f82dd6..68e11847ecfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - `[@jest/fake-timers]` Add Lolex as implementation of fake timers ([#8897](https://github.com/facebook/jest/pull/8897)) - `[jest-get-type]` Add `BigInt` support. ([#8382](https://github.com/facebook/jest/pull/8382)) - `[jest-matcher-utils]` Add `BigInt` support to `ensureNumbers` `ensureActualIsNumber`, `ensureExpectedIsNumber` ([#8382](https://github.com/facebook/jest/pull/8382)) +- `[jest-matcher-utils]` Ignore highlighting matched asymmetricMatcher in diffs ([#9257](https://github.com/facebook/jest/pull/9257)) - `[jest-reporters]` Export utils for path formatting ([#9162](https://github.com/facebook/jest/pull/9162)) - `[jest-reporters]` Provides global coverage thresholds as watermarks for istanbul ([#9416](https://github.com/facebook/jest/pull/9416)) - `[jest-runner]` Warn if a worker had to be force exited ([#8206](https://github.com/facebook/jest/pull/8206)) diff --git a/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap b/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap index b811c13d7360..c95358ee16aa 100644 --- a/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap +++ b/packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap @@ -364,14 +364,8 @@ exports[`.toBe() fails for: {"a": [Function a], "b": 2} and {"a": Any, If it should pass with deep equality, replace "toBe" with "toStrictEqual" -- Expected - 1 -+ Received + 1 - - Object { -- "a": Any, -+ "a": [Function a], - "b": 2, - } +Expected: {"a": Any, "b": 2} +Received: {"a": [Function a], "b": 2} `; exports[`.toBe() fails for: {"a": 1} and {"a": 1} 1`] = ` diff --git a/packages/jest-matcher-utils/src/Replaceable.ts b/packages/jest-matcher-utils/src/Replaceable.ts new file mode 100644 index 000000000000..24862f439f13 --- /dev/null +++ b/packages/jest-matcher-utils/src/Replaceable.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import getType = require('jest-get-type'); + +const supportTypes = ['map', 'array', 'object']; + +type ReplaceableForEachCallBack = (value: any, key: any, object: any) => void; + +export default class Replaceable { + object: any; + type: string; + + constructor(object: any) { + this.object = object; + this.type = getType(object); + if (!supportTypes.includes(this.type)) { + throw new Error(`Type ${this.type} is not support in Replaceable!`); + } + } + + static isReplaceable(obj1: any, obj2: any): boolean { + const obj1Type = getType(obj1); + const obj2Type = getType(obj2); + return obj1Type === obj2Type && supportTypes.includes(obj1Type); + } + + forEach(cb: ReplaceableForEachCallBack): void { + if (this.type === 'object') { + Object.entries(this.object).forEach(([key, value]) => { + cb(value, key, this.object); + }); + } else { + this.object.forEach(cb); + } + } + + get(key: any): any { + if (this.type === 'map') { + return this.object.get(key); + } + return this.object[key]; + } + + set(key: any, value: any): void { + if (this.type === 'map') { + this.object.set(key, value); + } else { + this.object[key] = value; + } + } +} diff --git a/packages/jest-matcher-utils/src/__tests__/Replaceable.test.ts b/packages/jest-matcher-utils/src/__tests__/Replaceable.test.ts new file mode 100644 index 000000000000..cb3294296140 --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/Replaceable.test.ts @@ -0,0 +1,154 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import Replaceable from '../Replaceable'; + +describe('Replaceable', () => { + describe('constructor', () => { + test('init with object', () => { + const replaceable = new Replaceable({a: 1, b: 2}); + expect(replaceable.object).toEqual({a: 1, b: 2}); + expect(replaceable.type).toBe('object'); + }); + + test('init with array', () => { + const replaceable = new Replaceable([1, 2, 3]); + expect(replaceable.object).toEqual([1, 2, 3]); + expect(replaceable.type).toBe('array'); + }); + + test('init with Map', () => { + const replaceable = new Replaceable( + new Map([ + ['a', 1], + ['b', 2], + ]), + ); + expect(replaceable.object).toEqual( + new Map([ + ['a', 1], + ['b', 2], + ]), + ); + expect(replaceable.type).toBe('map'); + }); + + test('init with other type should throw error', () => { + expect(() => { + //eslint-disable-next-line @typescript-eslint/no-unused-vars + const replaceable = new Replaceable(new Date()); + }).toThrow('Type date is not support in Replaceable!'); + }); + }); + + describe('get', () => { + test('get object item', () => { + const replaceable = new Replaceable({a: 1, b: 2}); + expect(replaceable.get('b')).toBe(2); + }); + + test('get array item', () => { + const replaceable = new Replaceable([1, 2, 3]); + expect(replaceable.get(1)).toBe(2); + }); + + test('get Map item', () => { + const replaceable = new Replaceable( + new Map([ + ['a', 1], + ['b', 2], + ]), + ); + expect(replaceable.get('b')).toBe(2); + }); + }); + + describe('set', () => { + test('set object item', () => { + const replaceable = new Replaceable({a: 1, b: 2}); + replaceable.set('b', 3); + expect(replaceable.object).toEqual({a: 1, b: 3}); + }); + + test('set array item', () => { + const replaceable = new Replaceable([1, 2, 3]); + replaceable.set(1, 3); + expect(replaceable.object).toEqual([1, 3, 3]); + }); + + test('set Map item', () => { + const replaceable = new Replaceable( + new Map([ + ['a', 1], + ['b', 2], + ]), + ); + replaceable.set('b', 3); + expect(replaceable.object).toEqual( + new Map([ + ['a', 1], + ['b', 3], + ]), + ); + }); + }); + + describe('forEach', () => { + test('object forEach', () => { + const replaceable = new Replaceable({a: 1, b: 2}); + const cb = jest.fn(); + replaceable.forEach(cb); + expect(cb.mock.calls[0]).toEqual([1, 'a', {a: 1, b: 2}]); + expect(cb.mock.calls[1]).toEqual([2, 'b', {a: 1, b: 2}]); + }); + + test('array forEach', () => { + const replaceable = new Replaceable([1, 2, 3]); + const cb = jest.fn(); + replaceable.forEach(cb); + expect(cb.mock.calls[0]).toEqual([1, 0, [1, 2, 3]]); + expect(cb.mock.calls[1]).toEqual([2, 1, [1, 2, 3]]); + expect(cb.mock.calls[2]).toEqual([3, 2, [1, 2, 3]]); + }); + + test('map forEach', () => { + const map = new Map([ + ['a', 1], + ['b', 2], + ]); + const replaceable = new Replaceable(map); + const cb = jest.fn(); + replaceable.forEach(cb); + expect(cb.mock.calls[0]).toEqual([1, 'a', map]); + expect(cb.mock.calls[1]).toEqual([2, 'b', map]); + }); + }); + + describe('isReplaceable', () => { + test('should return true if two object types equal and support', () => { + expect(Replaceable.isReplaceable({a: 1}, {b: 2})).toBe(true); + expect(Replaceable.isReplaceable([], [1, 2, 3])).toBe(true); + expect( + Replaceable.isReplaceable( + new Map(), + new Map([ + ['a', 1], + ['b', 2], + ]), + ), + ).toBe(true); + }); + + test('should return false if two object types not equal', () => { + expect(Replaceable.isReplaceable({a: 1}, [1, 2, 3])).toBe(false); + }); + + test('should return false if object types not support', () => { + expect(Replaceable.isReplaceable('foo', 'bar')).toBe(false); + }); + }); +}); diff --git a/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap index bb0566e91835..990ea8705924 100644 --- a/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap +++ b/packages/jest-matcher-utils/src/__tests__/__snapshots__/printDiffOrStringify.test.ts.snap @@ -1,5 +1,160 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`printDiffOrStringify asymmetricMatcher array 1`] = ` +- Expected - 1 ++ Received + 1 + + Array [ + 1, + Any, +- 3, ++ 2, + ] +`; + +exports[`printDiffOrStringify asymmetricMatcher circular array 1`] = ` +- Expected - 1 ++ Received + 1 + + Array [ + 1, + Any, +- 3, ++ 2, + [Circular], + ] +`; + +exports[`printDiffOrStringify asymmetricMatcher circular map 1`] = ` +- Expected - 2 ++ Received + 2 + + Map { + "a" => 1, + "b" => Any, +- "c" => 3, ++ "c" => 2, + "circular" => Map { + "a" => 1, + "b" => Any, +- "c" => 3, ++ "c" => 2, + "circular" => [Circular], + }, + } +`; + +exports[`printDiffOrStringify asymmetricMatcher circular object 1`] = ` +- Expected - 1 ++ Received + 1 + + Object { + "a": [Circular], + "b": Any, +- "c": 3, ++ "c": 2, + } +`; + +exports[`printDiffOrStringify asymmetricMatcher custom asymmetricMatcher 1`] = ` +- Expected - 1 ++ Received + 1 + + Object { + "a": equal5<>, +- "b": false, ++ "b": true, + } +`; + +exports[`printDiffOrStringify asymmetricMatcher jest asymmetricMatcher 1`] = ` +- Expected - 1 ++ Received + 1 + + Object { + "a": Any, + "b": Anything, + "c": ArrayContaining [ + 1, + 3, + ], + "d": StringContaining "jest", + "e": StringMatching /^jest/, + "f": ObjectContaining { + "a": Any, + }, +- "g": true, ++ "g": false, + } +`; + +exports[`printDiffOrStringify asymmetricMatcher map 1`] = ` +- Expected - 1 ++ Received + 1 + + Map { + "a" => 1, + "b" => Any, +- "c" => 3, ++ "c" => 2, + } +`; + +exports[`printDiffOrStringify asymmetricMatcher minimal test 1`] = ` +- Expected - 1 ++ Received + 1 + + Object { + "a": Any, +- "b": 2, ++ "b": 1, + } +`; + +exports[`printDiffOrStringify asymmetricMatcher nested object 1`] = ` +- Expected - 1 ++ Received + 1 + + Object { + "a": Any, + "b": Object { + "a": 1, + "b": Any, + }, +- "c": 2, ++ "c": 1, + } +`; + +exports[`printDiffOrStringify asymmetricMatcher object in array 1`] = ` +- Expected - 1 ++ Received + 1 + + Array [ + 1, + Object { + "a": 1, + "b": Any, + }, +- 3, ++ 2, + ] +`; + +exports[`printDiffOrStringify asymmetricMatcher transitive circular 1`] = ` +- Expected - 1 ++ Received + 1 + + Object { +- "a": 3, ++ "a": 2, + "nested": Object { + "b": Any, + "parent": [Circular], + }, + } +`; + exports[`printDiffOrStringify expected and received are multi line with trailing spaces 1`] = ` - Expected - 3 + Received + 3 diff --git a/packages/jest-matcher-utils/src/__tests__/deepCyclicCopyReplaceable.test.ts b/packages/jest-matcher-utils/src/__tests__/deepCyclicCopyReplaceable.test.ts new file mode 100644 index 000000000000..3063db1f76f7 --- /dev/null +++ b/packages/jest-matcher-utils/src/__tests__/deepCyclicCopyReplaceable.test.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import deepCyclicCopyReplaceable from '../deepCyclicCopyReplaceable'; + +test('returns the same value for primitive or function values', () => { + const fn = () => {}; + + expect(deepCyclicCopyReplaceable(undefined)).toBe(undefined); + expect(deepCyclicCopyReplaceable(null)).toBe(null); + expect(deepCyclicCopyReplaceable(true)).toBe(true); + expect(deepCyclicCopyReplaceable(42)).toBe(42); + expect(Number.isNaN(deepCyclicCopyReplaceable(NaN))).toBe(true); + expect(deepCyclicCopyReplaceable('foo')).toBe('foo'); + expect(deepCyclicCopyReplaceable(fn)).toBe(fn); +}); + +test('does not execute getters/setters, but copies them', () => { + const fn = jest.fn(); + const obj = { + // @ts-ignore + get foo() { + fn(); + }, + }; + const copy = deepCyclicCopyReplaceable(obj); + + expect(Object.getOwnPropertyDescriptor(copy, 'foo')).toBeDefined(); + expect(fn).not.toBeCalled(); +}); + +test('copies symbols', () => { + const symbol = Symbol('foo'); + const obj = {[symbol]: 42}; + + expect(deepCyclicCopyReplaceable(obj)[symbol]).toBe(42); +}); + +test('copies arrays as array objects', () => { + const array = [null, 42, 'foo', 'bar', [], {}]; + + expect(deepCyclicCopyReplaceable(array)).toEqual(array); + expect(Array.isArray(deepCyclicCopyReplaceable(array))).toBe(true); +}); + +test('handles cyclic dependencies', () => { + const cyclic: any = {a: 42, subcycle: {}}; + + cyclic.subcycle.baz = cyclic; + cyclic.bar = cyclic; + + expect(() => deepCyclicCopyReplaceable(cyclic)).not.toThrow(); + + const copy = deepCyclicCopyReplaceable(cyclic); + + expect(copy.a).toBe(42); + expect(copy.bar).toEqual(copy); + expect(copy.subcycle.baz).toEqual(copy); +}); + +test('Copy Map', () => { + const map = new Map([ + ['a', 1], + ['b', 2], + ]); + const copy = deepCyclicCopyReplaceable(map); + expect(copy).toEqual(map); + expect(copy.constructor).toBe(Map); +}); + +test('Copy cyclic Map', () => { + const map: Map = new Map([ + ['a', 1], + ['b', 2], + ]); + map.set('map', map); + expect(deepCyclicCopyReplaceable(map)).toEqual(map); +}); + +test('return same value for built-in object type except array, map and object', () => { + const date = new Date(); + const buffer = Buffer.from('jest'); + const numberArray = new Uint8Array([1, 2, 3]); + const regexp = /jest/; + const set = new Set(['foo', 'bar']); + + expect(deepCyclicCopyReplaceable(date)).toBe(date); + expect(deepCyclicCopyReplaceable(buffer)).toBe(buffer); + expect(deepCyclicCopyReplaceable(numberArray)).toBe(numberArray); + expect(deepCyclicCopyReplaceable(regexp)).toBe(regexp); + expect(deepCyclicCopyReplaceable(set)).toBe(set); +}); diff --git a/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts b/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts index df1bf6051714..450b69363396 100644 --- a/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts +++ b/packages/jest-matcher-utils/src/__tests__/printDiffOrStringify.test.ts @@ -11,7 +11,7 @@ import {INVERTED_COLOR, printDiffOrStringify} from '../index'; expect.addSnapshotSerializer(alignedAnsiStyleSerializer); describe('printDiffOrStringify', () => { - const testDiffOrStringify = (expected: string, received: string): string => + const testDiffOrStringify = (expected: unknown, received: unknown): string => printDiffOrStringify(expected, received, 'Expected', 'Received', true); test('expected is empty and received is single line', () => { @@ -86,4 +86,162 @@ describe('printDiffOrStringify', () => { expect(difference).not.toMatch(lessChange); }); }); + + describe('asymmetricMatcher', () => { + test('minimal test', () => { + const expected = {a: expect.any(Number), b: 2}; + const received = {a: 1, b: 1}; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('jest asymmetricMatcher', () => { + const expected = { + a: expect.any(Number), + b: expect.anything(), + c: expect.arrayContaining([1, 3]), + d: 'jest is awesome', + e: 'jest is awesome', + f: { + a: new Date(), + b: 'jest is awesome', + }, + g: true, + }; + const received = { + a: 1, + b: 'anything', + c: [1, 2, 3], + d: expect.stringContaining('jest'), + e: expect.stringMatching(/^jest/), + f: expect.objectContaining({ + a: expect.any(Date), + }), + g: false, + }; + + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('custom asymmetricMatcher', () => { + expect.extend({ + equal5(received: any) { + if (received === 5) + return { + message: () => `expected ${received} not to be 5`, + pass: true, + }; + return { + message: () => `expected ${received} to be 5`, + pass: false, + }; + }, + }); + const expected = { + a: expect.equal5(), + b: false, + }; + const received = { + a: 5, + b: true, + }; + + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('nested object', () => { + const expected = { + a: 1, + b: { + a: 1, + b: expect.any(Number), + }, + c: 2, + }; + const received = { + a: expect.any(Number), + b: { + a: 1, + b: 2, + }, + c: 1, + }; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('array', () => { + const expected: Array = [1, expect.any(Number), 3]; + const received: Array = [1, 2, 2]; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('object in array', () => { + const expected: Array = [1, {a: 1, b: expect.any(Number)}, 3]; + const received: Array = [1, {a: 1, b: 2}, 2]; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('map', () => { + const expected: Map = new Map([ + ['a', 1], + ['b', expect.any(Number)], + ['c', 3], + ]); + const received: Map = new Map([ + ['a', 1], + ['b', 2], + ['c', 2], + ]); + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('circular object', () => { + const expected: any = { + b: expect.any(Number), + c: 3, + }; + expected.a = expected; + const received: any = { + b: 2, + c: 2, + }; + received.a = received; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('transitive circular', () => { + const expected: any = { + a: 3, + }; + expected.nested = {b: expect.any(Number), parent: expected}; + const received: any = { + a: 2, + }; + received.nested = {b: 2, parent: received}; + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('circular array', () => { + const expected: Array = [1, expect.any(Number), 3]; + expected.push(expected); + const received: Array = [1, 2, 2]; + received.push(received); + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + + test('circular map', () => { + const expected: Map = new Map([ + ['a', 1], + ['b', expect.any(Number)], + ['c', 3], + ]); + expected.set('circular', expected); + const received: Map = new Map([ + ['a', 1], + ['b', 2], + ['c', 2], + ]); + received.set('circular', received); + expect(testDiffOrStringify(expected, received)).toMatchSnapshot(); + }); + }); }); diff --git a/packages/jest-matcher-utils/src/deepCyclicCopyReplaceable.ts b/packages/jest-matcher-utils/src/deepCyclicCopyReplaceable.ts new file mode 100644 index 000000000000..9d81675ff0e6 --- /dev/null +++ b/packages/jest-matcher-utils/src/deepCyclicCopyReplaceable.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +const builtInObject = [ + Array, + Buffer, + Date, + Float32Array, + Float64Array, + Int16Array, + Int32Array, + Int8Array, + Map, + Set, + RegExp, + Uint16Array, + Uint32Array, + Uint8Array, + Uint8ClampedArray, +]; + +const isBuiltInObject = (object: any) => + builtInObject.includes(object.constructor); + +const isMap = (value: any): value is Map => value.constructor === Map; + +export default function deepCyclicCopyReplaceable( + value: T, + cycles: WeakMap = new WeakMap(), +): T { + if (typeof value !== 'object' || value === null) { + return value; + } else if (cycles.has(value)) { + return cycles.get(value); + } else if (Array.isArray(value)) { + return deepCyclicCopyArray(value, cycles); + } else if (isMap(value)) { + return deepCyclicCopyMap(value, cycles); + } else if (isBuiltInObject(value)) { + return value; + } else { + return deepCyclicCopyObject(value, cycles); + } +} + +function deepCyclicCopyObject(object: T, cycles: WeakMap): T { + const newObject = Object.create(Object.getPrototypeOf(object)); + const descriptors = Object.getOwnPropertyDescriptors(object); + + cycles.set(object, newObject); + + Object.keys(descriptors).forEach(key => { + const descriptor = descriptors[key]; + if (typeof descriptor.value !== 'undefined') { + descriptor.value = deepCyclicCopyReplaceable(descriptor.value, cycles); + } + + descriptor.configurable = true; + }); + + return Object.defineProperties(newObject, descriptors); +} + +function deepCyclicCopyArray(array: Array, cycles: WeakMap): T { + const newArray = new (Object.getPrototypeOf(array).constructor)(array.length); + const length = array.length; + + cycles.set(array, newArray); + + for (let i = 0; i < length; i++) { + newArray[i] = deepCyclicCopyReplaceable(array[i], cycles); + } + + return newArray; +} + +function deepCyclicCopyMap( + map: Map, + cycles: WeakMap, +): T { + const newMap = new Map(); + + cycles.set(map, newMap); + + map.forEach((value, key) => { + newMap.set(key, deepCyclicCopyReplaceable(value, cycles)); + }); + + return newMap as any; +} diff --git a/packages/jest-matcher-utils/src/index.ts b/packages/jest-matcher-utils/src/index.ts index 84f1bcbe6fb2..708f16ba910d 100644 --- a/packages/jest-matcher-utils/src/index.ts +++ b/packages/jest-matcher-utils/src/index.ts @@ -17,6 +17,8 @@ import diffDefault, { } from 'jest-diff'; import getType = require('jest-get-type'); import prettyFormat = require('pretty-format'); +import Replaceable from './Replaceable'; +import deepCyclicCopyReplaceable from './deepCyclicCopyReplaceable'; const { AsymmetricMatcher, @@ -351,7 +353,16 @@ export const printDiffOrStringify = ( } if (isLineDiffable(expected, received)) { - const difference = diffDefault(expected, received, { + const { + replacedExpected, + replacedReceived, + } = replaceMatchedToAsymmetricMatcher( + deepCyclicCopyReplaceable(expected), + deepCyclicCopyReplaceable(received), + [], + [], + ); + const difference = diffDefault(replacedExpected, replacedReceived, { aAnnotation: expectedLabel, bAnnotation: receivedLabel, expand, @@ -394,6 +405,66 @@ const shouldPrintDiff = (actual: unknown, expected: unknown) => { return true; }; +function replaceMatchedToAsymmetricMatcher( + replacedExpected: unknown, + replacedReceived: unknown, + expectedCycles: Array, + receivedCycles: Array, +) { + if (!Replaceable.isReplaceable(replacedExpected, replacedReceived)) { + return {replacedExpected, replacedReceived}; + } + + if ( + expectedCycles.includes(replacedExpected) || + receivedCycles.includes(replacedReceived) + ) { + return {replacedExpected, replacedReceived}; + } + + expectedCycles.push(replacedExpected); + receivedCycles.push(replacedReceived); + + const expectedReplaceable = new Replaceable(replacedExpected); + const receivedReplaceable = new Replaceable(replacedReceived); + + expectedReplaceable.forEach((expectedValue: unknown, key: unknown) => { + const receivedValue = receivedReplaceable.get(key); + if (isAsymmetricMatcher(expectedValue)) { + if (expectedValue.asymmetricMatch(receivedValue)) { + receivedReplaceable.set(key, expectedValue); + } + } else if (isAsymmetricMatcher(receivedValue)) { + if (receivedValue.asymmetricMatch(expectedValue)) { + expectedReplaceable.set(key, receivedValue); + } + } else if (Replaceable.isReplaceable(expectedValue, receivedValue)) { + const replaced = replaceMatchedToAsymmetricMatcher( + expectedValue, + receivedValue, + expectedCycles, + receivedCycles, + ); + expectedReplaceable.set(key, replaced.replacedExpected); + receivedReplaceable.set(key, replaced.replacedReceived); + } + }); + + return { + replacedExpected: expectedReplaceable.object, + replacedReceived: receivedReplaceable.object, + }; +} + +type AsymmetricMatcher = { + asymmetricMatch: Function; +}; + +function isAsymmetricMatcher(data: any): data is AsymmetricMatcher { + const type = getType(data); + return type === 'object' && typeof data.asymmetricMatch === 'function'; +} + export const diff = (a: any, b: any, options?: DiffOptions): string | null => shouldPrintDiff(a, b) ? diffDefault(a, b, options) : null;