@jsenv/snapshot
Version:
Snapshot testing
285 lines (281 loc) • 7.81 kB
JavaScript
const jsenvMethodProxySymbol = Symbol.for("jsenv_method_proxy");
export const hookIntoMethod = (
object,
method,
initCallback,
{ execute = METHOD_EXECUTION_STANDARD } = {},
) => {
const current = object[method];
if (typeof current !== "function") {
console.warn(`"${method}" is not a function on object, found ${current}`);
return {
disable: () => {},
enable: () => {},
remove: () => {},
};
}
const jsenvSymbolValue = current[jsenvMethodProxySymbol];
let addInitCallback;
let removeInitCallback;
if (jsenvSymbolValue) {
addInitCallback = jsenvSymbolValue.addInitCallback;
removeInitCallback = jsenvSymbolValue.removeInitCallback;
} else {
const original = current;
let allWantsToPreventOriginalCall;
let hookExecuting;
const initCallbackSet = new Set();
const callHooks = (hookCallbackSet, ...args) => {
hookExecuting = true;
for (const hookCallback of hookCallbackSet) {
if (!hookCallback.initCallback.installed) {
continue;
}
hookCallback(...args);
}
hookExecuting = false;
hookCallbackSet.clear();
};
const proxy = function (...args) {
if (hookExecuting) {
// when a spy is executing
// if it calls the method himself
// then we want this call to go trough
// and others spy should not know about it
return original.call(this, ...args);
}
allWantsToPreventOriginalCall = undefined;
const returnPromiseCallbackSet = new Set();
const returnCallbackSet = new Set();
const catchCallbackSet = new Set();
const finallyCallbackSet = new Set();
hookExecuting = true;
for (const initCallback of initCallbackSet) {
if (initCallback.disabled) {
continue;
}
const hooks = initCallback(...args) || {};
if (hooks.preventOriginalCall) {
if (allWantsToPreventOriginalCall === undefined) {
allWantsToPreventOriginalCall = true;
}
} else {
allWantsToPreventOriginalCall = false;
}
if (hooks.returnPromise) {
hooks.returnPromise.initCallback = initCallback;
returnPromiseCallbackSet.add(hooks.returnPromise);
}
if (hooks.return) {
hooks.return.initCallback = initCallback;
returnCallbackSet.add(hooks.return);
}
if (hooks.catch) {
hooks.catch.initCallback = initCallback;
catchCallbackSet.add(hooks.catch);
}
if (hooks.finally) {
hooks.finally.initCallback = initCallback;
finallyCallbackSet.add(hooks.catch);
}
}
hookExecuting = false;
const onCatch = (valueThrown) => {
returnCallbackSet.clear();
callHooks(catchCallbackSet, valueThrown);
};
const onReturn = (...values) => {
returnPromiseCallbackSet.clear();
catchCallbackSet.clear();
callHooks(returnCallbackSet, ...values);
};
const onReturnPromise = () => {
callHooks(returnPromiseCallbackSet);
};
const onFinally = () => {
callHooks(finallyCallbackSet);
};
if (allWantsToPreventOriginalCall) {
onReturn(undefined);
onFinally();
return undefined;
}
return execute({
original,
thisValue: this,
args,
onCatch,
onReturn,
onReturnPromise,
onFinally,
});
};
addInitCallback = (initCallback) => {
if (initCallbackSet.size === 0) {
object[method] = proxy;
}
initCallbackSet.add(initCallback);
};
removeInitCallback = (initCallback) => {
initCallbackSet.delete(initCallback);
if (initCallbackSet.size === 0) {
object[method] = original;
}
};
proxy[jsenvMethodProxySymbol] = {
addInitCallback,
removeInitCallback,
original,
};
object[method] = proxy;
}
initCallback.installed = true;
addInitCallback(initCallback);
const hook = {
disable: () => {
initCallback.disabled = true;
},
enable: () => {
initCallback.disabled = false;
},
remove: () => {
initCallback.installed = false;
removeInitCallback(initCallback);
},
};
return hook;
};
export const METHOD_EXECUTION_STANDARD = ({
original,
thisValue,
args,
onCatch,
onFinally,
onReturnPromise,
onReturn,
}) => {
let valueReturned;
let thrown = false;
let valueThrown;
try {
valueReturned = original.call(thisValue, ...args);
} catch (e) {
thrown = true;
valueThrown = e;
}
if (thrown) {
onCatch(valueThrown);
onFinally();
throw valueThrown;
}
if (valueReturned && typeof valueReturned.then === "function") {
onReturnPromise();
valueReturned.then(
(valueResolved) => {
onReturn(valueResolved);
onFinally();
},
(valueRejected) => {
onCatch(valueRejected);
onFinally();
},
);
return valueReturned;
}
onReturn(valueReturned);
onFinally();
return valueReturned;
};
export const METHOD_EXECUTION_NODE_CALLBACK = ({
original,
thisValue,
args,
onCatch,
onFinally,
onReturnPromise,
onReturn,
}) => {
const lastArgIndex = args.length - 1;
const lastArg = args[lastArgIndex];
const installProxyAndCall = (originalCallback, installCallbackProxy) => {
const callbackProxy = function (...callbackArgs) {
uninstallCallbackProxy();
const [error, ...remainingArgs] = callbackArgs;
if (error) {
onCatch(error);
originalCallback.call(this, ...callbackArgs);
onFinally();
return;
}
onReturn(...remainingArgs);
originalCallback.call(this, ...callbackArgs);
onFinally();
};
const uninstallCallbackProxy = installCallbackProxy(callbackProxy);
try {
return original.call(thisValue, ...args);
} catch (e) {
onCatch(e);
onFinally();
throw e;
}
};
if (typeof lastArg === "function") {
return installProxyAndCall(lastArg, (callbackProxy) => {
args[lastArgIndex] = callbackProxy;
return () => {
// useless because are a copy of the args
// so the mutation we do above ( args[lastArgIndex] =)
// cannot be important for the method being proxied
if (args[lastArgIndex] === callbackProxy) {
args[lastArgIndex] = lastArg;
}
};
});
}
if (lastArg && typeof lastArg === "object") {
if (lastArg.context && typeof lastArg.context.callback === "function") {
const originalCallback = lastArg.context.callback;
return installProxyAndCall(originalCallback, (callbackProxy) => {
lastArg.context.callback = callbackProxy;
return () => {
if (lastArg.context.callback === callbackProxy) {
lastArg.context.callback = originalCallback;
}
};
});
}
if (typeof lastArg.oncomplete === "function") {
const originalCallback = lastArg.oncomplete;
return installProxyAndCall(originalCallback, (callbackProxy) => {
lastArg.oncomplete = callbackProxy;
return () => {
if (lastArg.oncomplete === callbackProxy) {
lastArg.oncomplete = originalCallback;
}
};
});
}
}
return METHOD_EXECUTION_STANDARD({
original,
thisValue,
args,
onCatch,
onFinally,
onReturnPromise,
onReturn,
});
};
export const disableHooksWhileCalling = (fn, hookToDisableArray) => {
for (const toDisable of hookToDisableArray) {
toDisable.disable();
}
try {
return fn();
} finally {
for (const toEnable of hookToDisableArray) {
toEnable.enable();
}
}
};