-
Notifications
You must be signed in to change notification settings - Fork 1
/
clone.ts
160 lines (148 loc) · 5.34 KB
/
clone.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { CloneFn } from './types';
/**
* Checks if the provided value is a JavaScript function or a class constructor.
* You should first check that `typeof functionOrClass === 'function'`
*
* @param {any} functionOrClass - value known to be either a function or class
* @returns {boolean} - `true` if the value is a function, false if it is a class
*/
export const isFunction = (functionOrClass: any): boolean => {
const propertyNames = Object.getOwnPropertyNames(functionOrClass);
return !propertyNames.includes('prototype') || propertyNames.includes('arguments');
};
/**
* Deep clones an object or array, creating a new object with the
* same structure and values.
*
* @param {T} obj - The object or array to deep clone.
* @returns {T} - A deep clone of the input object or array.
*/
const objectClone: CloneFn = <T>(obj: T): T => {
if (!obj || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
// Empty array doesn't clone properly in Jest with just map
return (obj.length === 0 ? [] : [...obj.map(objectClone)]) as T;
}
const cloneObj: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
cloneObj[key] = objectClone(value);
}
return cloneObj as T;
};
/**
* Deepclones the return type of a function
*
* A cloned function here means that arrays and objects are repacked at runtime
* through deepcloining such that the `expect.toStrictEqual` matcher from jest
* can accurately compare, essentially removing jest comparison false-negative
* issues relating to "serialises to the same string".
*
* @param fn - The function to be jewirified.
* @param clone - The deep cloning function (defaulting to `deepClone`).
* @returns A jewirified function.
*/
const functionClone = <T extends (...args: any[]) => any>(fn: T, clone: CloneFn) => {
/**
* Defines a new wrapper function that deep-clones the return value at run time
*
* @param args the arguments to be forwarded to the functions we are cloning
* @returns the results of the function, deep copied at run time.
*/
const wrapperClonedFunction = (
...args: Parameters<T>
): ReturnType<T> => {
const result = fn(...args);
return result && typeof result === 'object' ? clone(result) : result;
};
Object.defineProperty(wrapperClonedFunction, 'name', {
value: fn.name,
writable: false,
enumerable: false,
configurable: true,
});
return wrapperClonedFunction;
};
/**
* For each of the method in the class, apply function/object clone at run time
* for Jest expect.toStrictEqual to not return false negatives
*
* Based off this answer: https://stackoverflow.com/a/70710396/22324694
* @param target object instance to decorate methods around
*/
function decorateClassMethodClone(target: any, clone: CloneFn) {
/**
* Ensure that the return values of all objects are cloned
*
* @param obj object whose method return values need to be cloned
* @param key name of the method whose return values will be cloned
*/
const decorateMethod = (obj: Record<string, any>, key: string | symbol): void => {
const descriptor = Reflect.getOwnPropertyDescriptor(obj, key);
/* istanbul ignore next */
if (!descriptor?.configurable) {
return;
}
const { value } = descriptor;
if (typeof value === 'function' && value !== target) {
descriptor.value = function (...args: any[]) {
return entityClone(value.apply(this, args), clone);
};
Object.defineProperty(obj, key, descriptor);
}
};
// Decorate static methods
Object.getOwnPropertyNames(target)
.filter((key) => !['length', 'name', 'prototype'].includes(key))
.forEach(key => decorateMethod(target, key));
// Decorate instance methods
Reflect.ownKeys(target.prototype)
.filter(key => key !== 'constructor')
.forEach(key => decorateMethod(target.prototype, key));
return target;
}
/**
* Deeply clones a class object, preserving the prototype chain.
* - Modified from https://stackoverflow.com/a/43753414/22324694
*
* @template T
* @param {T} obj - The object to clone
* @returns {T} - A deep clone of the input object
* @throws {Error} - An unsupported data type is encountered
*/
/* istanbul ignore next */
const classClone = <T>(obj: T, objClone: CloneFn): T => {
if (obj ?? typeof obj !== 'object') {
return decorateClassMethodClone(obj as any, objClone);
}
const props = Object.getOwnPropertyDescriptors(obj);
for (const prop of Object.keys(props)) {
props[prop].value = classClone(props[prop].value, objClone);
}
return Object.create(Object.getPrototypeOf(obj), props);
};
/**
* Helper function to clone functions or classes
*
* @param functionOrClass the functions or class to clone
* @param objClone
* @returns the cloned function or class
*/
const functionOrClassClone = (functionOrClass: any, objClone: CloneFn) =>
isFunction(functionOrClass)
? functionClone(functionOrClass, objClone)
: classClone(functionOrClass, objClone);
/**
* Clones an entity for use with Jest expect.toStrictEqual
*
* @param entity the entity to clone, including variables/functions/classes
* @param objClone custom function to clone objects/arrays
* @returns the cloned entity
*/
function entityClone(entity: any, objClone = objectClone) {
return typeof entity === 'function'
? functionOrClassClone(entity, objClone)
: objClone(entity);
}
export default entityClone;