From 8a8fa8e52070a3efb922ce99cd608479d17b7ebb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 13:19:05 +0200 Subject: [PATCH 1/2] feat: change how wrap decorator functions is passed --- CLAUDE.md | 5 +- README.md | 66 ++++------ src/effect.decorator.ts | 63 +++++++--- src/hook.types.ts | 48 +++---- src/index.ts | 3 +- src/wrap-on-class.ts | 6 +- src/wrap-on-method.ts | 112 +++++++---------- src/wrap.decorator.ts | 18 +-- tests/Effect.spec.ts | 53 +++----- tests/Wrap.spec.ts | 198 +++++++++++++---------------- tests/WrapOnClass.spec.ts | 184 +++++++++++++-------------- tests/WrapOnMethod.spec.ts | 245 +++++++++++++++--------------------- tests/hook-types.spec.ts | 119 ++++-------------- tests/wrapFunction.spec.ts | 252 ++++++++++++------------------------- 14 files changed, 544 insertions(+), 828 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 77a7532..efdcfcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,5 +14,6 @@ This project uses **semantic-release** with **Conventional Commits**. Follow the **Core flow:** -1. `Effect` / `EffectOnMethod` / `EffectOnClass` (src/) — Logger-agnostic decorator primitives. `EffectOnMethod` wraps a single method: extracts parameter names, builds a `HookContext` (args object, target, propertyKey, descriptor, parameterNames, className), and invokes lifecycle hooks. `EffectOnClass` iterates prototype methods and applies `EffectOnMethod` to each. `Effect` dispatches to one or the other based on argument count. -3. `buildArgsObject` (src/effect-on-method.ts) — Maps parameter names to their call-time values to produce the pre-built `args` object passed in every `HookContext`. +1. `Wrap` / `WrapOnMethod` / `WrapOnClass` (src/) — Foundational decorator primitives. `WrapOnMethod` wraps a single method with lazy initialization: the factory runs once on first invocation with a method proxy and `WrapContext`. `WrapOnClass` iterates prototype methods and applies `WrapOnMethod` to each. `Wrap` dispatches to one or the other based on argument count. +2. `Effect` (src/effect.decorator.ts) — Higher-level abstraction built on `Wrap` that provides lifecycle hooks (onInvoke, onReturn, onError, finally). Builds a `HookContext` (args, argsObject, target, propertyKey, descriptor, parameterNames, className) per invocation. +3. `buildArgsObject` (src/effect.decorator.ts) — Maps parameter names to their call-time values to produce the pre-built `argsObject` passed in every `HookContext`. diff --git a/README.md b/README.md index b3540f5..2cbb4e3 100644 --- a/README.md +++ b/README.md @@ -50,17 +50,17 @@ npm install base-decorators ### Using Wrap -`Wrap` is the foundational primitive. You receive a `WrapContext` at decoration time and return a replacement function: +`Wrap` is the foundational primitive. You receive the original method and a `WrapContext` on first invocation and return a replacement function: ```typescript import { Wrap } from 'base-decorators'; -import type { WrapContext, InvocationContext } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; -const Log = () => Wrap((context: WrapContext) => { - // Outer function: called once at decoration time +const Log = () => Wrap((method, context: WrapContext) => { + // Outer function: called once on first invocation console.log('decorating', context.propertyKey); - return (method, {args}) => { + return (...args) => { // Inner function: called on every invocation console.log('called with', args); @@ -77,10 +77,10 @@ class Calculator { return a + b; } } -// logs: "decorating add" (at decoration time) const calc = new Calculator(); calc.add(2, 3); +// logs: "decorating add" (first time) // logs: "called with [2, 3]" // logs: "returned 5" @@ -114,17 +114,17 @@ calc.add(2, 3); ## How It Works -**`Wrap`** accepts a factory function that receives a `WrapContext` at decoration time and returns an inner function. The inner function is called on every invocation with the `this`-bound original method and an `InvocationContext`. You control the entire execution flow: +**`Wrap`** accepts a factory function that receives the original method and a `WrapContext` on first invocation and returns an inner function. The inner function is called on every invocation with the raw arguments. You control the entire execution flow: ```typescript import { Wrap } from 'base-decorators'; -import type { WrapContext, InvocationContext } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; -const Log = () => Wrap((context: WrapContext) => { - // Outer: called once at decoration time. WrapContext has propertyKey, parameterNames, descriptor. - return (method, { args, className }: InvocationContext) => { - // Inner: called on every invocation. InvocationContext extends WrapContext with target, className, args, argsObject. - console.log(`${className}.${String(context.propertyKey)} called`); +const Log = () => Wrap((method, context: WrapContext) => { + // Outer: called once on first invocation. + return (...args) => { + // Inner: called on every invocation with raw arguments. + console.log(`${context.className}.${String(context.propertyKey)} called`); return method(...args); }; }); @@ -216,10 +216,10 @@ All decorators work naturally with async methods. Return an async inner function ```typescript import { Wrap } from 'base-decorators'; -import type { WrapContext, InvocationContext } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; -const AsyncTimer = () => Wrap((context: WrapContext) => { - return async (method, { args }) => { +const AsyncTimer = () => Wrap((method, context: WrapContext) => { + return async (...args) => { const start = Date.now(); const result = await method(...args); @@ -420,38 +420,27 @@ Each hook receives a context object. All hooks are optional. Each hook has a cor ### WrapContext -Available in the **outer** factory function passed to `Wrap`. Contains only decoration-time fields -- fields that vary per call (target, className, args) are absent here. +Passed to the **outer** factory function of `Wrap` on first invocation. Contains decoration-time fields plus mutable runtime fields (`target`, `className`) that update before each call. ```typescript interface WrapContext { propertyKey: string | symbol; // method name parameterNames: string[]; // extracted parameter names descriptor: PropertyDescriptor; // method descriptor + target: object; // class instance (this), updated per call + className: string; // runtime class name, updated per call } ``` -### InvocationContext +### HookContext -Passed to the **inner** function returned by your `Wrap` factory. Extends `WrapContext` with per-call runtime fields, so all decoration-time fields are also available here. +Passed to every `Effect` lifecycle hook. Extends `WrapContext` with per-call argument data. ```typescript -interface InvocationContext extends WrapContext { - target: object; // class instance (this) - className: string; // runtime class name +interface HookContext extends WrapContext { args: unknown[]; // raw arguments argsObject: Record | undefined; // mapped parameter names - // Plus all WrapContext fields: propertyKey, parameterNames, descriptor -} -``` - -### HookContext - -Passed to every `Effect` lifecycle hook. Equivalent to `InvocationContext` -- all seven fields are available. - -```typescript -interface HookContext extends InvocationContext { - // All fields from WrapContext: propertyKey, parameterNames, descriptor - // All fields from InvocationContext: target, className, args, argsObject + // Plus all WrapContext fields: propertyKey, parameterNames, descriptor, target, className } ``` @@ -502,7 +491,7 @@ class DebugService { } ``` -The factory is called **once at decoration time** with the `WrapContext` containing `propertyKey`, `parameterNames`, and `descriptor`. The resolved hooks are reused for every subsequent call. Each resolved hook still receives the full `HookContext` (including `args`, `argsObject`, `target`, and `className`) on every invocation. +The factory is called **once on first invocation** with the `WrapContext` containing `propertyKey`, `parameterNames`, `descriptor`, `target`, and `className`. The resolved hooks are reused for every subsequent call. Each resolved hook still receives the full `HookContext` (including `args` and `argsObject`) on every invocation. ## API Reference @@ -517,10 +506,9 @@ The factory is called **once at decoration time** with the `WrapContext` contain | `OnReturnHook` | Decorator | Convenience hook for `onReturn` | | `OnErrorHook` | Decorator | Convenience hook for `onError` | | `FinallyHook` | Decorator | Convenience hook for `finally` | -| `WrapContext` | Type | Decoration-time context passed to `Wrap` outer factory (propertyKey, parameterNames, descriptor) | -| `InvocationContext` | Type | Per-call context extending `WrapContext` with runtime fields (target, className, args, argsObject) | -| `WrapFn` | Type | Wrapper function signature: `(context: WrapContext) => (method, context: InvocationContext) => R` | -| `HookContext` | Type | Context passed to `Effect` hooks -- equivalent to `InvocationContext` with all fields | +| `WrapContext` | Type | Context passed to `Wrap` factory (propertyKey, parameterNames, descriptor, target, className) | +| `WrapFn` | Type | Wrapper function signature: `(method, context: WrapContext) => (...args) => R` | +| `HookContext` | Type | Context passed to `Effect` hooks -- extends `WrapContext` with args, argsObject | | `EffectHooks` | Type | Lifecycle hooks object for `Effect` (onInvoke, onReturn, onError, finally) | ## Advanced Example diff --git a/src/effect.decorator.ts b/src/effect.decorator.ts index 5d0d1e5..2e705b7 100644 --- a/src/effect.decorator.ts +++ b/src/effect.decorator.ts @@ -3,7 +3,6 @@ import type { EffectHooks, HookContext, HooksOrFactory, - InvocationContext, UnwrapPromise, WrapContext, } from './hook.types'; @@ -24,8 +23,8 @@ import type { * @typeParam R - The return type expected from lifecycle hooks * @param hooks - Lifecycle callbacks (all optional) or a factory * function that receives a {@link WrapContext} and - * returns hooks. The factory is called **once at - * decoration time**. The resolved hooks are reused + * returns hooks. The factory is called **once on + * first invocation**. The resolved hooks are reused * for every subsequent call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default @@ -55,20 +54,16 @@ export const Effect = ( hooks: HooksOrFactory, exclusionKey?: symbol, ): ClassDecorator & MethodDecorator => - Wrap((wrapContext: WrapContext) => { - // Resolve hooks ONCE at decoration time. - // When hooks is a factory, it receives decoration-time WrapContext. + Wrap((method: (...args: unknown[]) => unknown, wrapContext: WrapContext) => { const resolvedHooks = resolveHooks(hooks, wrapContext); - return ( - boundMethod: (...args: unknown[]) => unknown, - invocationContext: InvocationContext, - ): unknown => { - const hookContext: HookContext = { ...invocationContext }; + return (...args: unknown[]): unknown => { + const argsObject = buildArgsObject(wrapContext.parameterNames, args); + const hookContext: HookContext = { ...wrapContext, args, argsObject }; const executeMethod = attachHooks( - boundMethod, - invocationContext.args, + method, + args, hookContext, resolvedHooks, ); @@ -85,6 +80,40 @@ export const Effect = ( }; }, exclusionKey); +/** + * Builds an object mapping parameter names to their call-time values. + * + * Creates a record where keys are parameter names and values are the + * corresponding argument values passed to the function. Returns + * `undefined` when both arrays are empty. + * + * @param parameterNames - Array of parameter names from the function signature + * @param args - Array of argument values from the current invocation + * @returns Object mapping parameter names to values, or undefined when empty + * + * @example + * buildArgsObject(['id', 'name'], [1, 'John']) + * // Returns: { id: 1, name: 'John' } + */ +export const buildArgsObject = ( + parameterNames: string[], + args: unknown[], +): Record | undefined => { + if (args.length === 0 && parameterNames.length === 0) { + return undefined; + } + + const argsObject: Record = {}; + + parameterNames.forEach((paramName, index) => { + if (index < args.length) { + argsObject[paramName] = args[index]; + } + }); + + return argsObject; +}; + /** * Returns a thunk that runs the bound method and applies sync/async lifecycle hooks. * @@ -93,13 +122,13 @@ export const Effect = ( * `onReturn` or `onError` throw. */ const attachHooks = ( - boundMethod: (...args: unknown[]) => unknown, + method: (...args: unknown[]) => unknown, args: unknown[], context: HookContext, hooks: EffectHooks, ): (() => unknown) => () => { try { - const result = boundMethod(...args); + const result = method(...args); if (result instanceof Promise) { return chainAsyncHooks(result, context, hooks); @@ -129,8 +158,8 @@ const attachHooks = ( * Resolves hooks from a static object or factory function. * * When `hooksOrFactory` is a function, it is called with the provided - * decoration-time context to produce the hooks. Otherwise, the static - * hooks are returned as-is. + * context to produce the hooks. Otherwise, the static hooks are returned + * as-is. */ const resolveHooks = ( hooksOrFactory: HooksOrFactory, diff --git a/src/hook.types.ts b/src/hook.types.ts index c953039..1115b1d 100644 --- a/src/hook.types.ts +++ b/src/hook.types.ts @@ -2,11 +2,12 @@ export type HookArgs = Record | undefined; /** - * Decoration-time context available to every wrapper factory. + * Context available to every wrapper factory. * - * Contains only the fields known at decoration time. Runtime fields - * (target, className) and per-call argument data are provided - * separately via {@link InvocationContext} and {@link HookContext}. + * Contains decoration-time fields (propertyKey, parameterNames, descriptor) + * plus mutable runtime fields (target, className) that update before each + * method invocation. The factory receives this context on first call and + * retains a reference; target/className always reflect the current caller. */ export interface WrapContext { /** The property key of the decorated method. */ @@ -15,46 +16,37 @@ export interface WrapContext { parameterNames: string[]; /** The property descriptor of the decorated method. */ descriptor: PropertyDescriptor; -} - -/** - * Per-call context passed to the inner function returned by a {@link WrapFn}. - * - * Extends {@link WrapContext} with runtime fields that change on each - * invocation: the `this` target, the derived class name, and the - * raw/mapped arguments. - */ -export interface InvocationContext extends WrapContext { - /** The `this` target object (class instance). */ + /** The `this` target object (class instance). Updated before each call. */ target: object; - /** Runtime class name derived from `this.constructor.name`. */ + /** Runtime class name derived from `this.constructor.name`. Updated before each call. */ className: string; - /** Raw arguments array passed to the method. */ - args: unknown[]; - /** Pre-built args object mapping parameter names to their values. */ - argsObject: HookArgs; } /** * Factory function accepted by the Wrap decorator. * - * Called **once at decoration time** with a {@link WrapContext}. Returns an - * inner function that is called on every invocation with the `this`-bound - * original method and an {@link InvocationContext}. + * Called **once on first method invocation** with the `this`-bound + * original method and a {@link WrapContext}. Returns an inner function + * that is called on every invocation with the raw arguments. * * @typeParam R - The return type produced by the inner function */ export type WrapFn = ( + method: (...args: unknown[]) => unknown, context: WrapContext, -) => (method: (...args: unknown[]) => unknown, context: InvocationContext) => R; +) => (...args: unknown[]) => R; /** * Shared context passed to every lifecycle hook. * - * Equivalent to {@link InvocationContext} which already includes all - * {@link WrapContext} fields plus per-call runtime data. + * Extends {@link WrapContext} with per-call argument data. */ -export interface HookContext extends InvocationContext {} +export interface HookContext extends WrapContext { + /** Raw arguments array passed to the method. */ + args: unknown[]; + /** Pre-built args object mapping parameter names to their values. */ + argsObject: HookArgs; +} /** Extracts the resolved type from a Promise, or returns the type itself. */ export type UnwrapPromise = T extends Promise ? U : T; @@ -138,7 +130,7 @@ export interface EffectHooks { * Accepts either a static hooks object or a factory function that * produces hooks from the decoration-time context. * - * When a factory is provided, it is called **once at decoration time** + * When a factory is provided, it is called **once on first invocation** * with the {@link WrapContext}. The resolved hooks are reused for * every subsequent call. * diff --git a/src/index.ts b/src/index.ts index 21b6d16..18115e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ // Internal (not exported): wrap-on-class.ts -export { wrapMethod as wrapFunction, buildArgsObject } from './wrap-on-method'; -export type { WrapMethodOptions } from './wrap-on-method'; +export { WRAP_KEY, type WrapMethodOptions } from './wrap-on-method'; export * from './wrap.decorator'; export * from './effect.decorator'; diff --git a/src/wrap-on-class.ts b/src/wrap-on-class.ts index 0092bb6..e7ccc70 100644 --- a/src/wrap-on-class.ts +++ b/src/wrap-on-class.ts @@ -37,9 +37,9 @@ import { WrapOnMethod, WRAP_KEY } from './wrap-on-method'; * ```ts * const LOG_KEY = Symbol('log'); * - * \@WrapOnClass((ctx) => (method, invCtx) => { - * console.log(`${invCtx.className}.${String(ctx.propertyKey)} called`); - * return method(...invCtx.args); + * \@WrapOnClass((method, ctx) => (...args) => { + * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); + * return method(...args); * }, LOG_KEY) * class Service { * doWork() { return 42; } diff --git a/src/wrap-on-method.ts b/src/wrap-on-method.ts index a295ef7..2e13e50 100644 --- a/src/wrap-on-method.ts +++ b/src/wrap-on-method.ts @@ -1,6 +1,6 @@ import { setMeta, SYM_META_PROP } from './set-meta.decorator'; import { getParameterNames } from './getParameterNames'; -import type { WrapFn, WrapContext, InvocationContext } from './hook.types'; +import type { WrapFn, WrapContext } from './hook.types'; /** * Symbol sentinel set on every function wrapped by {@link WrapOnMethod}. @@ -22,10 +22,10 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * the original function is copied to the wrapper. * * @typeParam R - The return type of the decorated method - * @param wrapFn - Factory called once at decoration time with a - * {@link WrapContext}. Returns the inner function that - * receives the `this`-bound original method and an - * {@link InvocationContext} on each call. + * @param wrapFn - Factory called once on first invocation with the + * `this`-bound original method and a + * {@link WrapContext}. Returns the inner function + * that receives raw args on each call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default * {@link WRAP_KEY}. This allows different @@ -37,9 +37,9 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * @example * ```ts * class Service { - * \@WrapOnMethod((ctx) => (method, invCtx) => { - * console.log(`${String(ctx.propertyKey)} called with`, invCtx.args); - * return method(...invCtx.args); + * \@WrapOnMethod((method, ctx) => (...args) => { + * console.log(`${String(ctx.propertyKey)} called with`, args); + * return method(...args); * }) * doWork(input: string) { return input.toUpperCase(); } * } @@ -101,40 +101,6 @@ const getClassName = (instance: object): string => { return ctor?.name ?? ''; }; -/** - * Builds an object mapping parameter names to their call-time values. - * - * Creates a record where keys are parameter names and values are the - * corresponding argument values passed to the function. Returns - * `undefined` when both arrays are empty. - * - * @param parameterNames - Array of parameter names from the function signature - * @param args - Array of argument values from the current invocation - * @returns Object mapping parameter names to values, or undefined when empty - * - * @example - * buildArgsObject(['id', 'name'], [1, 'John']) - * // Returns: { id: 1, name: 'John' } - */ -export const buildArgsObject = ( - parameterNames: string[], - args: unknown[], -): Record | undefined => { - if (args.length === 0 && parameterNames.length === 0) { - return undefined; - } - - const argsObject: Record = {}; - - parameterNames.forEach((paramName, index) => { - if (index < args.length) { - argsObject[paramName] = args[index]; - } - }); - - return argsObject; -}; - /** * Wraps a plain method with a {@link WrapFn} factory, producing a new * function that delegates to the factory-returned inner function on @@ -144,24 +110,29 @@ export const buildArgsObject = ( * It can be used directly to wrap any function without relying on the * decorator syntax. * - * The {@link WrapFn} factory is called **once** (immediately) with the - * decoration-time {@link WrapContext}. On each invocation, the original - * method is bound to `this`, an {@link InvocationContext} is built with - * the current arguments, and both are passed to the inner function. + * The {@link WrapFn} factory is called **once on first invocation** with + * a method proxy and a {@link WrapContext}. On each subsequent call, the + * cached inner function is invoked with raw arguments. + * + * The method proxy always delegates to the original method bound to the + * current `this` context (set before each call). The {@link WrapContext} + * is mutable -- `target` and `className` update before each call so the + * factory's closure always sees current values. * * @typeParam R - The return type produced by the wrapper * @param originalMethod - The function to wrap - * @param wrapFn - Factory called once at wrap time with a - * {@link WrapContext}. Returns the inner function - * that receives the `this`-bound method and an - * {@link InvocationContext} on each call. + * @param wrapFn - Factory called once on first invocation with a + * method proxy and {@link WrapContext}. Returns the + * inner function that receives raw args on each call. * @param options - Decoration-time metadata for the method being wrapped - * @returns A function that, when called, binds the original method, builds - * an {@link InvocationContext}, and delegates to the inner function + * @returns A function that, when called, delegates to the cached inner function * * @example * ```ts - * const wrapped = wrapMethod(originalFn, myWrapFn, { + * const wrapped = wrapMethod(originalFn, (method, ctx) => (...args) => { + * console.log(`${String(ctx.propertyKey)} called`); + * return method(...args); + * }, { * parameterNames: ['a', 'b'], * propertyKey: 'add', * descriptor, @@ -176,30 +147,33 @@ export const wrapMethod = ( ): ((this: object, ...args: unknown[]) => unknown) => { const { parameterNames, propertyKey, descriptor } = options; - const decorationContext: WrapContext = { + let invocationFn: ((...args: unknown[]) => R) | null = null; + let currentInstance: object; + + /** Method proxy that always delegates to current this. */ + const methodProxy = function (...args: unknown[]) { + return originalMethod.apply(currentInstance, args); + }; + + /** Mutable context -- target/className updated on each call. */ + const wrapContext: WrapContext = { propertyKey, parameterNames, descriptor, + target: Object.create(null), + className: '', }; - const factoryFn = wrapFn(decorationContext); - return function (this: object, ...args: unknown[]): unknown { + currentInstance = this; + wrapContext.target = this; + wrapContext.className = getClassName(this); - const boundMethod = originalMethod.bind(this); - - const className = getClassName(this); - const argsObject = buildArgsObject(parameterNames, args); - - const invocationContext: InvocationContext = { - ...decorationContext, - target: this, - className, - args, - argsObject, - }; + if (!invocationFn) { + invocationFn = wrapFn(methodProxy, wrapContext); + } - return factoryFn(boundMethod, invocationContext); + return invocationFn(...args); }; }; diff --git a/src/wrap.decorator.ts b/src/wrap.decorator.ts index dea1340..3fe1467 100644 --- a/src/wrap.decorator.ts +++ b/src/wrap.decorator.ts @@ -20,10 +20,10 @@ import type { WrapFn } from './hook.types'; * present but `descriptor` is `undefined`). * * @typeParam R - The return type expected from the wrapper function - * @param wrapFn - Factory called once at decoration time with a - * {@link WrapContext}. Returns the inner function that - * receives the `this`-bound original method and an - * {@link InvocationContext} on each call. + * @param wrapFn - Factory called once on first invocation with the + * `this`-bound original method and a + * {@link WrapContext}. Returns the inner function + * that receives raw args on each call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default * `WRAP_APPLIED_KEY`. This allows different @@ -36,17 +36,17 @@ import type { WrapFn } from './hook.types'; * ```ts * // Method-level usage * class Service { - * \@Wrap((ctx) => (method, invCtx) => { - * console.log(`${invCtx.className}.${String(ctx.propertyKey)} called`); - * return method(...invCtx.args); + * \@Wrap((method, ctx) => (...args) => { + * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); + * return method(...args); * }) * doWork() { return 42; } * } * * // Class-level usage - * \@Wrap((ctx) => (method, invCtx) => { + * \@Wrap((method, ctx) => (...args) => { * console.log(`${String(ctx.propertyKey)} called`); - * return method(...invCtx.args); + * return method(...args); * }) * class AnotherService { * methodA() { return 'a'; } diff --git a/tests/Effect.spec.ts b/tests/Effect.spec.ts index 5334cfe..78e6086 100644 --- a/tests/Effect.spec.ts +++ b/tests/Effect.spec.ts @@ -306,7 +306,7 @@ describe('Effect', () => { }); describe('factory hook resolution', () => { - it('should call factory ONCE at decoration time, not per invocation', () => { + it('should call factory ONCE on first invocation, not per invocation', () => { const factorySpy = vi.fn(() => ({ onReturn: ({ result }: { result: unknown }) => result, })); @@ -318,8 +318,8 @@ describe('Effect', () => { } } - // Factory called at decoration time - expect(factorySpy).toHaveBeenCalledTimes(1); + // Factory NOT called at decoration time (lazy init) + expect(factorySpy).not.toHaveBeenCalled(); const service = new TestService(); @@ -333,7 +333,7 @@ describe('Effect', () => { expect(factorySpy).toHaveBeenCalledTimes(1); }); - it('should call factory once at decoration time, reuse for all instances', () => { + it('should call factory once on first invocation, reuse for all instances', () => { const factorySpy = vi.fn(() => ({ onReturn: ({ result }: { result: unknown }) => result, })); @@ -345,8 +345,8 @@ describe('Effect', () => { } } - // Factory called once at decoration time - expect(factorySpy).toHaveBeenCalledTimes(1); + // Factory NOT called at decoration time + expect(factorySpy).not.toHaveBeenCalled(); const serviceA = new TestService(); const serviceB = new TestService(); @@ -362,7 +362,7 @@ describe('Effect', () => { expect(factorySpy).toHaveBeenCalledTimes(1); }); - it('should pass WrapContext fields to factory (propertyKey, parameterNames, descriptor)', () => { + it('should pass WrapContext fields to factory (propertyKey, parameterNames, descriptor, target, className)', () => { let receivedContext: Record | undefined; const factory = (ctx: Record) => { @@ -378,37 +378,17 @@ describe('Effect', () => { return `${greeting} ${name}`; } } - void TestService; - // Factory called at decoration time + const service = new TestService(); + service.greet('world', 'hi'); + + // Factory called on first invocation expect(receivedContext).toBeDefined(); expect(receivedContext!['propertyKey']).toBe('greet'); expect(receivedContext!['parameterNames']).toEqual(['name', 'greeting']); expect(receivedContext!['descriptor']).toBeDefined(); - }); - - it('should NOT pass args, argsObject, target, or className to factory', () => { - let receivedContext: Record | undefined; - - const factory = (ctx: Record) => { - receivedContext = ctx; - return {}; - }; - - class TestService { - @Effect(factory as Parameters[0]) - doWork(x: number) { - return x; - } - } - void TestService; - - // Factory is called at decoration time, no runtime fields available - expect(receivedContext).toBeDefined(); - expect(receivedContext!['args']).toBeUndefined(); - expect(receivedContext!['argsObject']).toBeUndefined(); - expect(receivedContext!['target']).toBeUndefined(); - expect(receivedContext!['className']).toBeUndefined(); + expect(receivedContext!['target']).toBe(service); + expect(receivedContext!['className']).toBe('TestService'); }); it('should pass full HookContext to each resolved hook per invocation', () => { @@ -493,15 +473,14 @@ describe('Effect', () => { } } - // Factory called once per method at decoration time - // (WrapOnClass creates separate WrapOnMethod per method) - expect(factorySpy).toHaveBeenCalledTimes(2); + // Factory NOT called at decoration time (lazy init) + expect(factorySpy).not.toHaveBeenCalled(); const service = new TestService(); service.methodA(); service.methodB(); - // Still 2 (not called again per invocation) + // Factory called once per method on first invocation expect(factorySpy).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/Wrap.spec.ts b/tests/Wrap.spec.ts index a4313c4..96a9894 100644 --- a/tests/Wrap.spec.ts +++ b/tests/Wrap.spec.ts @@ -3,13 +3,13 @@ import { describe, it, expect, vi } from 'vitest'; import { Wrap } from '../src/wrap.decorator'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; import { WRAP_KEY } from '../src/wrap-on-method'; -import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; +import type { WrapFn, WrapContext } from '../src/hook.types'; describe('Wrap', () => { describe('applied to a method', () => { it('should delegate to WrapOnMethod and wrap the method', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -26,7 +26,7 @@ describe('Wrap', () => { }); it('should set WRAP_APPLIED_KEY on the method descriptor', () => { - const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); class TestService { @Wrap(wrapFn) @@ -47,10 +47,10 @@ describe('Wrap', () => { it('should delegate to WrapOnClass and wrap all prototype methods', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -76,17 +76,17 @@ describe('Wrap', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const classWrapFn: WrapFn = (_method, context) => { + return (...args) => { classCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; - const methodWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const methodWrapFn: WrapFn = (_method, context) => { + return (...args) => { methodCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -111,7 +111,7 @@ describe('Wrap', () => { }); it('should return the constructor when applied to a class', () => { - const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); @Wrap(wrapFn) class TestService { @@ -128,10 +128,10 @@ describe('Wrap', () => { it('should not wrap getters or setters', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -164,8 +164,8 @@ describe('Wrap', () => { }); it('should not wrap the constructor', () => { - const wrapFnSpy = vi.fn((_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFnSpy = vi.fn((method, _context) => { + return (...args) => method(...args); }); @Wrap(wrapFnSpy) @@ -181,25 +181,25 @@ describe('Wrap', () => { } } - // wrapFn is called at decoration time for each eligible method - expect(wrapFnSpy).toHaveBeenCalledOnce(); + // wrapFn is NOT called at decoration time (lazy init) + expect(wrapFnSpy).not.toHaveBeenCalled(); const service = new TestService(); expect(service.value).toBe(42); service.doWork(); - // wrapFn still called only once (at decoration time) + // wrapFn called once on first invocation expect(wrapFnSpy).toHaveBeenCalledOnce(); }); }); - describe('WrapFn receives WrapContext at decoration time', () => { - it('should pass WrapContext with decoration-time fields only', () => { + describe('WrapFn receives method and WrapContext on first invocation', () => { + it('should pass WrapContext with all fields on first call', () => { let receivedContext: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { + const wrapFn: WrapFn = (method, context) => { receivedContext = context; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; class TestService { @@ -208,25 +208,28 @@ describe('Wrap', () => { return input.toUpperCase(); } } - void TestService; - // WrapContext captured at decoration time + // WrapContext NOT yet captured (lazy init) + expect(receivedContext).toBeUndefined(); + + const service = new TestService(); + service.doWork('test'); + + // WrapContext captured on first invocation expect(receivedContext).toBeDefined(); expect(receivedContext!.propertyKey).toBe('doWork'); expect(receivedContext!.parameterNames).toEqual(['input']); expect(receivedContext!.descriptor).toBeDefined(); + expect(receivedContext!.target).toBe(service); + expect(receivedContext!.className).toBe('TestService'); }); - it('should provide target and className via InvocationContext, not WrapContext', () => { - let receivedWrapCtx: WrapContext | undefined; - let receivedInvCtx: InvocationContext | undefined; + it('should provide target and className in WrapContext', () => { + let receivedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { - receivedWrapCtx = context; - return (method, invCtx) => { - receivedInvCtx = invCtx; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, context) => { + receivedCtx = context; + return (...args) => method(...args); }; class TestService { @@ -239,47 +242,18 @@ describe('Wrap', () => { const service = new TestService(); service.doWork('test'); - // WrapContext should NOT have target or className - expect(receivedWrapCtx).toBeDefined(); - expect('target' in receivedWrapCtx!).toBe(false); - expect('className' in receivedWrapCtx!).toBe(false); - - // InvocationContext should have target and className - expect(receivedInvCtx).toBeDefined(); - expect(receivedInvCtx!.target).toBe(service); - expect(receivedInvCtx!.className).toBe('TestService'); - }); - - it('should NOT include args or argsObject on WrapContext', () => { - let receivedContext: Record | undefined; - - const wrapFn: WrapFn = (context) => { - receivedContext = context as unknown as Record; - return (method, invCtx) => method(...invCtx.args); - }; - - class TestService { - @Wrap(wrapFn) - doWork(a: number, b: number) { - return a + b; - } - } - void TestService; - - // WrapContext captured at decoration time has no args - expect(receivedContext).toBeDefined(); - expect(receivedContext!['args']).toBeUndefined(); - expect(receivedContext!['argsObject']).toBeUndefined(); + // WrapContext should have target and className + expect(receivedCtx).toBeDefined(); + expect(receivedCtx!.target).toBe(service); + expect(receivedCtx!.className).toBe('TestService'); }); - it('should pass a this-bound method per invocation', () => { + it('should pass a this-bound method proxy', () => { let receivedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - receivedMethod = method; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, _context) => { + receivedMethod = method; + return (...args) => method(...args); }; class TestService { @@ -298,13 +272,13 @@ describe('Wrap', () => { expect(result).toBe('TestInstance'); // Calling the captured bound method directly also works - // (proving it is pre-bound, not requiring a this context) + // (proving it uses the proxy that delegates to the correct this) expect(receivedMethod!()).toBe('TestInstance'); }); it('should bind method to the correct instance for each invocation', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -326,8 +300,8 @@ describe('Wrap', () => { describe('sync method through Wrap', () => { it('should wrap a sync method and return its result unchanged', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class Calculator { @@ -342,9 +316,9 @@ describe('Wrap', () => { }); it('should allow Wrap to modify the sync return value', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - const result = method(...invCtx.args) as number; + const wrapFn: WrapFn = (method, _context) => { + return (...args) => { + const result = method(...args) as number; return result * 10; }; }; @@ -361,10 +335,10 @@ describe('Wrap', () => { }); it('should allow Wrap to intercept arguments for sync methods', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (method, _context) => { + return (...args) => { // Intercept: double all numeric arguments - const doubled = invCtx.args.map((a) => + const doubled = args.map((a) => typeof a === 'number' ? a * 2 : a, ); return method(...doubled); @@ -386,9 +360,9 @@ describe('Wrap', () => { describe('async method through Wrap', () => { it('should wrap an async method and return its resolved value', async () => { - const wrapFn: WrapFn = (_context) => { - return async (method, invCtx) => { - const result = await method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return async (...args) => { + const result = await method(...args); return result; }; }; @@ -407,9 +381,9 @@ describe('Wrap', () => { }); it('should allow Wrap to modify the async return value', async () => { - const wrapFn: WrapFn = (_context) => { - return async (method, invCtx) => { - const result = (await method(...invCtx.args)) as { id: number; name: string }; + const wrapFn: WrapFn = (method, _context) => { + return async (...args) => { + const result = (await method(...args)) as { id: number; name: string }; return { ...result, modified: true }; }; }; @@ -428,9 +402,9 @@ describe('Wrap', () => { }); it('should propagate errors from async methods', async () => { - const wrapFn: WrapFn = (_context) => { - return async (method, invCtx) => { - return method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return async (...args) => { + return method(...args); }; }; @@ -453,17 +427,17 @@ describe('Wrap', () => { const CUSTOM_KEY = Symbol('custom'); const calls: string[] = []; - const wrapFnA: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFnA: WrapFn = (_method, context) => { + return (...args) => { calls.push(`A:${String(context.propertyKey)}`); - return method(...invCtx.args); + return _method(...args); }; }; - const wrapFnB: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFnB: WrapFn = (_method, context) => { + return (...args) => { calls.push(`B:${String(context.propertyKey)}`); - return method(...invCtx.args); + return _method(...args); }; }; @@ -488,17 +462,17 @@ describe('Wrap', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const classWrapFn: WrapFn = (_method, context) => { + return (...args) => { classCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; - const methodWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const methodWrapFn: WrapFn = (_method, context) => { + return (...args) => { methodCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -528,10 +502,10 @@ describe('Wrap', () => { const EXCLUSION_KEY = Symbol('noWrap'); const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -557,7 +531,7 @@ describe('Wrap', () => { it('should mark method with exclusionKey when applied at method level', () => { const EXCLUSION_KEY = Symbol('customKey'); - const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); class TestService { @Wrap(wrapFn, EXCLUSION_KEY) @@ -579,7 +553,7 @@ describe('Wrap', () => { describe('invalid decorator context', () => { it('should throw Error when applied in an unsupported context', () => { - const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); const decorator = Wrap(wrapFn); @@ -590,7 +564,7 @@ describe('Wrap', () => { }); it('should throw Error with propertyKey present but descriptor missing', () => { - const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); const decorator = Wrap(wrapFn); diff --git a/tests/WrapOnClass.spec.ts b/tests/WrapOnClass.spec.ts index a968448..8a955ec 100644 --- a/tests/WrapOnClass.spec.ts +++ b/tests/WrapOnClass.spec.ts @@ -3,17 +3,17 @@ import { describe, it, expect, vi } from 'vitest'; import { WrapOnClass } from '../src/wrap-on-class'; import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; -import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; +import type { WrapFn, WrapContext } from '../src/hook.types'; describe('WrapOnClass', () => { describe('wraps all regular prototype methods', () => { it('should wrap every eligible method with the provided WrapFn', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -41,8 +41,8 @@ describe('WrapOnClass', () => { }); it('should preserve correct return values from wrapped methods', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; @WrapOnClass(wrapFn) @@ -64,8 +64,8 @@ describe('WrapOnClass', () => { describe('skips constructor', () => { it('should not fire the wrapper during construction', () => { - const wrapFnSpy = vi.fn((_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFnSpy = vi.fn((method, _context) => { + return (...args) => method(...args); }); @WrapOnClass(wrapFnSpy) @@ -81,13 +81,13 @@ describe('WrapOnClass', () => { } } - // WrapFn is called once at decoration time for each eligible method - expect(wrapFnSpy).toHaveBeenCalledOnce(); + // WrapFn is NOT called at decoration time (lazy init) + expect(wrapFnSpy).not.toHaveBeenCalled(); const service = new TestService(); expect(service.value).toBe(42); - // Still called only once (decoration time, not per invocation) + // Called once on first invocation service.doWork(); expect(wrapFnSpy).toHaveBeenCalledOnce(); }); @@ -95,9 +95,9 @@ describe('WrapOnClass', () => { it('should not include constructor in the set of wrapped property names', () => { const wrappedNames: string[] = []; - const wrapFn: WrapFn = (context) => { + const wrapFn: WrapFn = (_method, context) => { wrappedNames.push(String(context.propertyKey)); - return (method, invCtx) => method(...invCtx.args); + return (...args) => _method(...args); }; @WrapOnClass(wrapFn) @@ -115,13 +115,16 @@ describe('WrapOnClass', () => { } } - // wrappedNames populated at decoration time - expect(wrappedNames).toEqual(['alpha', 'beta']); - expect(wrappedNames).not.toContain('constructor'); + // wrappedNames NOT populated at decoration time (lazy init) + expect(wrappedNames).toEqual([]); const service = new TestService(); service.alpha(); service.beta(); + + // wrappedNames populated on first invocation of each method + expect(wrappedNames).toEqual(['alpha', 'beta']); + expect(wrappedNames).not.toContain('constructor'); }); }); @@ -129,10 +132,10 @@ describe('WrapOnClass', () => { it('should not wrap getter or setter accessors', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -165,10 +168,10 @@ describe('WrapOnClass', () => { it('should skip getter-only properties', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -194,10 +197,10 @@ describe('WrapOnClass', () => { const calls: string[] = []; let stored = 0; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -225,10 +228,10 @@ describe('WrapOnClass', () => { it('should not attempt to wrap non-function prototype properties', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -256,10 +259,10 @@ describe('WrapOnClass', () => { it('should skip string and object prototype values', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -294,10 +297,10 @@ describe('WrapOnClass', () => { it('should skip methods explicitly excluded via SetMeta with default key', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -325,10 +328,10 @@ describe('WrapOnClass', () => { const CUSTOM_KEY = Symbol('custom'); const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -358,17 +361,17 @@ describe('WrapOnClass', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const classWrapFn: WrapFn = (_method, context) => { + return (...args) => { classCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; - const methodWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const methodWrapFn: WrapFn = (_method, context) => { + return (...args) => { methodCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -398,17 +401,17 @@ describe('WrapOnClass', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const classWrapFn: WrapFn = (_method, context) => { + return (...args) => { classCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; - const methodWrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const methodWrapFn: WrapFn = (_method, context) => { + return (...args) => { methodCalls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -438,10 +441,10 @@ describe('WrapOnClass', () => { it('should default to WRAP_APPLIED_KEY when no exclusionKey is provided', () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -466,8 +469,8 @@ describe('WrapOnClass', () => { }); it('should set WRAP_APPLIED_KEY metadata on methods it wraps', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; @WrapOnClass(wrapFn) @@ -489,8 +492,8 @@ describe('WrapOnClass', () => { it('should set custom exclusion key metadata on wrapped methods', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; @WrapOnClass(wrapFn, CUSTOM_KEY) @@ -512,8 +515,8 @@ describe('WrapOnClass', () => { it('should not set WRAP_APPLIED_KEY when a custom key is provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; @WrapOnClass(wrapFn, CUSTOM_KEY) @@ -540,17 +543,17 @@ describe('WrapOnClass', () => { const callsA: string[] = []; const callsB: string[] = []; - const wrapFnA: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFnA: WrapFn = (_method, context) => { + return (...args) => { callsA.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; - const wrapFnB: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFnB: WrapFn = (_method, context) => { + return (...args) => { callsB.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; @@ -574,8 +577,8 @@ describe('WrapOnClass', () => { describe('this binding is preserved', () => { it('should preserve this context in wrapped methods', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; @WrapOnClass(wrapFn) @@ -592,17 +595,13 @@ describe('WrapOnClass', () => { }); }); - describe('WrapContext and InvocationContext are correctly populated', () => { - it('should pass decoration-time fields in WrapContext and runtime fields in InvocationContext', () => { - const capturedWrapContexts: WrapContext[] = []; - let capturedInvCtx: InvocationContext | undefined; + describe('WrapContext is correctly populated', () => { + it('should pass decoration-time and runtime fields in WrapContext', () => { + const capturedContexts: WrapContext[] = []; - const wrapFn: WrapFn = (context) => { - capturedWrapContexts.push(context); - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (_method, context) => { + capturedContexts.push(context); + return (...args) => _method(...args); }; @WrapOnClass(wrapFn) @@ -612,25 +611,22 @@ describe('WrapOnClass', () => { } } - // WrapContext captured at decoration time - expect(capturedWrapContexts).toHaveLength(1); - - const wrapCtx = capturedWrapContexts[0]; - expect(wrapCtx.propertyKey).toBe('greet'); - expect(wrapCtx.parameterNames).toEqual(['name']); - expect(wrapCtx.descriptor).toBeDefined(); - // WrapContext should NOT have target or className - expect('target' in wrapCtx).toBe(false); - expect('className' in wrapCtx).toBe(false); + // WrapContext NOT captured at decoration time (lazy init) + expect(capturedContexts).toHaveLength(0); const service = new TestService(); service.greet('world'); - // InvocationContext should have runtime fields - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.target).toBe(service); - expect(capturedInvCtx!.className).toBe('TestService'); - expect(capturedInvCtx!.args).toEqual(['world']); + // WrapContext captured on first invocation + expect(capturedContexts).toHaveLength(1); + + const ctx = capturedContexts[0]; + expect(ctx.propertyKey).toBe('greet'); + expect(ctx.parameterNames).toEqual(['name']); + expect(ctx.descriptor).toBeDefined(); + // WrapContext now includes target and className + expect(ctx.target).toBe(service); + expect(ctx.className).toBe('TestService'); }); }); @@ -638,10 +634,10 @@ describe('WrapOnClass', () => { it('should wrap async methods correctly', async () => { const calls: string[] = []; - const wrapFn: WrapFn = (context) => { - return (method, invCtx) => { + const wrapFn: WrapFn = (_method, context) => { + return (...args) => { calls.push(String(context.propertyKey)); - return method(...invCtx.args); + return _method(...args); }; }; diff --git a/tests/WrapOnMethod.spec.ts b/tests/WrapOnMethod.spec.ts index 01fcd4d..411ed24 100644 --- a/tests/WrapOnMethod.spec.ts +++ b/tests/WrapOnMethod.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { getMeta, SetMeta } from '../src/set-meta.decorator'; -import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; +import type { WrapFn, WrapContext } from '../src/hook.types'; describe('WrapOnMethod', () => { describe('WRAP_KEY', () => { @@ -13,9 +13,9 @@ describe('WrapOnMethod', () => { }); describe('basic wrapping', () => { - it('should call wrapFn once at decoration time with WrapContext', () => { - const wrapFnSpy = vi.fn((_context) => { - return (method, _invCtx) => method(..._invCtx.args); + it('should call wrapFn once on first invocation, not at decoration time', () => { + const wrapFnSpy = vi.fn((method, _context) => { + return (...args) => method(...args); }); class TestService { @@ -25,26 +25,26 @@ describe('WrapOnMethod', () => { } } - // wrapFn is called at decoration time, before any instance is created - expect(wrapFnSpy).toHaveBeenCalledTimes(1); + // wrapFn is NOT called at decoration time (lazy init) + expect(wrapFnSpy).not.toHaveBeenCalled(); const service = new TestService(); const result = service.greet('world'); expect(result).toBe('hello world'); - // Still called only once (decoration time) + // Called once on first invocation expect(wrapFnSpy).toHaveBeenCalledTimes(1); }); - it('should call wrapFn once at decoration time, not on each invocation', () => { + it('should call wrapFn once on first invocation, not on each invocation', () => { let wrapCount = 0; let callCount = 0; - const wrapFn: WrapFn = (_context) => { + const wrapFn: WrapFn = (method, _context) => { wrapCount++; - return (method, invCtx) => { + return (...args) => { callCount++; - return method(...invCtx.args); + return method(...args); }; }; @@ -55,8 +55,8 @@ describe('WrapOnMethod', () => { } } - // wrapFn called at decoration time - expect(wrapCount).toBe(1); + // wrapFn NOT called at decoration time + expect(wrapCount).toBe(0); expect(callCount).toBe(0); const service = new TestService(); @@ -76,9 +76,9 @@ describe('WrapOnMethod', () => { it('should reuse the same factory function across instances', () => { let wrapCount = 0; - const wrapFn: WrapFn = (_context) => { + const wrapFn: WrapFn = (method, _context) => { wrapCount++; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; class TestService { @@ -88,8 +88,8 @@ describe('WrapOnMethod', () => { } } - // wrapFn called once at decoration time - expect(wrapCount).toBe(1); + // wrapFn NOT called at decoration time + expect(wrapCount).toBe(0); const serviceA = new TestService(); const serviceB = new TestService(); @@ -106,9 +106,9 @@ describe('WrapOnMethod', () => { }); it('should return the result from innerFn', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - const result = method(...invCtx.args) as number; + const wrapFn: WrapFn = (method, _context) => { + return (...args) => { + const result = method(...args) as number; return result * 2; }; }; @@ -127,8 +127,8 @@ describe('WrapOnMethod', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -144,14 +144,12 @@ describe('WrapOnMethod', () => { expect(service.greet('world')).toBe('Hello, world'); }); - it('should pass a pre-bound method per invocation', () => { + it('should pass a method proxy that delegates to current this', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedMethod = method; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, _context) => { + capturedMethod = method; + return (...args) => method(...args); }; class TestService { @@ -166,22 +164,19 @@ describe('WrapOnMethod', () => { const service = new TestService(); service.getValue(); - // Call the captured method directly -- without .call or .apply. - // It should still resolve `this` because WrapOnMethod pre-binds it. + // Call the captured method directly -- the proxy delegates to currentInstance expect(capturedMethod).toBeDefined(); expect(capturedMethod!()).toBe('instance-data'); }); }); - describe('InvocationContext fields', () => { - it('should provide className and target in InvocationContext', () => { - let capturedInvCtx: InvocationContext | undefined; + describe('WrapContext fields', () => { + it('should provide target and className in WrapContext on first call', () => { + let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, context) => { + capturedCtx = context; + return (...args) => method(...args); }; class MySpecialService { @@ -194,44 +189,17 @@ describe('WrapOnMethod', () => { const service = new MySpecialService(); service.doWork(); - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.target).toBe(service); - expect(capturedInvCtx!.className).toBe('MySpecialService'); + expect(capturedCtx).toBeDefined(); + expect(capturedCtx!.target).toBe(service); + expect(capturedCtx!.className).toBe('MySpecialService'); }); - it('should provide args and argsObject in InvocationContext', () => { - let capturedInvCtx: InvocationContext | undefined; - - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; - }; - - class TestService { - @WrapOnMethod(wrapFn) - greet(name: string, greeting: string) { - return `${greeting} ${name}`; - } - } - - const service = new TestService(); - service.greet('world', 'hi'); - - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.args).toEqual(['world', 'hi']); - expect(capturedInvCtx!.argsObject).toEqual({ name: 'world', greeting: 'hi' }); - }); - - it('should include WrapContext fields (propertyKey, parameterNames, descriptor) in InvocationContext', () => { - let capturedInvCtx: InvocationContext | undefined; + it('should provide all decoration-time context fields', () => { + let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args) => method(...args); }; class TestService { @@ -244,31 +212,7 @@ describe('WrapOnMethod', () => { const service = new TestService(); service.greet('world', 'hi'); - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.propertyKey).toBe('greet'); - expect(capturedInvCtx!.parameterNames).toEqual(['name', 'greeting']); - expect(capturedInvCtx!.descriptor).toBeDefined(); - }); - }); - - describe('WrapContext fields', () => { - it('should provide decoration-time context fields', () => { - let capturedContext: WrapContext | undefined; - - const wrapFn: WrapFn = (context) => { - capturedContext = context; - return (method, invCtx) => method(...invCtx.args); - }; - - class TestService { - @WrapOnMethod(wrapFn) - greet(name: string, greeting: string) { - return `${greeting} ${name}`; - } - } - void TestService; - - // WrapContext is captured at decoration time + // WrapContext captured on first invocation expect(capturedContext).toBeDefined(); expect(capturedContext!.propertyKey).toBe('greet'); expect(capturedContext!.parameterNames).toEqual(['name', 'greeting']); @@ -276,28 +220,32 @@ describe('WrapOnMethod', () => { expect(typeof capturedContext!.descriptor.value).toBe('function'); }); - it('should NOT include target, className, args, or argsObject in WrapContext', () => { + it('should update target and className on each call (mutable context)', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { + const wrapFn: WrapFn = (method, context) => { capturedContext = context; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; class TestService { + constructor(private id: number) {} + @WrapOnMethod(wrapFn) - doWork(x: number) { - return x; + getId() { + return this.id; } } - void TestService; - // WrapContext is captured at decoration time - expect(capturedContext).toBeDefined(); - expect('target' in capturedContext!).toBe(false); - expect('className' in capturedContext!).toBe(false); - expect('args' in capturedContext!).toBe(false); - expect('argsObject' in capturedContext!).toBe(false); + const serviceA = new TestService(1); + const serviceB = new TestService(2); + + serviceA.getId(); + expect(capturedContext!.target).toBe(serviceA); + + serviceB.getId(); + // Mutable context updates to reflect current caller + expect(capturedContext!.target).toBe(serviceB); }); }); @@ -305,9 +253,9 @@ describe('WrapOnMethod', () => { it('should extract parameter names at decoration time', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { + const wrapFn: WrapFn = (method, context) => { capturedContext = context; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; class TestService { @@ -316,7 +264,9 @@ describe('WrapOnMethod', () => { return price + tax - discount; } } - void TestService; + + const service = new TestService(); + service.calculate(100, 10, 5); expect(capturedContext!.parameterNames).toEqual(['price', 'tax', 'discount']); }); @@ -324,9 +274,9 @@ describe('WrapOnMethod', () => { it('should reuse the same WrapContext reference since wrapFn is called once', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { + const wrapFn: WrapFn = (method, context) => { capturedContext = context; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; class TestService { @@ -336,14 +286,15 @@ describe('WrapOnMethod', () => { } } + const serviceA = new TestService(); + serviceA.calculate(100, 10, 5); + const firstCapture = capturedContext; - const serviceA = new TestService(); const serviceB = new TestService(); - serviceA.calculate(100, 10, 5); serviceB.calculate(200, 20, 10); - // WrapContext is the same reference (captured once at decoration time) + // WrapContext is the same reference (captured once on first call) expect(capturedContext).toBe(firstCapture); expect(capturedContext!.parameterNames).toEqual(['price', 'tax', 'discount']); }); @@ -351,9 +302,9 @@ describe('WrapOnMethod', () => { it('should return empty array for a method with no parameters', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { + const wrapFn: WrapFn = (method, context) => { capturedContext = context; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; class TestService { @@ -362,7 +313,9 @@ describe('WrapOnMethod', () => { return 'ok'; } } - void TestService; + + const service = new TestService(); + service.noParams(); expect(capturedContext).toBeDefined(); expect(capturedContext!.parameterNames).toEqual([]); @@ -371,8 +324,8 @@ describe('WrapOnMethod', () => { describe('exclusion key', () => { it('should set WRAP_KEY as default exclusion key', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -393,8 +346,8 @@ describe('WrapOnMethod', () => { it('should use custom exclusion key when provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -415,8 +368,8 @@ describe('WrapOnMethod', () => { it('should NOT set default WRAP_KEY when custom key is provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -441,8 +394,8 @@ describe('WrapOnMethod', () => { it('should preserve SetMeta metadata after wrapping', () => { const META_KEY = Symbol('testMeta'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -465,8 +418,8 @@ describe('WrapOnMethod', () => { const KEY_A = Symbol('a'); const KEY_B = Symbol('b'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -490,8 +443,8 @@ describe('WrapOnMethod', () => { describe('sync method wrapping', () => { it('should pass through the return value unchanged when wrapper delegates', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -508,8 +461,8 @@ describe('WrapOnMethod', () => { it('should propagate sync errors from the original method', () => { const syncError = new Error('sync failure'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -526,8 +479,8 @@ describe('WrapOnMethod', () => { describe('async methods', () => { it('should work with async methods', async () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -544,9 +497,9 @@ describe('WrapOnMethod', () => { }); it('should allow async wrapper to modify async results', async () => { - const wrapFn: WrapFn> = (_context) => { - return async (method, invCtx) => { - const result = (await method(...invCtx.args)) as string; + const wrapFn: WrapFn> = (method, _context) => { + return async (...args) => { + const result = (await method(...args)) as string; return `modified: ${result}`; }; }; @@ -567,8 +520,8 @@ describe('WrapOnMethod', () => { it('should propagate async errors (rejected promises) from the original method', async () => { const asyncError = new Error('async failure'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { @@ -585,8 +538,8 @@ describe('WrapOnMethod', () => { describe('method decorator return type', () => { it('should return a valid MethodDecorator', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; const decorator = WrapOnMethod(wrapFn); @@ -594,8 +547,8 @@ describe('WrapOnMethod', () => { }); it('should replace descriptor.value with the wrapped function', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; class TestService { diff --git a/tests/hook-types.spec.ts b/tests/hook-types.spec.ts index 369461a..65e8ac9 100644 --- a/tests/hook-types.spec.ts +++ b/tests/hook-types.spec.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import type { WrapContext, - InvocationContext, WrapFn, HookContext, HookArgs, @@ -19,75 +18,29 @@ import type { } from '../src/hook.types'; describe('hook.types', () => { - describe('WrapContext (decoration-time)', () => { - it('contains exactly the 3 decoration-time fields', () => { + describe('WrapContext (includes decoration-time + runtime fields)', () => { + it('contains decoration-time and runtime fields', () => { const ctx: WrapContext = { propertyKey: 'method', parameterNames: ['a', 'b'], descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + target: {}, + className: 'TestClass', }; expect(ctx.propertyKey).toBe('method'); expect(ctx.parameterNames).toEqual(['a', 'b']); expect(ctx.descriptor).toBeDefined(); - }); - - it('does not contain target, className, args, or argsObject', () => { - const ctx: WrapContext = { - propertyKey: 'method', - parameterNames: [], - descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, - }; - - expect(ctx).not.toHaveProperty('target'); - expect(ctx).not.toHaveProperty('className'); - expect(ctx).not.toHaveProperty('args'); - expect(ctx).not.toHaveProperty('argsObject'); - }); - }); - - describe('InvocationContext (per-call, extends WrapContext)', () => { - it('contains target, className, args, argsObject, plus WrapContext fields', () => { - const invCtx: InvocationContext = { - propertyKey: 'method', - parameterNames: ['a', 'b'], - descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, - target: {}, - className: 'TestClass', - args: [1, 2], - argsObject: { a: 1, b: 2 }, - }; - - expect(invCtx.target).toBeDefined(); - expect(invCtx.className).toBe('TestClass'); - expect(invCtx.args).toEqual([1, 2]); - expect(invCtx.argsObject).toEqual({ a: 1, b: 2 }); - expect(invCtx.propertyKey).toBe('method'); - expect(invCtx.parameterNames).toEqual(['a', 'b']); - expect(invCtx.descriptor).toBeDefined(); - }); - - it('is assignable to WrapContext (structural subtype)', () => { - const invCtx: InvocationContext = { - propertyKey: 'method', - parameterNames: [], - descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, - target: {}, - className: 'Test', - args: [], - argsObject: undefined, - }; - - const wrapCtx: WrapContext = invCtx; - expect(wrapCtx.propertyKey).toBe('method'); + expect(ctx.target).toBeDefined(); + expect(ctx.className).toBe('TestClass'); }); }); describe('WrapFn', () => { - it('accepts WrapContext and returns a function taking method and InvocationContext', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - return method(...invCtx.args); + it('accepts method and WrapContext and returns a function taking args', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args) => { + return method(...args); }; }; @@ -96,25 +49,18 @@ describe('hook.types', () => { propertyKey: 'test', parameterNames: [], descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, - }; - - const factory = wrapFn(ctx); - const invCtx: InvocationContext = { - propertyKey: 'test', - parameterNames: [], - descriptor: ctx.descriptor, target: {}, className: 'Test', - args: [42], - argsObject: undefined, }; - expect(factory(fakeMethod, invCtx)).toBe(42); + + const innerFn = wrapFn(fakeMethod, ctx); + expect(innerFn(42)).toBe(42); }); it('supports generic return type parameter', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - return (method(...invCtx.args) as number) * 2; + const wrapFn: WrapFn = (method, _context) => { + return (...args) => { + return (method(...args) as number) * 2; }; }; @@ -123,24 +69,17 @@ describe('hook.types', () => { propertyKey: 'test', parameterNames: [], descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, - }; - - const factory = wrapFn(ctx); - const invCtx: InvocationContext = { - propertyKey: 'test', - parameterNames: [], - descriptor: ctx.descriptor, target: {}, className: 'Test', - args: [], - argsObject: undefined, }; - expect(factory(fakeMethod, invCtx)).toBe(42); + + const innerFn = wrapFn(fakeMethod, ctx); + expect(innerFn()).toBe(42); }); }); - describe('HookContext extends InvocationContext', () => { - it('contains all 7 fields (3 from WrapContext + 4 runtime)', () => { + describe('HookContext extends WrapContext with args', () => { + it('contains all WrapContext fields plus args and argsObject', () => { const hookCtx: HookContext = { target: {}, propertyKey: 'method', @@ -174,22 +113,6 @@ describe('hook.types', () => { const wrapCtx: WrapContext = hookCtx; expect(wrapCtx.propertyKey).toBe('method'); }); - - it('is assignable to InvocationContext (structural subtype)', () => { - const hookCtx: HookContext = { - target: {}, - propertyKey: 'method', - parameterNames: [], - className: 'Cls', - descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, - args: [], - argsObject: undefined, - }; - - const invCtx: InvocationContext = hookCtx; - expect(invCtx.target).toBeDefined(); - expect(invCtx.className).toBe('Cls'); - }); }); describe('existing type exports remain unchanged', () => { diff --git a/tests/wrapFunction.spec.ts b/tests/wrapFunction.spec.ts index 7bbe46d..1a32fe1 100644 --- a/tests/wrapFunction.spec.ts +++ b/tests/wrapFunction.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { wrapMethod } from '../src/wrap-on-method'; -import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; +import type { WrapFn, WrapContext } from '../src/hook.types'; /** * Helper that simulates how {@link WrapOnMethod} extracts the original @@ -13,9 +13,9 @@ const asMethod = (fn: Function): ((...args: unknown[]) => unknown) => describe('wrapFunction', () => { describe('basic wrapping', () => { - it('should call wrapFn once at wrap time', () => { - const wrapFnSpy = vi.fn((_context) => { - return (method, invCtx) => method(...invCtx.args); + it('should call wrapFn once on first invocation, not at wrap time', () => { + const wrapFnSpy = vi.fn((method, _context) => { + return (...args) => method(...args); }); function greet(name: string) { @@ -31,14 +31,14 @@ describe('wrapFunction', () => { descriptor, }); - // wrapFn is called immediately at wrap time - expect(wrapFnSpy).toHaveBeenCalledTimes(1); + // wrapFn is NOT called at wrap time (lazy init) + expect(wrapFnSpy).not.toHaveBeenCalled(); const instance = { constructor: { name: 'TestService' } }; const result = wrapped.call(instance, 'world'); expect(result).toBe('hello world'); - // Still called only once (wrap time) + // Called once on first invocation expect(wrapFnSpy).toHaveBeenCalledTimes(1); }); @@ -46,11 +46,11 @@ describe('wrapFunction', () => { let wrapCount = 0; let callCount = 0; - const wrapFn: WrapFn = (_context) => { + const wrapFn: WrapFn = (method, _context) => { wrapCount++; - return (method, invCtx) => { + return (...args) => { callCount++; - return method(...invCtx.args); + return method(...args); }; }; @@ -66,8 +66,8 @@ describe('wrapFunction', () => { descriptor, }); - // wrapFn called at wrap time - expect(wrapCount).toBe(1); + // wrapFn NOT called at wrap time + expect(wrapCount).toBe(0); expect(callCount).toBe(0); const instance = { constructor: { name: 'TestService' } }; @@ -88,9 +88,9 @@ describe('wrapFunction', () => { it('should reuse the factory result for different instances', () => { let wrapCount = 0; - const wrapFn: WrapFn = (_context) => { + const wrapFn: WrapFn = (method, _context) => { wrapCount++; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; function doWork() { @@ -105,8 +105,8 @@ describe('wrapFunction', () => { descriptor, }); - // wrapFn called once at wrap time - expect(wrapCount).toBe(1); + // wrapFn NOT called at wrap time + expect(wrapCount).toBe(0); const instanceA = { constructor: { name: 'TestService' } }; const instanceB = { constructor: { name: 'TestService' } }; @@ -123,9 +123,9 @@ describe('wrapFunction', () => { }); it('should return the result from the inner function', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - const result = method(...invCtx.args) as number; + const wrapFn: WrapFn = (method, _context) => { + return (...args) => { + const result = method(...args) as number; return result * 2; }; }; @@ -149,8 +149,8 @@ describe('wrapFunction', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; const original: (...args: unknown[]) => unknown = function ( @@ -171,14 +171,12 @@ describe('wrapFunction', () => { expect(wrapped.call(instance, 'world')).toBe('Hello, world'); }); - it('should pass a pre-bound method per invocation', () => { + it('should pass a method proxy that delegates to current instance', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedMethod = method; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, _context) => { + capturedMethod = method; + return (...args) => method(...args); }; const original: (...args: unknown[]) => unknown = function ( @@ -197,19 +195,19 @@ describe('wrapFunction', () => { const instance = { value: 'instance-data', constructor: { name: 'TestService' } }; wrapped.call(instance); - // The captured method should be pre-bound to the instance + // The captured method proxy delegates to the current instance expect(capturedMethod).toBeDefined(); expect(capturedMethod!()).toBe('instance-data'); }); }); - describe('WrapContext fields (decoration-time)', () => { - it('should provide decoration-time context fields', () => { + describe('WrapContext fields', () => { + it('should provide decoration-time and runtime context fields', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { + const wrapFn: WrapFn = (method, context) => { capturedContext = context; - return (method, invCtx) => method(...invCtx.args); + return (...args) => method(...args); }; function greet(name: string, greeting: string) { @@ -218,57 +216,33 @@ describe('wrapFunction', () => { const original = asMethod(greet); const descriptor: PropertyDescriptor = { value: original, writable: true }; - wrapMethod(original, wrapFn, { + const wrapped = wrapMethod(original, wrapFn, { parameterNames: ['name', 'greeting'], propertyKey: 'greet', descriptor, }); - // WrapContext captured at wrap time + // WrapContext NOT captured at wrap time (lazy init) + expect(capturedContext).toBeUndefined(); + + const instance = { constructor: { name: 'TestService' } }; + wrapped.call(instance, 'world', 'hi'); + + // WrapContext captured on first invocation expect(capturedContext).toBeDefined(); expect(capturedContext!.propertyKey).toBe('greet'); expect(capturedContext!.parameterNames).toEqual(['name', 'greeting']); expect(capturedContext!.descriptor).toBe(descriptor); + expect(capturedContext!.target).toBe(instance); + expect(capturedContext!.className).toBe('TestService'); }); - it('should NOT include target, className, args, or argsObject in WrapContext', () => { - let capturedContext: WrapContext | undefined; + it('should provide target and className in WrapContext', () => { + let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (context) => { - capturedContext = context; - return (method, invCtx) => method(...invCtx.args); - }; - - function doWork(x: number) { - return x; - } - - const original = asMethod(doWork); - const descriptor: PropertyDescriptor = { value: original, writable: true }; - wrapMethod(original, wrapFn, { - parameterNames: ['x'], - propertyKey: 'doWork', - descriptor, - }); - - // WrapContext captured at wrap time - expect(capturedContext).toBeDefined(); - expect('target' in capturedContext!).toBe(false); - expect('className' in capturedContext!).toBe(false); - expect('args' in capturedContext!).toBe(false); - expect('argsObject' in capturedContext!).toBe(false); - }); - }); - - describe('InvocationContext fields (per-call)', () => { - it('should provide target and className in InvocationContext', () => { - let capturedInvCtx: InvocationContext | undefined; - - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, context) => { + capturedCtx = context; + return (...args) => method(...args); }; function doWork() { @@ -286,49 +260,17 @@ describe('wrapFunction', () => { const instance = { constructor: { name: 'MySpecialService' } }; wrapped.call(instance); - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.target).toBe(instance); - expect(capturedInvCtx!.className).toBe('MySpecialService'); + expect(capturedCtx).toBeDefined(); + expect(capturedCtx!.target).toBe(instance); + expect(capturedCtx!.className).toBe('MySpecialService'); }); - it('should provide args and argsObject in InvocationContext', () => { - let capturedInvCtx: InvocationContext | undefined; + it('should include propertyKey, parameterNames, descriptor in WrapContext', () => { + let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; - }; - - function greet(name: string, greeting: string) { - return `${greeting} ${name}`; - } - - const original = asMethod(greet); - const descriptor: PropertyDescriptor = { value: original, writable: true }; - const wrapped = wrapMethod(original, wrapFn, { - parameterNames: ['name', 'greeting'], - propertyKey: 'greet', - descriptor, - }); - - const instance = { constructor: { name: 'TestService' } }; - wrapped.call(instance, 'world', 'hi'); - - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.args).toEqual(['world', 'hi']); - expect(capturedInvCtx!.argsObject).toEqual({ name: 'world', greeting: 'hi' }); - }); - - it('should include WrapContext fields in InvocationContext', () => { - let capturedInvCtx: InvocationContext | undefined; - - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, context) => { + capturedCtx = context; + return (...args) => method(...args); }; function doWork() { @@ -346,20 +288,18 @@ describe('wrapFunction', () => { const instance = { constructor: { name: 'TestService' } }; wrapped.call(instance); - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.propertyKey).toBe('doWork'); - expect(capturedInvCtx!.parameterNames).toEqual([]); - expect(capturedInvCtx!.descriptor).toBe(descriptor); + expect(capturedCtx).toBeDefined(); + expect(capturedCtx!.propertyKey).toBe('doWork'); + expect(capturedCtx!.parameterNames).toEqual([]); + expect(capturedCtx!.descriptor).toBe(descriptor); }); it('should return empty string for className when constructor has no name', () => { - let capturedInvCtx: InvocationContext | undefined; + let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (method, context) => { + capturedCtx = context; + return (...args) => method(...args); }; function doWork() { @@ -378,49 +318,18 @@ describe('wrapFunction', () => { const instance = { constructor: {} }; wrapped.call(instance as object); - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.className).toBe(''); - }); - - it('should return undefined argsObject for method with no parameters', () => { - let capturedInvCtx: InvocationContext | undefined; - - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvCtx = invCtx; - return method(...invCtx.args); - }; - }; - - function doWork() { - return 'done'; - } - - const original = asMethod(doWork); - const descriptor: PropertyDescriptor = { value: original, writable: true }; - const wrapped = wrapMethod(original, wrapFn, { - parameterNames: [], - propertyKey: 'doWork', - descriptor, - }); - - const instance = { constructor: { name: 'TestService' } }; - wrapped.call(instance); - - expect(capturedInvCtx).toBeDefined(); - expect(capturedInvCtx!.argsObject).toBeUndefined(); + expect(capturedCtx).toBeDefined(); + expect(capturedCtx!.className).toBe(''); }); }); describe('parameter names reuse', () => { - it('should reuse the same parameterNames reference across calls', () => { - const capturedInvContexts: InvocationContext[] = []; + it('should reuse the same WrapContext reference across calls', () => { + const capturedContexts: WrapContext[] = []; - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => { - capturedInvContexts.push(invCtx); - return method(...invCtx.args); - }; + const wrapFn: WrapFn = (_method, context) => { + capturedContexts.push(context); + return (...args) => _method(...args); }; function calculate(price: number, tax: number) { @@ -441,17 +350,16 @@ describe('wrapFunction', () => { wrapped.call(instanceA, 100, 10); wrapped.call(instanceB, 200, 20); - expect(capturedInvContexts[0].parameterNames).toEqual(['price', 'tax']); - expect(capturedInvContexts[1].parameterNames).toEqual(['price', 'tax']); - // Same reference passed each time (from decoration-time context spread) - expect(capturedInvContexts[0].parameterNames).toBe(capturedInvContexts[1].parameterNames); + // Only one context captured (factory called once) + expect(capturedContexts).toHaveLength(1); + expect(capturedContexts[0].parameterNames).toEqual(['price', 'tax']); }); }); describe('async methods', () => { it('should work with async methods', async () => { - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; async function fetchData(id: number): Promise { @@ -473,9 +381,9 @@ describe('wrapFunction', () => { }); it('should allow async wrapper to modify async results', async () => { - const wrapFn: WrapFn> = (_context) => { - return async (method, invCtx) => { - const result = (await method(...invCtx.args)) as string; + const wrapFn: WrapFn> = (method, _context) => { + return async (...args) => { + const result = (await method(...args)) as string; return `modified: ${result}`; }; }; @@ -501,8 +409,8 @@ describe('wrapFunction', () => { it('should propagate async errors from the original method', async () => { const asyncError = new Error('async failure'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; async function failingAsync() { @@ -526,8 +434,8 @@ describe('wrapFunction', () => { it('should propagate sync errors from the original method', () => { const syncError = new Error('sync failure'); - const wrapFn: WrapFn = (_context) => { - return (method, invCtx) => method(...invCtx.args); + const wrapFn: WrapFn = (method, _context) => { + return (...args) => method(...args); }; function failing(): never { From 7953c4fdd7f5920bebebfafdc8d3b26cdd132e52 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 16:25:18 +0200 Subject: [PATCH 2/2] feat: add proper type inferetence --- .claude/rules/type-level-testing.md | 44 ++ README.md | 2 + src/effect.decorator.ts | 81 ++-- src/finally.hook.ts | 17 +- src/getParameterNames.ts | 2 +- src/hook.types.ts | 179 +++++-- src/on-error.hook.ts | 18 +- src/on-invoke.hook.ts | 17 +- src/on-return.hook.ts | 20 +- src/wrap-on-class.ts | 16 +- src/wrap-on-method.ts | 51 +- src/wrap.decorator.ts | 38 +- tests/Effect.spec.ts | 54 ++- tests/FinallyHook.spec.ts | 27 +- tests/OnErrorHook.spec.ts | 18 +- tests/OnInvokeHook.spec.ts | 21 +- tests/OnReturnHook.spec.ts | 24 +- tests/Wrap.spec.ts | 155 +++++- tests/WrapOnClass.spec.ts | 51 +- tests/WrapOnMethod.spec.ts | 51 +- tests/hook-types.spec.ts | 704 +++++++++++++++++++++++++++- tests/wrapFunction.spec.ts | 37 +- 22 files changed, 1333 insertions(+), 294 deletions(-) create mode 100644 .claude/rules/type-level-testing.md diff --git a/.claude/rules/type-level-testing.md b/.claude/rules/type-level-testing.md new file mode 100644 index 0000000..bc8198f --- /dev/null +++ b/.claude/rules/type-level-testing.md @@ -0,0 +1,44 @@ +--- +title: Verify Type Changes With Compile-Time Tests +impact: HIGH +paths: + - "tests/**/*.ts" + - "src/**/*.ts" +--- + +# Verify Type Changes With Compile-Time Tests + +When adding or modifying generic type parameters, include compile-time type verification tests using `expectTypeOf` (from vitest) or `@ts-expect-error` comments. Runtime assertions alone cannot detect silent type degradation where generics fall back to defaults like `unknown`. + +## Incorrect + +Type tests that only use runtime assertions -- these pass even if generics silently degrade to `unknown`. + +```typescript +it('HookContext accepts generic parameters', () => { + const ctx: HookContext = { /* ... */ }; + // This runtime assertion passes regardless of whether generics work + expect(ctx.target).toBeDefined(); + expect(ctx.args).toEqual([42]); +}); +``` + +## Correct + +Type tests that include compile-time verification and negative cases. + +```typescript +import { expectTypeOf } from 'vitest'; + +it('HookContext infers target type from generic parameter', () => { + const ctx: HookContext = { /* ... */ }; + + // Positive: verify inferred types at compile time + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.args).toEqualTypeOf<[number]>(); + + // Negative: verify wrong types are rejected + // @ts-expect-error - target should not accept string + const bad: HookContext = { /* ... */ }; +}); +``` diff --git a/README.md b/README.md index 2cbb4e3..6ce8e74 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ const Log = () => Wrap((method, context: WrapContext) => { }); ``` +> **Auto-bound method:** The `method` parameter is automatically bound to the current `this` instance on every call. You never need to use `.bind()`, `.call()`, or `.apply()` -- just invoke `method(...args)` directly and it will execute with the correct `this` context. + **`Effect`**: Instead of writing the full wrapping logic yourself, you provide lifecycle hooks and Effect handles the execution flow: ```typescript diff --git a/src/effect.decorator.ts b/src/effect.decorator.ts index 2e705b7..47f0674 100644 --- a/src/effect.decorator.ts +++ b/src/effect.decorator.ts @@ -3,6 +3,7 @@ import type { EffectHooks, HookContext, HooksOrFactory, + TypedMethodDecorator, UnwrapPromise, WrapContext, } from './hook.types'; @@ -20,7 +21,6 @@ import type { * When applied to a **method**, wraps that single method with the provided * lifecycle hooks (via Wrap -> WrapOnMethod). * - * @typeParam R - The return type expected from lifecycle hooks * @param hooks - Lifecycle callbacks (all optional) or a factory * function that receives a {@link WrapContext} and * returns hooks. The factory is called **once on @@ -28,10 +28,13 @@ import type { * for every subsequent call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default - * `WRAP_APPLIED_KEY`. This allows different + * `WRAP_KEY`. This allows different * Effect-based decorators (e.g. `@Log`, `@Metrics`) to * use independent markers that do not interfere with * each other during class-level decoration. + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @returns A decorator usable on both classes and methods * * @example @@ -50,18 +53,22 @@ import type { * } * ``` */ -export const Effect = ( - hooks: HooksOrFactory, +export const Effect = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + hooks: HooksOrFactory, exclusionKey?: symbol, -): ClassDecorator & MethodDecorator => - Wrap((method: (...args: unknown[]) => unknown, wrapContext: WrapContext) => { - const resolvedHooks = resolveHooks(hooks, wrapContext); +): ClassDecorator & TypedMethodDecorator => + Wrap((method, wrapContext) => { + const resolvedHooks = resolveHooks(hooks, wrapContext); - return (...args: unknown[]): unknown => { + return (...args: TArgs): TReturn => { const argsObject = buildArgsObject(wrapContext.parameterNames, args); - const hookContext: HookContext = { ...wrapContext, args, argsObject }; + const hookContext: HookContext = { ...wrapContext, args, argsObject }; - const executeMethod = attachHooks( + const executeMethod = attachHooks( method, args, hookContext, @@ -72,7 +79,7 @@ export const Effect = ( const invokeResult = resolvedHooks.onInvoke(hookContext); if (invokeResult instanceof Promise) { - return invokeResult.then(executeMethod); + return invokeResult.then(executeMethod) as TReturn; } } @@ -121,22 +128,26 @@ export const buildArgsObject = ( * `finally` is applied inline on sync paths to avoid double-calling when * `onReturn` or `onError` throw. */ -const attachHooks = ( - method: (...args: unknown[]) => unknown, - args: unknown[], - context: HookContext, - hooks: EffectHooks, -): (() => unknown) => () => { +const attachHooks = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + method: (...args: TArgs) => TReturn, + args: TArgs, + context: HookContext, + hooks: EffectHooks, +): (() => TReturn) => (): TReturn => { try { const result = method(...args); if (result instanceof Promise) { - return chainAsyncHooks(result, context, hooks); + return chainAsyncHooks(result, context, hooks) as TReturn; } try { return hooks.onReturn - ? hooks.onReturn({ ...context, result: result as UnwrapPromise }) + ? hooks.onReturn({ ...context, result: result as UnwrapPromise }) as TReturn : result; } finally { hooks.finally?.(context); @@ -144,7 +155,7 @@ const attachHooks = ( } catch (error: unknown) { try { if (hooks.onError) { - return hooks.onError({ ...context, error }); + return hooks.onError({ ...context, error }) as TReturn; } throw error; @@ -161,10 +172,14 @@ const attachHooks = ( * context to produce the hooks. Otherwise, the static hooks are returned * as-is. */ -const resolveHooks = ( - hooksOrFactory: HooksOrFactory, - context: WrapContext, -): EffectHooks => { +const resolveHooks = < +T extends object = object, +TArgs extends unknown[] = unknown[], +TReturn = unknown, +>( + hooksOrFactory: HooksOrFactory, + context: WrapContext, +): EffectHooks => { if (typeof hooksOrFactory === 'function') { return hooksOrFactory(context); } @@ -177,20 +192,24 @@ const resolveHooks = ( * Uses async/await with try/catch/finally so that onReturn fires after * resolution, onError fires after rejection, and finally always fires last. */ -const chainAsyncHooks = async ( - promise: Promise, - context: HookContext, - hooks: EffectHooks, -): Promise => { +const chainAsyncHooks = async < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + promise: Promise>, + context: HookContext, + hooks: EffectHooks, +): Promise> => { try { const value = await promise; return hooks.onReturn - ? await hooks.onReturn({ ...context, result: value as UnwrapPromise }) + ? await hooks.onReturn({ ...context, result: value }) as UnwrapPromise : value; } catch (error: unknown) { if (hooks.onError) { - return await hooks.onError({ ...context, error }); + return await hooks.onError({ ...context, error }) as UnwrapPromise; } throw error; diff --git a/src/finally.hook.ts b/src/finally.hook.ts index 630b406..15db87f 100644 --- a/src/finally.hook.ts +++ b/src/finally.hook.ts @@ -6,11 +6,14 @@ import type { FinallyHookType } from './hook.types'; * regardless of outcome. Useful for cleanup, resource release, or metrics * finalization that must run whether the method succeeded or failed. * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @param callback - Function called after every method execution * @param exclusionKey - Optional symbol; Methods carrying this * metadata are skipped during class-level decoration, * and method-level decoration marks methods with this - * key instead of the default `EFFECT_APPLIED_KEY`. + * key instead of the default `WRAP_KEY`. * @returns A decorator usable on both classes and methods * * @example @@ -21,9 +24,13 @@ import type { FinallyHookType } from './hook.types'; * } * ``` */ -export const FinallyHook = ( - callback: FinallyHookType, +export const FinallyHook = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + callback: FinallyHookType, exclusionKey?: symbol -): ClassDecorator & MethodDecorator => { - return Effect({ finally: callback }, exclusionKey); +) => { + return Effect({ finally: callback }, exclusionKey); }; diff --git a/src/getParameterNames.ts b/src/getParameterNames.ts index 3b865dd..6d4a695 100644 --- a/src/getParameterNames.ts +++ b/src/getParameterNames.ts @@ -11,7 +11,7 @@ * function example(id: number, name: string = 'default') {} * getParameterNames(example) // Returns: ['id', 'name'] */ -export const getParameterNames = (func: (...args: unknown[]) => unknown): string[] => { +export const getParameterNames = (func: (...args: TArgs) => TReturn): string[] => { const funcStr = func.toString(); const match = funcStr.match(/\(([^)]*)\)/); diff --git a/src/hook.types.ts b/src/hook.types.ts index 1115b1d..426b8d8 100644 --- a/src/hook.types.ts +++ b/src/hook.types.ts @@ -8,8 +8,14 @@ export type HookArgs = Record | undefined; * plus mutable runtime fields (target, className) that update before each * method invocation. The factory receives this context on first call and * retains a reference; target/className always reflect the current caller. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. */ -export interface WrapContext { +export interface WrapContext< + T extends object = object +> { /** The property key of the decorated method. */ propertyKey: string | symbol; /** Parameter names extracted from the original function signature. */ @@ -17,7 +23,7 @@ export interface WrapContext { /** The property descriptor of the decorated method. */ descriptor: PropertyDescriptor; /** The `this` target object (class instance). Updated before each call. */ - target: object; + target: T; /** Runtime class name derived from `this.constructor.name`. Updated before each call. */ className: string; } @@ -29,78 +35,149 @@ export interface WrapContext { * original method and a {@link WrapContext}. Returns an inner function * that is called on every invocation with the raw arguments. * - * @typeParam R - The return type produced by the inner function + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type and the inner function return type. Defaults to `unknown`. */ -export type WrapFn = ( - method: (...args: unknown[]) => unknown, - context: WrapContext, -) => (...args: unknown[]) => R; +export type WrapFn< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> = ( + method: (...args: TArgs) => TReturn, + context: WrapContext, +) => (...args: TArgs) => TReturn; /** * Shared context passed to every lifecycle hook. * * Extends {@link WrapContext} with per-call argument data. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. */ -export interface HookContext extends WrapContext { +export interface HookContext< + T extends object = object, + TArgs extends unknown[] = unknown[] +> extends WrapContext { /** Raw arguments array passed to the method. */ - args: unknown[]; + args: TArgs; /** Pre-built args object mapping parameter names to their values. */ - argsObject: HookArgs; + argsObject: HookArgs; // TODO: add type inferetence HookArgs } +/** + * A method decorator signature that uses `TypedPropertyDescriptor` to + * enable TypeScript to infer `TArgs` and `TReturn` from the decoration site. + * + * When TypeScript sees this decorator applied to a method, it matches the + * method's parameter types and return type against `TArgs` and `TReturn`, + * preserving the method's original type through decoration. + */ +export type TypedMethodDecorator = ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor<(...args: TArgs) => TReturn>, +) => TypedPropertyDescriptor<(...args: TArgs) => TReturn> | void; + /** Extracts the resolved type from a Promise, or returns the type itself. */ export type UnwrapPromise = T extends Promise ? U : T; /** Allows a Promise return only when the original type is already a Promise. */ export type MaybeAsync = T extends Promise ? U | Promise : T; -/** Context for the onReturn hook, adding the method result. */ -export interface OnReturnContext extends HookContext { +/** + * Context for the onReturn hook, adding the method result. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. + */ +export interface OnReturnContext< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> extends HookContext { /** The value returned by the original method (unwrapped if it was a Promise). */ - result: UnwrapPromise; + result: UnwrapPromise; } -/** Context for the onError hook, adding the thrown error. */ -export interface OnErrorContext extends HookContext { +/** + * Context for the onError hook, adding the thrown error. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. + */ +export interface OnErrorContext< + T extends object = object, + TArgs extends unknown[] = unknown[] +> extends HookContext { /** The error thrown by the original method. */ error: unknown; } /** * Hook fired before the original method executes. - * @typeParam R - The return type of the decorated method. When it extends Promise, - * the hook may also return a Promise. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The return type of the decorated method. When it extends Promise, + * the hook may also return a Promise. Defaults to `unknown`. */ -export type OnInvokeHookType = ( - context: HookContext, -) => R extends Promise ? void | Promise : void; +export type OnInvokeHookType< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> = ( + context: HookContext, +) => TReturn extends Promise ? void | Promise : void; /** * Hook fired after a successful return. Its return value replaces the method result. - * @typeParam R - The return type of the decorated method. When it extends Promise, - * the hook may also return a Promise of the resolved type. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. */ -export type OnReturnHookType = ( - context: OnReturnContext, -) => MaybeAsync; +export type OnReturnHookType< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> = ( + context: OnReturnContext, +) => MaybeAsync; /** * Hook fired when the method throws. May return a recovery value or re-throw. - * @typeParam R - The return type of the decorated method. When it extends Promise, - * the hook may also return a Promise of the resolved type. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. */ -export type OnErrorHookType = ( - context: OnErrorContext, -) => MaybeAsync; +export type OnErrorHookType< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> = ( + context: OnErrorContext, +) => MaybeAsync; /** * Hook fired after both success and error paths, regardless of outcome. - * @typeParam R - The return type of the decorated method. When it extends Promise, - * the hook may also return a Promise. + * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. */ -export type FinallyHookType = ( - context: HookContext, -) => R extends Promise ? void | Promise : void; +export type FinallyHookType< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> = ( + context: HookContext, +) => TReturn extends Promise ? void | Promise : void; /** * Lifecycle hooks for method decoration via Effect-based decorators. @@ -110,20 +187,26 @@ export type FinallyHookType = ( * name. Hooks are optional -- omitting a hook simply skips that * lifecycle point. * - * @typeParam R - The return type of the decorated method + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. */ -export interface EffectHooks { +export interface EffectHooks< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> { /** Fires before the original method executes. */ - onInvoke?: OnInvokeHookType; + onInvoke?: OnInvokeHookType; /** Fires after a successful return. Its return value replaces the method result. */ - onReturn?: OnReturnHookType; + onReturn?: OnReturnHookType; /** Fires when the method throws. May return a recovery value or re-throw. */ - onError?: OnErrorHookType; + onError?: OnErrorHookType; /** Fires after both success and error paths, regardless of outcome. */ - finally?: FinallyHookType; + finally?: FinallyHookType; } /** @@ -134,8 +217,14 @@ export interface EffectHooks { * with the {@link WrapContext}. The resolved hooks are reused for * every subsequent call. * - * @typeParam R - The return type of the decorated method + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. */ -export type HooksOrFactory = - | EffectHooks - | ((context: WrapContext) => EffectHooks); +export type HooksOrFactory< + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +> = + | EffectHooks + | ((context: WrapContext) => EffectHooks); diff --git a/src/on-error.hook.ts b/src/on-error.hook.ts index 2871c92..ffb07cd 100644 --- a/src/on-error.hook.ts +++ b/src/on-error.hook.ts @@ -6,12 +6,14 @@ import type { OnErrorHookType } from './hook.types'; * throws an error. The callback may return a recovery value or re-throw * the error to propagate it. * - * @typeParam R - The return type of the decorated method + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @param callback - Function called when the method throws * @param exclusionKey - Optional symbol; Methods carrying this * metadata are skipped during class-level decoration, * and method-level decoration marks methods with this - * key instead of the default `EFFECT_APPLIED_KEY`. + * key instead of the default `WRAP_KEY`. * @returns A decorator usable on both classes and methods * * @example @@ -25,9 +27,13 @@ import type { OnErrorHookType } from './hook.types'; * } * ``` */ -export const OnErrorHook = ( - callback: OnErrorHookType, +export const OnErrorHook = < +T extends object = object, +TArgs extends unknown[] = unknown[], +TReturn = unknown, +>( + callback: OnErrorHookType, exclusionKey?: symbol -): ClassDecorator & MethodDecorator => { - return Effect({ onError: callback }, exclusionKey); +) => { + return Effect({ onError: callback }, exclusionKey); }; diff --git a/src/on-invoke.hook.ts b/src/on-invoke.hook.ts index 21a4e9c..69de639 100644 --- a/src/on-invoke.hook.ts +++ b/src/on-invoke.hook.ts @@ -6,11 +6,14 @@ import type { OnInvokeHookType } from './hook.types'; * executes. Useful for pre-execution side effects such as tracing, metrics, * or input validation logging. * + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @param callback - Function called before each method invocation * @param exclusionKey - Optional symbol; Methods carrying this * metadata are skipped during class-level decoration, * and method-level decoration marks methods with this - * key instead of the default `EFFECT_APPLIED_KEY`. + * key instead of the default `WRAP_KEY`. * @returns A decorator usable on both classes and methods * * @example @@ -21,9 +24,13 @@ import type { OnInvokeHookType } from './hook.types'; * } * ``` */ -export const OnInvokeHook = ( - callback: OnInvokeHookType, +export const OnInvokeHook = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + callback: OnInvokeHookType, exclusionKey?: symbol -): ClassDecorator & MethodDecorator => { - return Effect({ onInvoke: callback }, exclusionKey); +) => { + return Effect({ onInvoke: callback }, exclusionKey); }; diff --git a/src/on-return.hook.ts b/src/on-return.hook.ts index 0b2ed9c..9183824 100644 --- a/src/on-return.hook.ts +++ b/src/on-return.hook.ts @@ -6,12 +6,14 @@ import type { OnReturnHookType } from './hook.types'; * returns successfully. The callback's return value replaces the method * result, enabling post-processing or result transformation. * - * @typeParam R - The return type of the decorated method - * @param callback - Function called after successful return\ + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. + * @param callback - Function called after successful return * @param exclusionKey - Optional symbol; Methods carrying this * metadata are skipped during class-level decoration, * and method-level decoration marks methods with this - * key instead of the default `EFFECT_APPLIED_KEY`. + * key instead of the default `WRAP_KEY`. * @returns A decorator usable on both classes and methods * * @example @@ -25,9 +27,13 @@ import type { OnReturnHookType } from './hook.types'; * } * ``` */ -export const OnReturnHook = ( - callback: OnReturnHookType, +export const OnReturnHook = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + callback: OnReturnHookType, exclusionKey?: symbol -): ClassDecorator & MethodDecorator => { - return Effect({ onReturn: callback }, exclusionKey); +) => { + return Effect({ onReturn: callback }, exclusionKey); }; diff --git a/src/wrap-on-class.ts b/src/wrap-on-class.ts index e7ccc70..1d48a1c 100644 --- a/src/wrap-on-class.ts +++ b/src/wrap-on-class.ts @@ -24,7 +24,9 @@ import { WrapOnMethod, WRAP_KEY } from './wrap-on-method'; * - Methods marked with `exclusionKey` metadata (double-wrap prevention and * explicit exclusion via e.g. `@SetMeta(key, true)`) * - * @typeParam R - The return type expected from the wrapped methods + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @param wrapFn - Factory forwarded to {@link WrapOnMethod} for each * eligible method * @param exclusionKey - Symbol used to detect already-decorated and excluded @@ -49,11 +51,15 @@ import { WrapOnMethod, WRAP_KEY } from './wrap-on-method'; * } * ``` */ -export const WrapOnClass = ( - wrapFn: WrapFn, +export const WrapOnClass = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + wrapFn: WrapFn, exclusionKey: symbol = WRAP_KEY, -): ClassDecorator => { - const methodDecorator = WrapOnMethod(wrapFn, exclusionKey); +): ClassDecorator => { // TODO: add type inference for class + const methodDecorator = WrapOnMethod(wrapFn, exclusionKey); return (target: Function): void => { const prototype = target.prototype as Record; diff --git a/src/wrap-on-method.ts b/src/wrap-on-method.ts index 2e13e50..7b204a7 100644 --- a/src/wrap-on-method.ts +++ b/src/wrap-on-method.ts @@ -1,6 +1,6 @@ import { setMeta, SYM_META_PROP } from './set-meta.decorator'; import { getParameterNames } from './getParameterNames'; -import type { WrapFn, WrapContext } from './hook.types'; +import type { WrapFn, WrapContext, TypedMethodDecorator } from './hook.types'; /** * Symbol sentinel set on every function wrapped by {@link WrapOnMethod}. @@ -21,7 +21,9 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * descriptor via `setMeta`, and any existing `_symMeta` metadata from * the original function is copied to the wrapper. * - * @typeParam R - The return type of the decorated method + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @param wrapFn - Factory called once on first invocation with the * `this`-bound original method and a * {@link WrapContext}. Returns the inner function @@ -45,16 +47,19 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * } * ``` */ -export const WrapOnMethod = ( - wrapFn: WrapFn, +export const WrapOnMethod = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + wrapFn: WrapFn, exclusionKey: symbol = WRAP_KEY, -): MethodDecorator => { - return ( - _target: object, - propertyKey: string | symbol, - descriptor: PropertyDescriptor, - ): PropertyDescriptor => { - const originalMethod = descriptor.value as (...args: unknown[]) => unknown; +): TypedMethodDecorator => { + return (_target,propertyKey,descriptor) => { + const originalMethod = descriptor.value; + if (!originalMethod) { + throw new Error('Method decorator can only be applied to methods'); + } // Extract parameter names at decoration time (once, not per-call) const parameterNames = getParameterNames(originalMethod); @@ -119,7 +124,9 @@ const getClassName = (instance: object): string => { * is mutable -- `target` and `className` update before each call so the * factory's closure always sees current values. * - * @typeParam R - The return type produced by the wrapper + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @param originalMethod - The function to wrap * @param wrapFn - Factory called once on first invocation with a * method proxy and {@link WrapContext}. Returns the @@ -140,23 +147,27 @@ const getClassName = (instance: object): string => { * const result = wrapped.call(instance, 1, 2); * ``` */ -export const wrapMethod = ( - originalMethod: (...args: unknown[]) => unknown, - wrapFn: WrapFn, +export const wrapMethod = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + originalMethod: (...args: TArgs) => TReturn, + wrapFn: WrapFn, options: WrapMethodOptions, -): ((this: object, ...args: unknown[]) => unknown) => { +): ((this: T, ...args: TArgs) => TReturn) => { const { parameterNames, propertyKey, descriptor } = options; - let invocationFn: ((...args: unknown[]) => R) | null = null; + let invocationFn: ((...args: TArgs) => TReturn) | null = null; let currentInstance: object; /** Method proxy that always delegates to current this. */ - const methodProxy = function (...args: unknown[]) { + const methodProxy = function (...args: TArgs) { return originalMethod.apply(currentInstance, args); }; /** Mutable context -- target/className updated on each call. */ - const wrapContext: WrapContext = { + const wrapContext: WrapContext = { propertyKey, parameterNames, descriptor, @@ -164,7 +175,7 @@ export const wrapMethod = ( className: '', }; - return function (this: object, ...args: unknown[]): unknown { + return function (this: T, ...args: TArgs): TReturn { currentInstance = this; wrapContext.target = this; wrapContext.className = getClassName(this); diff --git a/src/wrap.decorator.ts b/src/wrap.decorator.ts index 3fe1467..0fbe14e 100644 --- a/src/wrap.decorator.ts +++ b/src/wrap.decorator.ts @@ -1,6 +1,6 @@ import { WrapOnClass } from './wrap-on-class'; import { WrapOnMethod } from './wrap-on-method'; -import type { WrapFn } from './hook.types'; +import type { WrapFn, TypedMethodDecorator } from './hook.types'; /** * Creates a decorator that can be applied to either a class or a method. @@ -8,25 +8,27 @@ import type { WrapFn } from './hook.types'; * When applied to a **class** (receives 1 argument -- the constructor), * delegates to {@link WrapOnClass} which wraps every eligible prototype * method with the provided wrapper function, skipping methods already - * marked with `WRAP_APPLIED_KEY` or `exclusionKey`. + * marked with `WRAP_KEY` or `exclusionKey`. * * When applied to a **method** (receives 3 arguments -- target, propertyKey, * descriptor), delegates to {@link WrapOnMethod} which wraps that single * method with the provided wrapper function and marks it with `exclusionKey` - * (or `WRAP_APPLIED_KEY` if none provided) to prevent double-wrapping + * (or `WRAP_KEY` if none provided) to prevent double-wrapping * by a class-level decorator. * * Throws an `Error` if invoked in any other context (e.g. `propertyKey` is * present but `descriptor` is `undefined`). * - * @typeParam R - The return type expected from the wrapper function + * @typeParam T - The class instance type. Defaults to `object`. + * @typeParam TArgs - Tuple of method parameter types. Defaults to `unknown[]`. + * @typeParam TReturn - The method return type. Defaults to `unknown`. * @param wrapFn - Factory called once on first invocation with the * `this`-bound original method and a * {@link WrapContext}. Returns the inner function * that receives raw args on each call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default - * `WRAP_APPLIED_KEY`. This allows different + * `WRAP_KEY`. This allows different * Wrap-based decorators (e.g. `@Log`, `@Timer`) to * use independent markers that do not interfere with * each other during class-level decoration. @@ -54,12 +56,16 @@ import type { WrapFn } from './hook.types'; * } * ``` */ -export const Wrap = ( - wrapFn: WrapFn, +export const Wrap = < + T extends object = object, + TArgs extends unknown[] = unknown[], + TReturn = unknown, +>( + wrapFn: WrapFn, exclusionKey?: symbol, -): ClassDecorator & MethodDecorator => { - const classDecorator = WrapOnClass(wrapFn, exclusionKey); - const methodDecorator = WrapOnMethod(wrapFn, exclusionKey); +): ClassDecorator & TypedMethodDecorator => { + const classDecorator = WrapOnClass(wrapFn, exclusionKey); + const methodDecorator = WrapOnMethod(wrapFn, exclusionKey); return (( target: Function | object, @@ -68,8 +74,14 @@ export const Wrap = ( ): Function | PropertyDescriptor | void => { // Class decorator: receives 1 argument (the constructor) if (propertyKey === undefined) { - classDecorator(target as Function); - return target as Function; + if (typeof target === 'function') { + classDecorator(target); + return target; + } + + throw new Error( + 'Wrap decorator can only be applied to classes or methods', + ); } // Method decorator: receives 3 arguments (target, propertyKey, descriptor) @@ -80,5 +92,5 @@ export const Wrap = ( throw new Error( 'Wrap decorator can only be applied to classes or methods', ); - }) as ClassDecorator & MethodDecorator; + }) as ClassDecorator & TypedMethodDecorator; }; diff --git a/tests/Effect.spec.ts b/tests/Effect.spec.ts index 78e6086..9a20233 100644 --- a/tests/Effect.spec.ts +++ b/tests/Effect.spec.ts @@ -2,14 +2,18 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from '../src/effect.decorator'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; -import type { EffectHooks } from '../src/set-meta.decorator'; +import type { EffectHooks, HooksOrFactory } from '../src/hook.types'; + +/** Permissive Effect wrapper for runtime-focused tests where type inference is not under test. */ +const AnyEffect = (hooks: HooksOrFactory, exclusionKey?: symbol) => + Effect(hooks, exclusionKey); describe('Effect', () => { describe('applied to a method', () => { it('should fire hooks correctly for sync method', () => { const callOrder: string[] = []; - const hooks: EffectHooks = { + const hooks: EffectHooks = { onInvoke: () => callOrder.push('onInvoke'), onReturn: ({ result }) => { callOrder.push('onReturn'); @@ -19,7 +23,7 @@ describe('Effect', () => { }; class TestService { - @Effect(hooks) + @AnyEffect(hooks) greet(name: string) { callOrder.push('original'); return `hello ${name}`; @@ -37,7 +41,7 @@ describe('Effect', () => { const onReturn = vi.fn(({ result }: { result: unknown }) => result); class TestService { - @Effect({ onReturn }) + @AnyEffect({ onReturn }) async fetchData(id: number) { return { id, name: 'test' }; } @@ -55,7 +59,7 @@ describe('Effect', () => { const onError = vi.fn(({ error }: { error: unknown }) => { throw error; }); class TestService { - @Effect({ onError }) + @AnyEffect({ onError }) failing() { throw testError; } @@ -72,7 +76,7 @@ describe('Effect', () => { it('should wrap all methods and fire hooks for each', () => { const onReturn = vi.fn(({ result }: { result: unknown }) => result); - @Effect({ onReturn }) + @AnyEffect({ onReturn }) class TestService { methodA() { return 'a'; @@ -93,7 +97,7 @@ describe('Effect', () => { it('should not wrap the constructor', () => { const onInvoke = vi.fn(); - @Effect({ onInvoke }) + @AnyEffect({ onInvoke }) class TestService { value: number; @@ -119,7 +123,7 @@ describe('Effect', () => { it('should fire all lifecycle hooks in correct order for class methods', () => { const callOrder: string[] = []; - const hooks: EffectHooks = { + const hooks: EffectHooks = { onInvoke: () => callOrder.push('onInvoke'), onReturn: ({ result }) => { callOrder.push('onReturn'); @@ -132,7 +136,7 @@ describe('Effect', () => { finally: () => callOrder.push('finally'), }; - @Effect(hooks) + @AnyEffect(hooks) class TestService { greet(name: string) { callOrder.push('original'); @@ -153,9 +157,9 @@ describe('Effect', () => { const classOnReturn = vi.fn(({ result }: { result: unknown }) => result); const methodOnReturn = vi.fn(({ result }: { result: unknown }) => result); - @Effect({ onReturn: classOnReturn }) + @AnyEffect({ onReturn: classOnReturn }) class TestService { - @Effect({ onReturn: methodOnReturn }) + @AnyEffect({ onReturn: methodOnReturn }) decoratedMethod() { return 'result'; } @@ -179,9 +183,9 @@ describe('Effect', () => { const classOnInvoke = vi.fn(); const methodOnInvoke = vi.fn(); - @Effect({ onInvoke: classOnInvoke }) + @AnyEffect({ onInvoke: classOnInvoke }) class TestService { - @Effect({ onInvoke: methodOnInvoke }) + @AnyEffect({ onInvoke: methodOnInvoke }) async fetchData(id: number) { return { id }; } @@ -204,11 +208,11 @@ describe('Effect', () => { it('should produce correct results when method-level Effect wins', () => { const methodOnReturn = vi.fn(({ result }: { result: string }) => `${result}-method-processed`); - @Effect({ + @AnyEffect({ onReturn: ({ result }: { result: string }) => `${result}-class-processed`, }) class TestService { - @Effect({ onReturn: methodOnReturn }) + @AnyEffect({ onReturn: methodOnReturn }) targeted() { return 'base'; } @@ -232,7 +236,7 @@ describe('Effect', () => { const EXCLUSION_KEY = Symbol('noEffect'); const onReturn = vi.fn(({ result }: { result: unknown }) => result); - @Effect({ onReturn }, EXCLUSION_KEY) + @AnyEffect({ onReturn }, EXCLUSION_KEY) class TestService { @SetMeta(EXCLUSION_KEY, true) excluded() { @@ -257,7 +261,7 @@ describe('Effect', () => { const onReturn = vi.fn(({ result }: { result: unknown }) => result); class TestService { - @Effect({ onReturn }, EXCLUSION_KEY) + @AnyEffect({ onReturn }, EXCLUSION_KEY) myMethod() { return 'result'; } @@ -282,9 +286,9 @@ describe('Effect', () => { const classOnReturn = vi.fn(({ result }: { result: unknown }) => result); const methodOnReturn = vi.fn(({ result }: { result: unknown }) => result); - @Effect({ onReturn: classOnReturn }, EXCLUSION_KEY) + @AnyEffect({ onReturn: classOnReturn }, EXCLUSION_KEY) class TestService { - @Effect({ onReturn: methodOnReturn }, EXCLUSION_KEY) + @AnyEffect({ onReturn: methodOnReturn }, EXCLUSION_KEY) decoratedMethod() { return 'result'; } @@ -312,7 +316,7 @@ describe('Effect', () => { })); class TestService { - @Effect(factorySpy) + @AnyEffect(factorySpy) doWork() { return 42; } @@ -339,7 +343,7 @@ describe('Effect', () => { })); class TestService { - @Effect(factorySpy) + @AnyEffect(factorySpy) doWork() { return 42; } @@ -373,7 +377,7 @@ describe('Effect', () => { }; class TestService { - @Effect(factory as Parameters[0]) + @AnyEffect(factory as Parameters[0]) greet(name: string, greeting: string) { return `${greeting} ${name}`; } @@ -402,7 +406,7 @@ describe('Effect', () => { }); class TestService { - @Effect(factory as Parameters[0]) + @AnyEffect(factory as Parameters[0]) greet(name: string) { return `hello ${name}`; } @@ -438,7 +442,7 @@ describe('Effect', () => { }); class TestService { - @Effect(factory as Parameters[0]) + @AnyEffect(factory as Parameters[0]) greet(name: string) { callOrder.push('original'); return `hello ${name}`; @@ -462,7 +466,7 @@ describe('Effect', () => { onReturn: ({ result }: { result: unknown }) => result, })); - @Effect(factorySpy) + @AnyEffect(factorySpy) class TestService { methodA() { return 'a'; diff --git a/tests/FinallyHook.spec.ts b/tests/FinallyHook.spec.ts index fa4c454..e006fdf 100644 --- a/tests/FinallyHook.spec.ts +++ b/tests/FinallyHook.spec.ts @@ -1,15 +1,20 @@ import { describe, it, expect, vi } from 'vitest'; import { FinallyHook } from '../src/finally.hook'; +import type { FinallyHookType } from '../src/hook.types'; + +/** Permissive FinallyHook wrapper for runtime-focused tests where type inference is not under test. */ +const AnyFinallyHook = (callback: FinallyHookType, exclusionKey?: symbol) => + FinallyHook(callback, exclusionKey); describe('FinallyHook', () => { describe('applied to a method', () => { it('should fire callback after sync method succeeds', () => { const callOrder: string[] = []; - const callback = vi.fn(() => callOrder.push('finally')); + const callback = vi.fn(() => { callOrder.push('finally'); }); class TestService { - @FinallyHook(callback) + @AnyFinallyHook(callback) greet(name: string) { callOrder.push('original'); return `hello ${name}`; @@ -27,10 +32,10 @@ describe('FinallyHook', () => { it('should fire callback after sync method throws', () => { const callOrder: string[] = []; const testError = new Error('failure'); - const callback = vi.fn(() => callOrder.push('finally')); + const callback = vi.fn(() => { callOrder.push('finally'); }); class TestService { - @FinallyHook(callback) + @AnyFinallyHook(callback) failing() { callOrder.push('original'); throw testError; @@ -45,10 +50,10 @@ describe('FinallyHook', () => { it('should fire callback after async method resolves', async () => { const callOrder: string[] = []; - const callback = vi.fn(() => callOrder.push('finally')); + const callback = vi.fn(() => { callOrder.push('finally'); }); class TestService { - @FinallyHook(callback) + @AnyFinallyHook(callback) async fetchData(id: number) { callOrder.push('original'); return { id }; @@ -66,10 +71,10 @@ describe('FinallyHook', () => { it('should fire callback after async method rejects', async () => { const callOrder: string[] = []; const testError = new Error('async failure'); - const callback = vi.fn(() => callOrder.push('finally')); + const callback = vi.fn(() => { callOrder.push('finally'); }); class TestService { - @FinallyHook(callback) + @AnyFinallyHook(callback) async failing() { callOrder.push('original'); throw testError; @@ -86,7 +91,7 @@ describe('FinallyHook', () => { const callback = vi.fn(); class TestService { - @FinallyHook(callback) + @AnyFinallyHook(callback) doWork(a: number, b: string) { return `${a}-${b}`; } @@ -111,7 +116,7 @@ describe('FinallyHook', () => { const callback = vi.fn(); const testError = new Error('method B error'); - @FinallyHook(callback) + @AnyFinallyHook(callback) class TestService { methodA() { return 'a'; @@ -132,7 +137,7 @@ describe('FinallyHook', () => { it('should not fire callback during construction', () => { const callback = vi.fn(); - @FinallyHook(callback) + @AnyFinallyHook(callback) class TestService { value: number; diff --git a/tests/OnErrorHook.spec.ts b/tests/OnErrorHook.spec.ts index 3f0c38b..7493d16 100644 --- a/tests/OnErrorHook.spec.ts +++ b/tests/OnErrorHook.spec.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import { OnErrorHook } from '../src/on-error.hook'; -import type { OnErrorContext } from '../src/hook.types'; +import type { OnErrorContext, OnErrorHookType } from '../src/hook.types'; + +/** Permissive OnErrorHook wrapper for runtime-focused tests where type inference is not under test. */ +const AnyOnErrorHook = (callback: OnErrorHookType, exclusionKey?: symbol) => + OnErrorHook(callback, exclusionKey); describe('OnErrorHook', () => { describe('applied to a method', () => { @@ -10,7 +14,7 @@ describe('OnErrorHook', () => { const callback = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); class TestService { - @OnErrorHook(callback) + @AnyOnErrorHook(callback) failing() { throw testError; } @@ -27,7 +31,7 @@ describe('OnErrorHook', () => { const callback = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); class TestService { - @OnErrorHook(callback) + @AnyOnErrorHook(callback) async failing() { throw testError; } @@ -43,7 +47,7 @@ describe('OnErrorHook', () => { const callback = vi.fn(() => 'recovered'); class TestService { - @OnErrorHook(callback) + @AnyOnErrorHook(callback) failing(): string { throw new Error('oops'); } @@ -60,7 +64,7 @@ describe('OnErrorHook', () => { const callback = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); class TestService { - @OnErrorHook(callback) + @AnyOnErrorHook(callback) succeeding() { return 'success'; } @@ -78,7 +82,7 @@ describe('OnErrorHook', () => { const callback = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); class TestService { - @OnErrorHook(callback) + @AnyOnErrorHook(callback) failing(input: string) { throw testError; } @@ -103,7 +107,7 @@ describe('OnErrorHook', () => { const testErrorB = new Error('error B'); const callback = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); - @OnErrorHook(callback) + @AnyOnErrorHook(callback) class TestService { methodA() { throw testErrorA; diff --git a/tests/OnInvokeHook.spec.ts b/tests/OnInvokeHook.spec.ts index 8ce3c0d..8f5c0ee 100644 --- a/tests/OnInvokeHook.spec.ts +++ b/tests/OnInvokeHook.spec.ts @@ -1,15 +1,20 @@ import { describe, it, expect, vi } from 'vitest'; import { OnInvokeHook } from '../src/on-invoke.hook'; +import type { OnInvokeHookType } from '../src/hook.types'; + +/** Permissive OnInvokeHook wrapper for runtime-focused tests where type inference is not under test. */ +const AnyOnInvokeHook = (callback: OnInvokeHookType, exclusionKey?: symbol) => + OnInvokeHook(callback, exclusionKey); describe('OnInvokeHook', () => { describe('applied to a method', () => { it('should fire callback before sync method executes', () => { const callOrder: string[] = []; - const callback = vi.fn(() => callOrder.push('onInvoke')); + const callback = vi.fn(() => { callOrder.push('onInvoke'); }); class TestService { - @OnInvokeHook(callback) + @AnyOnInvokeHook(callback) greet(name: string) { callOrder.push('original'); return `hello ${name}`; @@ -26,10 +31,10 @@ describe('OnInvokeHook', () => { it('should fire callback before async method executes', async () => { const callOrder: string[] = []; - const callback = vi.fn(() => callOrder.push('onInvoke')); + const callback = vi.fn(() => { callOrder.push('onInvoke'); }); class TestService { - @OnInvokeHook(callback) + @AnyOnInvokeHook(callback) async fetchData(id: number) { callOrder.push('original'); return { id }; @@ -48,7 +53,7 @@ describe('OnInvokeHook', () => { const callback = vi.fn(); class TestService { - @OnInvokeHook(callback) + @AnyOnInvokeHook(callback) doWork(a: number, b: string) { return `${a}-${b}`; } @@ -72,7 +77,7 @@ describe('OnInvokeHook', () => { const testError = new Error('failure'); class TestService { - @OnInvokeHook(callback) + @AnyOnInvokeHook(callback) failing() { throw testError; } @@ -88,7 +93,7 @@ describe('OnInvokeHook', () => { it('should fire callback before each method executes', () => { const callback = vi.fn(); - @OnInvokeHook(callback) + @AnyOnInvokeHook(callback) class TestService { methodA() { return 'a'; @@ -109,7 +114,7 @@ describe('OnInvokeHook', () => { it('should not fire callback during construction', () => { const callback = vi.fn(); - @OnInvokeHook(callback) + @AnyOnInvokeHook(callback) class TestService { value: number; diff --git a/tests/OnReturnHook.spec.ts b/tests/OnReturnHook.spec.ts index abd04e7..abcdbae 100644 --- a/tests/OnReturnHook.spec.ts +++ b/tests/OnReturnHook.spec.ts @@ -1,19 +1,23 @@ import { describe, it, expect, vi } from 'vitest'; import { OnReturnHook } from '../src/on-return.hook'; -import type { OnReturnContext } from '../src/hook.types'; +import type { OnReturnContext, OnReturnHookType } from '../src/hook.types'; + +/** Permissive OnReturnHook wrapper for runtime-focused tests where type inference is not under test. */ +const AnyOnReturnHook = (callback: OnReturnHookType, exclusionKey?: symbol) => + OnReturnHook(callback, exclusionKey); describe('OnReturnHook', () => { describe('applied to a method', () => { it('should fire callback after sync method returns successfully', () => { const callOrder: string[] = []; - const callback = vi.fn((ctx: OnReturnContext) => { + const callback = vi.fn((ctx: OnReturnContext) => { callOrder.push('onReturn'); return ctx.result; }); class TestService { - @OnReturnHook(callback) + @AnyOnReturnHook(callback) greet(name: string) { callOrder.push('original'); return `hello ${name}`; @@ -36,7 +40,7 @@ describe('OnReturnHook', () => { }); class TestService { - @OnReturnHook(callback) + @AnyOnReturnHook(callback) async fetchData(id: number) { callOrder.push('original'); return { id }; @@ -52,10 +56,10 @@ describe('OnReturnHook', () => { }); it('should allow callback to transform the return value', () => { - const callback = vi.fn((ctx: OnReturnContext) => `${ctx.result}-transformed`); + const callback = vi.fn((ctx: OnReturnContext) => `${ctx.result}-transformed`); class TestService { - @OnReturnHook(callback) + @AnyOnReturnHook(callback) greet(name: string) { return `hello ${name}`; } @@ -72,7 +76,7 @@ describe('OnReturnHook', () => { const testError = new Error('failure'); class TestService { - @OnReturnHook(callback) + @AnyOnReturnHook(callback) failing() { throw testError; } @@ -84,10 +88,10 @@ describe('OnReturnHook', () => { }); it('should pass args, target, propertyKey, result, and descriptor to callback', () => { - const callback = vi.fn((ctx: OnReturnContext) => ctx.result); + const callback = vi.fn((ctx: OnReturnContext) => ctx.result); class TestService { - @OnReturnHook(callback) + @AnyOnReturnHook(callback) add(a: number, b: number) { return a + b; } @@ -112,7 +116,7 @@ describe('OnReturnHook', () => { it('should fire callback after each method returns', () => { const callback = vi.fn((ctx: OnReturnContext) => ctx.result); - @OnReturnHook(callback) + @AnyOnReturnHook(callback) class TestService { methodA() { return 'a'; diff --git a/tests/Wrap.spec.ts b/tests/Wrap.spec.ts index 96a9894..8e6a6d0 100644 --- a/tests/Wrap.spec.ts +++ b/tests/Wrap.spec.ts @@ -5,10 +5,13 @@ import { SetMeta, getMeta } from '../src/set-meta.decorator'; import { WRAP_KEY } from '../src/wrap-on-method'; import type { WrapFn, WrapContext } from '../src/hook.types'; +/** Permissive WrapFn alias for runtime-focused tests where type inference is not under test. */ +type AnyWrapFn = WrapFn; + describe('Wrap', () => { describe('applied to a method', () => { it('should delegate to WrapOnMethod and wrap the method', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -26,7 +29,7 @@ describe('Wrap', () => { }); it('should set WRAP_APPLIED_KEY on the method descriptor', () => { - const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); + const wrapFn: AnyWrapFn = (method, _context) => (...args) => method(...args); class TestService { @Wrap(wrapFn) @@ -47,7 +50,7 @@ describe('Wrap', () => { it('should delegate to WrapOnClass and wrap all prototype methods', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -76,14 +79,14 @@ describe('Wrap', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (_method, context) => { + const classWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { classCalls.push(String(context.propertyKey)); return _method(...args); }; }; - const methodWrapFn: WrapFn = (_method, context) => { + const methodWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { methodCalls.push(String(context.propertyKey)); return _method(...args); @@ -111,7 +114,7 @@ describe('Wrap', () => { }); it('should return the constructor when applied to a class', () => { - const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); + const wrapFn: AnyWrapFn = (method, _context) => (...args) => method(...args); @Wrap(wrapFn) class TestService { @@ -128,7 +131,7 @@ describe('Wrap', () => { it('should not wrap getters or setters', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -164,7 +167,7 @@ describe('Wrap', () => { }); it('should not wrap the constructor', () => { - const wrapFnSpy = vi.fn((method, _context) => { + const wrapFnSpy = vi.fn((method, _context) => { return (...args) => method(...args); }); @@ -197,7 +200,7 @@ describe('Wrap', () => { it('should pass WrapContext with all fields on first call', () => { let receivedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { receivedContext = context; return (...args) => method(...args); }; @@ -227,7 +230,7 @@ describe('Wrap', () => { it('should provide target and className in WrapContext', () => { let receivedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { receivedCtx = context; return (...args) => method(...args); }; @@ -251,7 +254,7 @@ describe('Wrap', () => { it('should pass a this-bound method proxy', () => { let receivedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { receivedMethod = method; return (...args) => method(...args); }; @@ -277,7 +280,7 @@ describe('Wrap', () => { }); it('should bind method to the correct instance for each invocation', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -300,7 +303,7 @@ describe('Wrap', () => { describe('sync method through Wrap', () => { it('should wrap a sync method and return its result unchanged', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -316,7 +319,7 @@ describe('Wrap', () => { }); it('should allow Wrap to modify the sync return value', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => { const result = method(...args) as number; return result * 10; @@ -335,7 +338,7 @@ describe('Wrap', () => { }); it('should allow Wrap to intercept arguments for sync methods', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => { // Intercept: double all numeric arguments const doubled = args.map((a) => @@ -360,7 +363,7 @@ describe('Wrap', () => { describe('async method through Wrap', () => { it('should wrap an async method and return its resolved value', async () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return async (...args) => { const result = await method(...args); return result; @@ -381,7 +384,7 @@ describe('Wrap', () => { }); it('should allow Wrap to modify the async return value', async () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return async (...args) => { const result = (await method(...args)) as { id: number; name: string }; return { ...result, modified: true }; @@ -402,7 +405,7 @@ describe('Wrap', () => { }); it('should propagate errors from async methods', async () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return async (...args) => { return method(...args); }; @@ -427,14 +430,14 @@ describe('Wrap', () => { const CUSTOM_KEY = Symbol('custom'); const calls: string[] = []; - const wrapFnA: WrapFn = (_method, context) => { + const wrapFnA: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(`A:${String(context.propertyKey)}`); return _method(...args); }; }; - const wrapFnB: WrapFn = (_method, context) => { + const wrapFnB: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(`B:${String(context.propertyKey)}`); return _method(...args); @@ -462,14 +465,14 @@ describe('Wrap', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (_method, context) => { + const classWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { classCalls.push(String(context.propertyKey)); return _method(...args); }; }; - const methodWrapFn: WrapFn = (_method, context) => { + const methodWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { methodCalls.push(String(context.propertyKey)); return _method(...args); @@ -502,7 +505,7 @@ describe('Wrap', () => { const EXCLUSION_KEY = Symbol('noWrap'); const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -531,7 +534,7 @@ describe('Wrap', () => { it('should mark method with exclusionKey when applied at method level', () => { const EXCLUSION_KEY = Symbol('customKey'); - const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); + const wrapFn: AnyWrapFn = (method, _context) => (...args) => method(...args); class TestService { @Wrap(wrapFn, EXCLUSION_KEY) @@ -551,9 +554,109 @@ describe('Wrap', () => { }); }); + describe('inner function receives exact call-site arguments', () => { + it('should pass the same args array to the inner function', () => { + let capturedArgs: unknown[] | undefined; + + const wrapFn: AnyWrapFn = (method, _context) => { + return (...args) => { + capturedArgs = args; + return method(...args); + }; + }; + + class Calculator { + @Wrap(wrapFn) + add(a: number, b: number) { + return a + b; + } + } + + const calc = new Calculator(); + calc.add(2, 3); + + expect(capturedArgs).toEqual([2, 3]); + }); + + it('should match call-site arguments with mixed types', () => { + let capturedArgs: unknown[] | undefined; + + const wrapFn: AnyWrapFn = (method, _context) => { + return (...args) => { + capturedArgs = args; + return method(...args); + }; + }; + + class Service { + @Wrap(wrapFn) + process(name: string, count: number, config: { verbose: boolean }) { + return { name, count, config }; + } + } + + const service = new Service(); + const config = { verbose: true }; + service.process('test', 42, config); + + expect(capturedArgs).toBeDefined(); + expect(capturedArgs).toHaveLength(3); + expect(capturedArgs![0]).toBe('test'); + expect(capturedArgs![1]).toBe(42); + expect(capturedArgs![2]).toBe(config); + }); + + it('should receive empty args array when called with no arguments', () => { + let capturedArgs: unknown[] | undefined; + + const wrapFn: AnyWrapFn = (method, _context) => { + return (...args) => { + capturedArgs = args; + return method(...args); + }; + }; + + class Service { + @Wrap(wrapFn) + noArgs() { + return 'done'; + } + } + + const service = new Service(); + service.noArgs(); + + expect(capturedArgs).toEqual([]); + }); + + it('should receive updated args on each invocation', () => { + const allCaptures: unknown[][] = []; + + const wrapFn: AnyWrapFn = (method, _context) => { + return (...args) => { + allCaptures.push([...args]); + return method(...args); + }; + }; + + class Service { + @Wrap(wrapFn) + greet(name: string) { + return `hello ${name}`; + } + } + + const service = new Service(); + service.greet('alice'); + service.greet('bob'); + + expect(allCaptures).toEqual([['alice'], ['bob']]); + }); + }); + describe('invalid decorator context', () => { it('should throw Error when applied in an unsupported context', () => { - const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); + const wrapFn: AnyWrapFn = (method, _context) => (...args) => method(...args); const decorator = Wrap(wrapFn); @@ -564,7 +667,7 @@ describe('Wrap', () => { }); it('should throw Error with propertyKey present but descriptor missing', () => { - const wrapFn: WrapFn = (method, _context) => (...args) => method(...args); + const wrapFn: AnyWrapFn = (method, _context) => (...args) => method(...args); const decorator = Wrap(wrapFn); diff --git a/tests/WrapOnClass.spec.ts b/tests/WrapOnClass.spec.ts index 8a955ec..75769b9 100644 --- a/tests/WrapOnClass.spec.ts +++ b/tests/WrapOnClass.spec.ts @@ -5,12 +5,15 @@ import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; import type { WrapFn, WrapContext } from '../src/hook.types'; +/** Permissive WrapFn alias for runtime-focused tests where type inference is not under test. */ +type AnyWrapFn = WrapFn; + describe('WrapOnClass', () => { describe('wraps all regular prototype methods', () => { it('should wrap every eligible method with the provided WrapFn', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -41,7 +44,7 @@ describe('WrapOnClass', () => { }); it('should preserve correct return values from wrapped methods', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -64,7 +67,7 @@ describe('WrapOnClass', () => { describe('skips constructor', () => { it('should not fire the wrapper during construction', () => { - const wrapFnSpy = vi.fn((method, _context) => { + const wrapFnSpy = vi.fn((method, _context) => { return (...args) => method(...args); }); @@ -95,7 +98,7 @@ describe('WrapOnClass', () => { it('should not include constructor in the set of wrapped property names', () => { const wrappedNames: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { wrappedNames.push(String(context.propertyKey)); return (...args) => _method(...args); }; @@ -132,7 +135,7 @@ describe('WrapOnClass', () => { it('should not wrap getter or setter accessors', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -168,7 +171,7 @@ describe('WrapOnClass', () => { it('should skip getter-only properties', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -197,7 +200,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; let stored = 0; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -228,7 +231,7 @@ describe('WrapOnClass', () => { it('should not attempt to wrap non-function prototype properties', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -259,7 +262,7 @@ describe('WrapOnClass', () => { it('should skip string and object prototype values', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -297,7 +300,7 @@ describe('WrapOnClass', () => { it('should skip methods explicitly excluded via SetMeta with default key', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -328,7 +331,7 @@ describe('WrapOnClass', () => { const CUSTOM_KEY = Symbol('custom'); const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -361,14 +364,14 @@ describe('WrapOnClass', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (_method, context) => { + const classWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { classCalls.push(String(context.propertyKey)); return _method(...args); }; }; - const methodWrapFn: WrapFn = (_method, context) => { + const methodWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { methodCalls.push(String(context.propertyKey)); return _method(...args); @@ -401,14 +404,14 @@ describe('WrapOnClass', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (_method, context) => { + const classWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { classCalls.push(String(context.propertyKey)); return _method(...args); }; }; - const methodWrapFn: WrapFn = (_method, context) => { + const methodWrapFn: AnyWrapFn = (_method, context) => { return (...args) => { methodCalls.push(String(context.propertyKey)); return _method(...args); @@ -441,7 +444,7 @@ describe('WrapOnClass', () => { it('should default to WRAP_APPLIED_KEY when no exclusionKey is provided', () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); @@ -469,7 +472,7 @@ describe('WrapOnClass', () => { }); it('should set WRAP_APPLIED_KEY metadata on methods it wraps', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -492,7 +495,7 @@ describe('WrapOnClass', () => { it('should set custom exclusion key metadata on wrapped methods', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -515,7 +518,7 @@ describe('WrapOnClass', () => { it('should not set WRAP_APPLIED_KEY when a custom key is provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -543,14 +546,14 @@ describe('WrapOnClass', () => { const callsA: string[] = []; const callsB: string[] = []; - const wrapFnA: WrapFn = (_method, context) => { + const wrapFnA: AnyWrapFn = (_method, context) => { return (...args) => { callsA.push(String(context.propertyKey)); return _method(...args); }; }; - const wrapFnB: WrapFn = (_method, context) => { + const wrapFnB: AnyWrapFn = (_method, context) => { return (...args) => { callsB.push(String(context.propertyKey)); return _method(...args); @@ -577,7 +580,7 @@ describe('WrapOnClass', () => { describe('this binding is preserved', () => { it('should preserve this context in wrapped methods', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -599,7 +602,7 @@ describe('WrapOnClass', () => { it('should pass decoration-time and runtime fields in WrapContext', () => { const capturedContexts: WrapContext[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { capturedContexts.push(context); return (...args) => _method(...args); }; @@ -634,7 +637,7 @@ describe('WrapOnClass', () => { it('should wrap async methods correctly', async () => { const calls: string[] = []; - const wrapFn: WrapFn = (_method, context) => { + const wrapFn: AnyWrapFn = (_method, context) => { return (...args) => { calls.push(String(context.propertyKey)); return _method(...args); diff --git a/tests/WrapOnMethod.spec.ts b/tests/WrapOnMethod.spec.ts index 411ed24..b3ea1d5 100644 --- a/tests/WrapOnMethod.spec.ts +++ b/tests/WrapOnMethod.spec.ts @@ -4,6 +4,9 @@ import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { getMeta, SetMeta } from '../src/set-meta.decorator'; import type { WrapFn, WrapContext } from '../src/hook.types'; +/** Permissive WrapFn alias for runtime-focused tests where type inference is not under test. */ +type AnyWrapFn = WrapFn; + describe('WrapOnMethod', () => { describe('WRAP_KEY', () => { it('should be a unique symbol', () => { @@ -14,7 +17,7 @@ describe('WrapOnMethod', () => { describe('basic wrapping', () => { it('should call wrapFn once on first invocation, not at decoration time', () => { - const wrapFnSpy = vi.fn((method, _context) => { + const wrapFnSpy = vi.fn((method, _context) => { return (...args) => method(...args); }); @@ -40,7 +43,7 @@ describe('WrapOnMethod', () => { let wrapCount = 0; let callCount = 0; - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { wrapCount++; return (...args) => { callCount++; @@ -76,7 +79,7 @@ describe('WrapOnMethod', () => { it('should reuse the same factory function across instances', () => { let wrapCount = 0; - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { wrapCount++; return (...args) => method(...args); }; @@ -106,7 +109,7 @@ describe('WrapOnMethod', () => { }); it('should return the result from innerFn', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: WrapFn = (method, _context) => { return (...args) => { const result = method(...args) as number; return result * 2; @@ -127,7 +130,7 @@ describe('WrapOnMethod', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -147,7 +150,7 @@ describe('WrapOnMethod', () => { it('should pass a method proxy that delegates to current this', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { capturedMethod = method; return (...args) => method(...args); }; @@ -174,7 +177,7 @@ describe('WrapOnMethod', () => { it('should provide target and className in WrapContext on first call', () => { let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedCtx = context; return (...args) => method(...args); }; @@ -197,7 +200,7 @@ describe('WrapOnMethod', () => { it('should provide all decoration-time context fields', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedContext = context; return (...args) => method(...args); }; @@ -223,7 +226,7 @@ describe('WrapOnMethod', () => { it('should update target and className on each call (mutable context)', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedContext = context; return (...args) => method(...args); }; @@ -253,7 +256,7 @@ describe('WrapOnMethod', () => { it('should extract parameter names at decoration time', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedContext = context; return (...args) => method(...args); }; @@ -274,7 +277,7 @@ describe('WrapOnMethod', () => { it('should reuse the same WrapContext reference since wrapFn is called once', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedContext = context; return (...args) => method(...args); }; @@ -302,7 +305,7 @@ describe('WrapOnMethod', () => { it('should return empty array for a method with no parameters', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedContext = context; return (...args) => method(...args); }; @@ -324,7 +327,7 @@ describe('WrapOnMethod', () => { describe('exclusion key', () => { it('should set WRAP_KEY as default exclusion key', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -346,7 +349,7 @@ describe('WrapOnMethod', () => { it('should use custom exclusion key when provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -368,7 +371,7 @@ describe('WrapOnMethod', () => { it('should NOT set default WRAP_KEY when custom key is provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -394,7 +397,7 @@ describe('WrapOnMethod', () => { it('should preserve SetMeta metadata after wrapping', () => { const META_KEY = Symbol('testMeta'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -418,7 +421,7 @@ describe('WrapOnMethod', () => { const KEY_A = Symbol('a'); const KEY_B = Symbol('b'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -443,7 +446,7 @@ describe('WrapOnMethod', () => { describe('sync method wrapping', () => { it('should pass through the return value unchanged when wrapper delegates', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -461,7 +464,7 @@ describe('WrapOnMethod', () => { it('should propagate sync errors from the original method', () => { const syncError = new Error('sync failure'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -479,7 +482,7 @@ describe('WrapOnMethod', () => { describe('async methods', () => { it('should work with async methods', async () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -497,7 +500,7 @@ describe('WrapOnMethod', () => { }); it('should allow async wrapper to modify async results', async () => { - const wrapFn: WrapFn> = (method, _context) => { + const wrapFn: WrapFn> = (method, _context) => { return async (...args) => { const result = (await method(...args)) as string; return `modified: ${result}`; @@ -520,7 +523,7 @@ describe('WrapOnMethod', () => { it('should propagate async errors (rejected promises) from the original method', async () => { const asyncError = new Error('async failure'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -538,7 +541,7 @@ describe('WrapOnMethod', () => { describe('method decorator return type', () => { it('should return a valid MethodDecorator', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -547,7 +550,7 @@ describe('WrapOnMethod', () => { }); it('should replace descriptor.value with the wrapped function', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; diff --git a/tests/hook-types.spec.ts b/tests/hook-types.spec.ts index 65e8ac9..0018de0 100644 --- a/tests/hook-types.spec.ts +++ b/tests/hook-types.spec.ts @@ -1,8 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, expectTypeOf } from 'vitest'; +import { Wrap } from '../src/wrap.decorator'; +import { Effect } from '../src/effect.decorator'; +import { OnInvokeHook } from '../src/on-invoke.hook'; +import { OnReturnHook } from '../src/on-return.hook'; +import { OnErrorHook } from '../src/on-error.hook'; +import { FinallyHook } from '../src/finally.hook'; import type { WrapContext, WrapFn, + TypedMethodDecorator, HookContext, HookArgs, OnReturnContext, @@ -58,9 +65,9 @@ describe('hook.types', () => { }); it('supports generic return type parameter', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: WrapFn = (method, _context) => { return (...args) => { - return (method(...args) as number) * 2; + return method(...args) * 2; }; }; @@ -122,7 +129,7 @@ describe('hook.types', () => { }); it('OnReturnContext extends HookContext with result', () => { - const ctx: OnReturnContext = { + const ctx: OnReturnContext = { target: {}, propertyKey: 'method', parameterNames: [], @@ -183,4 +190,693 @@ describe('hook.types', () => { expect(finallyHook).toBeDefined(); }); }); + + describe('generic type parameters (compile-time inference)', () => { + it('WrapContext accepts explicit generic parameters', () => { + class MyService { name = 'svc'; } + + const ctx: WrapContext = { + propertyKey: 'method', + parameterNames: ['id', 'name'], + descriptor: { value: () => true, writable: true, enumerable: true, configurable: true }, + target: new MyService(), + className: 'MyService', + }; + + // target is typed as MyService + expect(ctx.target.name).toBe('svc'); + expect(ctx.propertyKey).toBe('method'); + }); + + it('WrapFn with explicit generics types the method and context', () => { + class MyService { id = 1; } + + const wrapFn: WrapFn = (method, context) => { + // method accepts [number, string] and returns string + // context.target is MyService + expect(context.target.id).toBe(1); + return (...args) => { + const result = method(...args); + return `wrapped: ${result}`; + }; + }; + + // Verify the factory function exists and is callable + const fakeMethod = (_a: number, _b: string) => 'hello'; + const ctx: WrapContext = { + propertyKey: 'test', + parameterNames: ['a', 'b'], + descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, + target: new MyService(), + className: 'MyService', + }; + + const inner = wrapFn(fakeMethod, ctx); + expect(inner(42, 'world')).toBe('wrapped: hello'); + }); + + it('WrapFn defaults preserve backward compatibility', () => { + // WrapFn sets TReturn = number while T and TArgs default + const wrapFn: WrapFn = (method, _context) => { + return (...args) => { + const result = method(...args); + return typeof result === 'number' ? result * 2 : 0; + }; + }; + + const fakeMethod = (..._args: unknown[]) => 21; + const ctx: WrapContext = { + propertyKey: 'test', + parameterNames: [], + descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, + target: {}, + className: 'Test', + }; + + const innerFn = wrapFn(fakeMethod, ctx); + expect(innerFn()).toBe(42); + }); + + it('HookContext accepts explicit generic parameters', () => { + class MyService { label = 'test'; } + + const hookCtx: HookContext = { + target: new MyService(), + propertyKey: 'method', + parameterNames: ['id'], + className: 'MyService', + descriptor: { value: () => 'ok', writable: true, enumerable: true, configurable: true }, + args: [42], + argsObject: { id: 42 }, + }; + + // target is typed as MyService + expect(hookCtx.target.label).toBe('test'); + // args is typed as [number] + expect(hookCtx.args).toEqual([42]); + }); + + it('OnReturnContext accepts explicit generic parameters', () => { + const ctx: OnReturnContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + result: 'hello', + }; + + expect(ctx.result).toBe('hello'); + }); + + it('generic WrapContext is assignable to default WrapContext (structural subtype)', () => { + class MyService { name = 'svc'; } + + const specific: WrapContext = { + propertyKey: 'method', + parameterNames: ['id'], + descriptor: { value: () => 'ok', writable: true, enumerable: true, configurable: true }, + target: new MyService(), + className: 'MyService', + }; + + // A specific WrapContext should be assignable to the default WrapContext + const general: WrapContext = specific; + expect(general.propertyKey).toBe('method'); + }); + }); + + describe('compile-time type assertions (expectTypeOf)', () => { + it('WrapContext target is inferred as T when specified', () => { + class MyService { name = 'svc'; } + + const ctx: WrapContext = { + propertyKey: 'method', + parameterNames: [], + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + target: new MyService(), + className: 'MyService', + }; + + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.propertyKey).toEqualTypeOf(); + expectTypeOf(ctx.parameterNames).toEqualTypeOf(); + expectTypeOf(ctx.className).toEqualTypeOf(); + }); + + it('WrapContext defaults target to object when no generic specified', () => { + const ctx: WrapContext = { + propertyKey: 'method', + parameterNames: [], + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + target: {}, + className: 'Test', + }; + + expectTypeOf(ctx.target).toEqualTypeOf(); + }); + + it('HookContext args is inferred as TArgs when specified', () => { + class MyService { label = 'test'; } + + const hookCtx: HookContext = { + target: new MyService(), + propertyKey: 'method', + parameterNames: ['id', 'name'], + className: 'MyService', + descriptor: { value: () => 'ok', writable: true, enumerable: true, configurable: true }, + args: [42, 'hello'], + argsObject: { id: 42, name: 'hello' }, + }; + + expectTypeOf(hookCtx.target).toEqualTypeOf(); + expectTypeOf(hookCtx.args).toEqualTypeOf<[number, string]>(); + expectTypeOf(hookCtx.argsObject).toEqualTypeOf(); + }); + + it('HookContext defaults args to unknown[] when no generic specified', () => { + const hookCtx: HookContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Test', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + }; + + expectTypeOf(hookCtx.args).toEqualTypeOf(); + }); + + it('WrapFn method parameter reflects TArgs and TReturn', () => { + class Svc { id = 1; } + type MyWrapFn = WrapFn; + + // Verify the method parameter accepts (number, string) and returns boolean + const fn: MyWrapFn = (method, context) => { + expectTypeOf(method).toEqualTypeOf<(a: number, b: string) => boolean>(); + expectTypeOf(context.target).toEqualTypeOf(); + return (...args) => { + expectTypeOf(args).toEqualTypeOf<[number, string]>(); + return method(...args); + }; + }; + + // Prevent unused variable warning + expect(fn).toBeDefined(); + }); + + it('WrapFn inner function returns TReturn', () => { + type NumberWrapFn = WrapFn; + + const fn: NumberWrapFn = (method, _ctx) => { + return (...args) => { + void method(...args); + return 42; + }; + }; + + const fakeMethod = (..._args: unknown[]) => 0; + const ctx: WrapContext = { + propertyKey: 'test', + parameterNames: [], + descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, + target: {}, + className: 'Test', + }; + + const inner = fn(fakeMethod, ctx); + expectTypeOf(inner).toEqualTypeOf<(...args: unknown[]) => number>(); + }); + + it('OnReturnContext result is UnwrapPromise', () => { + // For sync return type + const syncCtx: OnReturnContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + result: 42, + }; + + expectTypeOf(syncCtx.result).toEqualTypeOf(); + + // For async return type, result is unwrapped + const asyncCtx: OnReturnContext> = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + result: 'unwrapped', + }; + + expectTypeOf(asyncCtx.result).toEqualTypeOf(); + }); + + it('OnErrorContext extends HookContext with error field', () => { + const ctx: OnErrorContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + error: new Error('test'), + }; + + expectTypeOf(ctx.error).toEqualTypeOf(); + expectTypeOf(ctx.target).toEqualTypeOf(); + }); + + it('generic WrapContext is structurally assignable to default WrapContext', () => { + class MyService { name = 'svc'; } + + const specific: WrapContext = { + propertyKey: 'method', + parameterNames: ['id'], + descriptor: { value: () => 'ok', writable: true, enumerable: true, configurable: true }, + target: new MyService(), + className: 'MyService', + }; + + // Verify assignability at compile time + expectTypeOf(specific).toMatchTypeOf(); + }); + }); + + describe('negative compile-time type assertions (@ts-expect-error)', () => { + it('WrapContext rejects wrong target type', () => { + class MyService { name = 'svc'; } + + const ctx: WrapContext = { + propertyKey: 'method', + parameterNames: [], + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + target: new MyService(), + className: 'MyService', + }; + + // @ts-expect-error - target is MyService, not assignable to number + const _wrong: number = ctx.target; + expect(_wrong).toBeDefined(); + }); + + it('HookContext rejects wrong args tuple type', () => { + const ctx: HookContext = { + target: {}, + propertyKey: 'method', + parameterNames: ['a', 'b'], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [1, 'hello'], + argsObject: { a: 1, b: 'hello' }, + }; + + // @ts-expect-error - args is [number, string], not [boolean] + const _wrong: [boolean] = ctx.args; + expect(_wrong).toBeDefined(); + }); + + it('WrapContext T must extend object', () => { + // @ts-expect-error - string does not extend object + type _BadCtx = WrapContext; + expect(true).toBe(true); + }); + + it('WrapFn method parameter rejects incompatible call signatures', () => { + // A WrapFn typed for [number] args should not accept [string] args + type NumberArgsWrapFn = WrapFn; + + const fn: NumberArgsWrapFn = (method, _ctx) => { + return (...args) => { + return method(...args); + }; + }; + + const fakeMethod = (_a: number) => undefined; + const ctx: WrapContext = { + propertyKey: 'test', + parameterNames: ['a'], + descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, + target: {}, + className: 'Test', + }; + + const inner = fn(fakeMethod, ctx); + + // @ts-expect-error - inner expects (number), not (string) + inner('wrong'); + expect(true).toBe(true); + }); + + it('OnReturnContext rejects wrong result type', () => { + const ctx: OnReturnContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + result: 42, + }; + + // @ts-expect-error - result is number, not string + const _wrong: string = ctx.result; + expect(_wrong).toBeDefined(); + }); + }); + + describe('generic propagation through Effect hook type aliases', () => { + it('OnInvokeHookType propagates T and TArgs to its HookContext', () => { + class Svc { id = 1; } + + const hook: OnInvokeHookType = (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.args).toEqualTypeOf<[number]>(); + }; + + expect(hook).toBeDefined(); + }); + + it('OnReturnHookType propagates T and TArgs to its OnReturnContext', () => { + class Svc { name = 'test'; } + + const hook: OnReturnHookType = (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.args).toEqualTypeOf<[string]>(); + expectTypeOf(ctx.result).toEqualTypeOf(); + return ctx.result; + }; + + expect(hook).toBeDefined(); + }); + + it('OnErrorHookType propagates T and TArgs to its OnErrorContext', () => { + class Svc { label = 'err'; } + + const hook: OnErrorHookType = (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.args).toEqualTypeOf<[boolean]>(); + expectTypeOf(ctx.error).toEqualTypeOf(); + return 'recovered'; + }; + + expect(hook).toBeDefined(); + }); + + it('FinallyHookType propagates T and TArgs to its HookContext', () => { + class Svc { done = false; } + + const hook: FinallyHookType = (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.args).toEqualTypeOf<[number, string]>(); + }; + + expect(hook).toBeDefined(); + }); + + it('EffectHooks propagates generics to all hook types', () => { + class Svc { id = 1; } + + const hooks: EffectHooks = { + onInvoke: (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.args).toEqualTypeOf<[string]>(); + }, + onReturn: (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.result).toEqualTypeOf(); + return ctx.result; + }, + }; + + expect(hooks).toBeDefined(); + }); + + it('HooksOrFactory propagates generics to factory context', () => { + class Svc { name = 'factory'; } + + const factory: HooksOrFactory = (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + return { + onReturn: (returnCtx) => { + expectTypeOf(returnCtx.target).toEqualTypeOf(); + expectTypeOf(returnCtx.args).toEqualTypeOf<[number]>(); + return returnCtx.result; + }, + }; + }; + + expect(factory).toBeDefined(); + }); + + it('hook type aliases default to unparameterized HookContext when no generics specified', () => { + const hook: OnInvokeHookType = (ctx) => { + expectTypeOf(ctx.target).toEqualTypeOf(); + expectTypeOf(ctx.args).toEqualTypeOf(); + }; + + expect(hook).toBeDefined(); + }); + }); + + describe('Wrap return type and TypedMethodDecorator types', () => { + it('Wrap returns ClassDecorator & TypedMethodDecorator', () => { + const decorator = Wrap((method, _ctx) => { + return (...args: unknown[]) => method(...args); + }); + + expect(decorator).toBeDefined(); + expectTypeOf(decorator).toMatchTypeOf(); + expectTypeOf(decorator).toMatchTypeOf>(); + }); + + it('TypedMethodDecorator type is exported', () => { + type AssertIsType = TypedMethodDecorator; + + const fn: AssertIsType = (_target, _key, descriptor) => { + return descriptor; + }; + + expect(fn).toBeDefined(); + }); + }); + + describe('Wrap decorator preserves method types at decoration site', () => { + it('preserves method signature through typed Wrap decoration', () => { + const TestDecorator = Wrap((method, _ctx) => { + return (...args) => method(...args); + }); + + class TestClass { + @TestDecorator + greet(name: string, count: number): string { + return `Hello ${name} x${count}`; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.greet).toEqualTypeOf<(name: string, count: number) => string>(); + }); + + it('preserves async method signature through typed Wrap decoration', () => { + const TestDecorator = Wrap>((method, _ctx) => { + return async (...args) => method(...args); + }); + + class TestClass { + @TestDecorator + async fetchData(id: number): Promise { + return `data-${id}`; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.fetchData).toEqualTypeOf<(id: number) => Promise>(); + }); + + it('preserves method signature when Wrap is used as factory return', () => { + const Log = () => Wrap((method, _ctx) => { + return (...args) => method(...args); + }); + + class TestClass { + @Log() + add(a: number, b: number): number { + return a + b; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.add).toEqualTypeOf<(a: number, b: number) => number>(); + }); + + it('preserves void return type', () => { + const TestDecorator = Wrap((method, _ctx) => { + return (...args) => method(...args); + }); + + class TestClass { + @TestDecorator + doWork(task: string): void { + console.log(task); + } + } + + const instance = new TestClass(); + expectTypeOf(instance.doWork).toEqualTypeOf<(task: string) => void>(); + }); + + it('preserves no-arg method signature', () => { + const TestDecorator = Wrap((method, _ctx) => { + return (...args) => method(...args); + }); + + class TestClass { + @TestDecorator + getVersion(): string { + return '1.0.0'; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.getVersion).toEqualTypeOf<() => string>(); + }); + }); + + describe('Effect decorator preserves method types at decoration site', () => { + it('preserves method signature through typed Effect decoration', () => { + const TestDecorator = Effect({ + onInvoke: () => {}, + }); + + class TestClass { + @TestDecorator + greet(name: string): string { + return `Hello ${name}`; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.greet).toEqualTypeOf<(name: string) => string>(); + }); + + it('preserves method signature when Effect is used as factory return', () => { + const Log = (message: string) => Effect({ + onInvoke: () => console.log(message), + }); + + class TestClass { + @Log('test') + compute(a: number, b: number): number { + return a + b; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.compute).toEqualTypeOf<(a: number, b: number) => number>(); + }); + }); + + describe('convenience hook decorators preserve method types', () => { + it('OnInvokeHook preserves method signature', () => { + const decorator = OnInvokeHook(() => {}); + + class TestClass { + @decorator + greet(name: string): string { + return `Hello ${name}`; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.greet).toEqualTypeOf<(name: string) => string>(); + }); + + it('OnReturnHook preserves method signature', () => { + const decorator = OnReturnHook(({ result }) => result); + + class TestClass { + @decorator + compute(x: number): number { + return x * 2; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.compute).toEqualTypeOf<(x: number) => number>(); + }); + + it('OnErrorHook preserves method signature', () => { + const decorator = OnErrorHook(({ error }) => { throw error; }); + + class TestClass { + @decorator + riskyOp(input: string): boolean { + return input.length > 0; + } + } + + const instance = new TestClass(); + expectTypeOf(instance.riskyOp).toEqualTypeOf<(input: string) => boolean>(); + }); + + it('FinallyHook preserves method signature', () => { + const decorator = FinallyHook(() => {}); + + class TestClass { + @decorator + process(data: number[]): string { + return data.join(','); + } + } + + const instance = new TestClass(); + expectTypeOf(instance.process).toEqualTypeOf<(data: number[]) => string>(); + }); + }); + + describe('class-level decorators work as ClassDecorator', () => { + it('Wrap works as class decorator', () => { + const TestDecorator = Wrap((method, _ctx) => { + return (...args: unknown[]) => method(...args); + }); + + @TestDecorator + class TestClass { + greet(name: string): string { + return `Hello ${name}`; + } + } + + const instance = new TestClass(); + expect(instance.greet('world')).toBe('Hello world'); + }); + + it('Effect works as class decorator', () => { + const TestDecorator = Effect({ + onInvoke: () => {}, + }); + + @TestDecorator + class TestClass { + add(a: number, b: number): number { + return a + b; + } + } + + const instance = new TestClass(); + expect(instance.add(2, 3)).toBe(5); + }); + }); }); diff --git a/tests/wrapFunction.spec.ts b/tests/wrapFunction.spec.ts index 1a32fe1..36cf4c9 100644 --- a/tests/wrapFunction.spec.ts +++ b/tests/wrapFunction.spec.ts @@ -3,6 +3,9 @@ import { describe, it, expect, vi } from 'vitest'; import { wrapMethod } from '../src/wrap-on-method'; import type { WrapFn, WrapContext } from '../src/hook.types'; +/** Permissive WrapFn alias for runtime-focused tests where type inference is not under test. */ +type AnyWrapFn = WrapFn; + /** * Helper that simulates how {@link WrapOnMethod} extracts the original * method from a descriptor. The cast mirrors `descriptor.value as (...args: unknown[]) => unknown` @@ -14,7 +17,7 @@ const asMethod = (fn: Function): ((...args: unknown[]) => unknown) => describe('wrapFunction', () => { describe('basic wrapping', () => { it('should call wrapFn once on first invocation, not at wrap time', () => { - const wrapFnSpy = vi.fn((method, _context) => { + const wrapFnSpy = vi.fn((method, _context) => { return (...args) => method(...args); }); @@ -46,7 +49,7 @@ describe('wrapFunction', () => { let wrapCount = 0; let callCount = 0; - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { wrapCount++; return (...args) => { callCount++; @@ -88,7 +91,7 @@ describe('wrapFunction', () => { it('should reuse the factory result for different instances', () => { let wrapCount = 0; - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { wrapCount++; return (...args) => method(...args); }; @@ -123,7 +126,7 @@ describe('wrapFunction', () => { }); it('should return the result from the inner function', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => { const result = method(...args) as number; return result * 2; @@ -149,7 +152,7 @@ describe('wrapFunction', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -174,7 +177,7 @@ describe('wrapFunction', () => { it('should pass a method proxy that delegates to current instance', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { capturedMethod = method; return (...args) => method(...args); }; @@ -205,7 +208,7 @@ describe('wrapFunction', () => { it('should provide decoration-time and runtime context fields', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedContext = context; return (...args) => method(...args); }; @@ -240,7 +243,7 @@ describe('wrapFunction', () => { it('should provide target and className in WrapContext', () => { let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedCtx = context; return (...args) => method(...args); }; @@ -268,7 +271,7 @@ describe('wrapFunction', () => { it('should include propertyKey, parameterNames, descriptor in WrapContext', () => { let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedCtx = context; return (...args) => method(...args); }; @@ -297,7 +300,7 @@ describe('wrapFunction', () => { it('should return empty string for className when constructor has no name', () => { let capturedCtx: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: AnyWrapFn = (method, context) => { capturedCtx = context; return (...args) => method(...args); }; @@ -358,7 +361,7 @@ describe('wrapFunction', () => { describe('async methods', () => { it('should work with async methods', async () => { - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -381,7 +384,7 @@ describe('wrapFunction', () => { }); it('should allow async wrapper to modify async results', async () => { - const wrapFn: WrapFn> = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return async (...args) => { const result = (await method(...args)) as string; return `modified: ${result}`; @@ -409,7 +412,7 @@ describe('wrapFunction', () => { it('should propagate async errors from the original method', async () => { const asyncError = new Error('async failure'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -434,7 +437,7 @@ describe('wrapFunction', () => { it('should propagate sync errors from the original method', () => { const syncError = new Error('sync failure'); - const wrapFn: WrapFn = (method, _context) => { + const wrapFn: AnyWrapFn = (method, _context) => { return (...args) => method(...args); }; @@ -456,9 +459,9 @@ describe('wrapFunction', () => { }); describe('export from barrel', () => { - it('should be importable from the main index', async () => { - const indexModule = await import('../src/index'); - expect(typeof indexModule.wrapFunction).toBe('function'); + it('wrapMethod is internal and not exported from the barrel', async () => { + const indexModule = await import('../src/index') as Record; + expect(indexModule['wrapMethod']).toBeUndefined(); }); it('should export buildArgsObject from the main index', async () => {