Skip to content

Instantly share code, notes, and snippets.

@nberlette
Last active May 25, 2024 16:36
Show Gist options
  • Save nberlette/72f434709e74d32b19960ae520916c47 to your computer and use it in GitHub Desktop.
Save nberlette/72f434709e74d32b19960ae520916c47 to your computer and use it in GitHub Desktop.
TemporalProxy
// deno-lint-ignore-file ban-types
import { is } from "jsr:@type/is@0.1.0";
/**
* 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