Last active
May 25, 2024 16:36
-
-
Save nberlette/72f434709e74d32b19960ae520916c47 to your computer and use it in GitHub Desktop.
TemporalProxy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// deno-lint-ignore-file ban-types | |
import { is } from "jsr:@type/[email protected]"; | |
/** | |
* Simple abstraction using the native `Proxy` and `Proxy.revocable` APIs to | |
* create temporary, "restorable" proxies of a given target object. This is | |
* particularly well-suited for testing and mocking purposes, allowing one to | |
* create a virtually indistinguishable proxy of an object or function that is | |
* to be tested, apply custom spying / mocking logic, and still be capable of | |
* restoring the original object's functionality once the test is complete. | |
* | |
* Calling the `restore` method on the TemporalProxy instance will internally | |
* revoke the proxy and set the `restored` property to `true`; once that flag | |
* has been set it cannot be changed, and all subsequent operations will be | |
* routed through the corresponding `Reflect` method instead, bypassing the | |
* proxy handler entirely to access the original object. | |
* | |
* Despite all the traps being re-routed to directly affect the target object, | |
* it is still worth noting that the original object and the proxy object are | |
* distinct entities; a reference to the original underlying target object is | |
* available at any time via the `.original` property. | |
* | |
* @template T The type of the target object. | |
* @category Testing Utilities | |
*/ | |
export class TemporalProxy<const T extends object> { | |
#target: T; | |
#handler: ProxyHandler<T> | null; | |
#restored = false; | |
#proxy: T; | |
/** | |
* Create a new TemporalProxy instance. | |
* @param target The original object to proxy. | |
* @param handler The proxy handler to use. | |
*/ | |
constructor(target: T, handler: ProxyHandler<T>) { | |
this.#target = target; | |
this.#handler = handler; | |
const restored = () => this.#restored; | |
const $trap = <T extends object, K extends keyof ProxyHandler<T>>( | |
handler: ProxyHandler<T>, | |
k: K, | |
...args: Parameters<ProxyHandler<T>[K] & {}> | |
): ReturnType<ProxyHandler<T>[K] & {}> => { | |
const o = (restored() || !handler[k]) ? Reflect : handler; | |
// deno-lint-ignore no-explicit-any | |
return o[k as keyof typeof o](...args); | |
}; | |
this.#proxy = new Proxy(target, { | |
has: (...args) => $trap(handler, "has", ...args), | |
get: (...args) => $trap(handler, "get", ...args), | |
set: (...args) => $trap(handler, "set", ...args), | |
apply: (...args) => $trap(handler, "apply", ...args), | |
construct: (...args) => $trap(handler, "construct", ...args), | |
defineProperty: (...args) => $trap(handler, "defineProperty", ...args), | |
deleteProperty: (...args) => $trap(handler, "deleteProperty", ...args), | |
getOwnPropertyDescriptor: (...args) => | |
$trap(handler, "getOwnPropertyDescriptor", ...args), | |
ownKeys: (...args) => $trap(handler, "ownKeys", ...args), | |
getPrototypeOf: (...args) => $trap(handler, "getPrototypeOf", ...args), | |
setPrototypeOf: (...args) => $trap(handler, "setPrototypeOf", ...args), | |
isExtensible: (...args) => $trap(handler, "isExtensible", ...args), | |
preventExtensions: (...args) => | |
$trap(handler, "preventExtensions", ...args), | |
}); | |
// this.#revoke = revoke; | |
} | |
public get restored(): boolean { | |
return this.#restored; | |
} | |
/** Restore the original object's functionality. */ | |
public restore(): void { | |
if (!this.restored) { | |
// this.#revoke(); | |
this.#restored = true; | |
} | |
} | |
/** | |
* Get the proxy object. | |
* @returns The proxy object. | |
*/ | |
public get proxy(): T { | |
return this.#proxy; | |
} | |
/** | |
* Get the original object. | |
* @returns The original object. | |
*/ | |
public get original(): T { | |
return this.#target; | |
} | |
/** | |
* Get the proxy handler. | |
* @returns The proxy handler. | |
*/ | |
public get handler(): ProxyHandler<T> | null { | |
return this.#handler; | |
} | |
/** | |
* Get the current target object, which will either be the proxy object or | |
* the original object depending on whether the {@linkcode restore} method | |
* has been called or not. This is the recommend access point for the target | |
* object, as it will automatically route the operation through the correct | |
* path based on the current state of the proxy, and will also return the | |
* original underlying object once it is restored. | |
*/ | |
public get target(): T { | |
return this.restored ? this.#target : this.#proxy; | |
} | |
} | |
export class SpyCallTiming< | |
// deno-lint-ignore no-explicit-any | |
Target extends (...args: any[]) => any = any, | |
> { | |
static readonly precision = 3; | |
public readonly label: string; | |
public readonly start: number = NaN; | |
public readonly end: number = NaN; | |
constructor(target: Target, start = SpyCallTiming.now()) { | |
this.start = start; | |
this.label = `timing:${target.name || "anonymous"}(${target.length})`; | |
} | |
get duration(): number { | |
const end = isNaN(this.end) ? SpyCallTiming.now() : this.end; | |
return end - this.start; | |
} | |
complete(): this { | |
if (isNaN(this.end) && !Object.isFrozen(this)) { | |
const end = SpyCallTiming.now(); | |
const value = end - this.start; | |
Object.assign(this, { end }); | |
Object.defineProperty(this, "duration", { value, enumerable: true }); | |
Object.freeze(this); | |
} | |
return this; | |
} | |
/** | |
* The time origin of the current context (in milliseconds). This is a | |
* measure of the time elapsed since the UNIX epoch, taken at the moment the | |
* script started running. It is added to the relative time returned by the | |
* `performance.now()` for an absolute high resolution timestamp. */ | |
static get timeOrigin(): number { | |
return performance.timeOrigin; | |
} | |
/** | |
* Get the current high resolution timestamp, relative to | |
* {@link timeOrigin}. By default, the origin is set to | |
* {@link SpyCallTiming.timeOrigin}, but it can be overridden with a custom | |
* value at runtime if needed. | |
* | |
* The timestamp is formatted with the {@link SpyCallTiming.format} method, | |
* to a default precision of 3 decimal places. | |
*/ | |
static now(timeOrigin = SpyCallTiming.timeOrigin): number { | |
return SpyCallTiming.format(performance.now() + timeOrigin); | |
} | |
/** | |
* Format a high resolution timestamp to a fixed precision. The default | |
* level of precision is defined by {@link SpyCallTiming.precision} (default | |
* 3). */ | |
static format(time: number, precision = SpyCallTiming.precision): number { | |
return parseFloat(time.toFixed(precision)); | |
} | |
} | |
export class SpyCall< | |
// deno-lint-ignore no-explicit-any | |
Target extends FnOrConstructor<This, Args, Result> = any, | |
This = void, | |
const Args extends readonly unknown[] = readonly unknown[], | |
Result = unknown, | |
> { | |
constructor( | |
/** The target function being called. */ | |
public readonly target: Target, | |
/** The arguments passed to the target function. */ | |
public readonly args: Args = [] as Args, | |
/** The `this` context of the target function. */ | |
public readonly thisArg: This = void 0 as This, | |
/** The result of the target function. */ | |
public result: Result | undefined = void 0 as Result, | |
/** The error thrown by the target function. */ | |
public error: Error | undefined = void 0 as Error, | |
/** The timing information of the call. */ | |
public readonly timing: SpyCallTiming = new SpyCallTiming(target), | |
) {} | |
#completed = false; | |
/** Indicates whether this call has been completed. */ | |
get completed(): boolean { | |
return this.#completed; | |
} | |
/** Marks this call as complete, stops its timer, and freezes it. */ | |
complete(freeze?: boolean): this { | |
if (!this.#completed) { | |
this.timing.complete(); | |
if (freeze) this.freeze(); | |
} | |
return this; | |
} | |
/** Freeze this call, preventing further modification. */ | |
freeze(): this { | |
if (Object.isFrozen(this)) { | |
throw new ReferenceError("Cannot freeze an object that is already frozen."); | |
} | |
if (!Object.isFrozen(this.timing)) Object.freeze(this.timing); | |
if (!Object.isFrozen(this.args)) Object.freeze(this.args); | |
if (this.error && !Object.isFrozen(this.error)) { | |
Object.freeze(this.error); | |
} | |
if (this.result && !Object.isFrozen(this.result)) { | |
Object.freeze(this.result); | |
} | |
Object.freeze(this); | |
return this; | |
} | |
} | |
export class SpyConstructorCall< | |
// deno-lint-ignore no-explicit-any | |
Target extends new (...args: Args) => Result = any, | |
NewTarget extends new (...args: any) => any = Target, | |
const Args extends readonly unknown[] = ConstructorParameters<Target>, | |
Result = InstanceType<Target>, | |
> extends SpyCall<Target, NewTarget, Args, Result> { | |
constructor( | |
target: Target, | |
args: Args = [] as Args, | |
public readonly newTarget: NewTarget = target as NewTarget, | |
public result: Result | undefined = void 0 as Result, | |
public error: Error | undefined = void 0 as Error, | |
timing: SpyCallTiming = new SpyCallTiming(target), | |
) { | |
super(target, args, newTarget, result, error, timing); | |
} | |
/** Get the instance created by the target constructor. */ | |
get instance(): Result | undefined { | |
return this.result; | |
} | |
/** Create a new instance of the target constructor. */ | |
new(): Result { | |
const { target, newTarget, args } = this; | |
return this.result ??= Reflect.construct(target, args, newTarget); | |
} | |
} | |
// Real-world use case: mocking a function for testing purposes | |
// deno-lint-ignore no-explicit-any | |
type FnOrConstructor<T = any, A extends readonly unknown[] = any[], R = any> = | |
| ((this: T | void, ...args: A) => R) | |
| ((new (...args: A) => R) & ThisType<T>); | |
// deno-lint-ignore no-explicit-any | |
class MockProxy<T extends FnOrConstructor = any> extends TemporalProxy<T> { | |
public readonly calls: SpyCall[] = []; | |
public readonly instances: unknown[] = []; | |
constructor(original: T) { | |
// deno-lint-ignore no-this-alias | |
const self = this; | |
const handler = { | |
apply: (t, thisArg, args) => { | |
const call = new SpyCall(t, args, thisArg); | |
try { | |
return call.result = Reflect.apply(t, thisArg, args); | |
} catch (err) { | |
const error = is.error(err) ? err : new Error(String(err)); | |
Error.captureStackTrace?.(error, handler.apply); | |
error.stack?.slice(); | |
throw call.error = error; | |
} finally { | |
self.calls.push(call.complete()); | |
} | |
}, | |
construct: (target, args, newTarget) => { | |
const call = new SpyConstructorCall(target, args, newTarget); | |
try { | |
return call.result = Reflect.construct(target, args, newTarget); | |
} catch (err) { | |
const error = is.error(err) ? err : new Error(String(err)); | |
Error.captureStackTrace?.(error, handler.construct); | |
error.stack?.slice(); | |
throw call.error = error; | |
} finally { | |
self.calls.push(call.complete()); | |
self.instances.push(call.result); | |
} | |
}, | |
} as const satisfies ProxyHandler<T>; | |
super(original, handler); | |
} | |
get errors(): Error[] { | |
return this.calls.map((c) => c.error).filter((e): e is Error => e != null); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment