diff --git a/src/behaviors.ts b/src/behaviors.ts index 7b4c5b9..4a68396 100644 --- a/src/behaviors.ts +++ b/src/behaviors.ts @@ -1,8 +1,8 @@ import { equals } from '@vitest/expect' import type { - AnyCallable, AnyFunction, + Mock, ParametersOf, ReturnTypeOf, WithMatchers, @@ -12,27 +12,25 @@ export interface WhenOptions { times?: number } -export interface BehaviorStack { - use: ( - args: ParametersOf, - ) => BehaviorEntry> | undefined +export interface BehaviorStack { + use: (args: TParameters) => BehaviorEntry | undefined - getAll: () => readonly BehaviorEntry>[] + getAll: () => readonly BehaviorEntry[] - getUnmatchedCalls: () => readonly ParametersOf[] + getUnmatchedCalls: () => readonly TParameters[] bindArgs: ( - args: WithMatchers>, + args: WithMatchers, options: WhenOptions, - ) => BoundBehaviorStack> + ) => BoundBehaviorStack } -export interface BoundBehaviorStack { +export interface BoundBehaviorStack { addReturn: (values: TReturn[]) => void addResolve: (values: Awaited[]) => void addThrow: (values: unknown[]) => void addReject: (values: unknown[]) => void - addDo: (values: AnyFunction[]) => void + addDo: (values: ((...args: TParameters) => TReturn)[]) => void } export interface BehaviorEntry { @@ -62,11 +60,16 @@ export interface BehaviorOptions { maxCallCount: number | undefined } +export type BehaviorStackOf = BehaviorStack< + ParametersOf, + ReturnTypeOf +> + export const createBehaviorStack = < - TFunc extends AnyCallable, ->(): BehaviorStack => { - const behaviors: BehaviorEntry>[] = [] - const unmatchedCalls: ParametersOf[] = [] + TMock extends Mock, +>(): BehaviorStackOf => { + const behaviors: BehaviorEntry>[] = [] + const unmatchedCalls: ParametersOf[] = [] return { getAll: () => behaviors, diff --git a/src/debug.ts b/src/debug.ts index 364411d..ff1e5ed 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -5,7 +5,7 @@ import { import { type Behavior, BehaviorType } from './behaviors.ts' import { getBehaviorStack } from './stubs.ts' -import type { AnyCallable, Mock } from './types.ts' +import type { Mock } from './types.ts' export interface DebugResult { name: string @@ -20,9 +20,7 @@ export interface Stubbing { calls: readonly unknown[][] } -export const getDebug = ( - mock: Mock, -): DebugResult => { +export const getDebug = (mock: Mock): DebugResult => { const name = mock.getMockName() const behaviors = getBehaviorStack(mock) const unmatchedCalls = behaviors?.getUnmatchedCalls() ?? mock.mock.calls diff --git a/src/fallback-implementation.ts b/src/fallback-implementation.ts index 6073263..c2c1bbd 100644 --- a/src/fallback-implementation.ts +++ b/src/fallback-implementation.ts @@ -1,18 +1,18 @@ -import type { AnyCallable, AsFunction, Mock } from './types.ts' +import type { AsFunction, Mock } from './types.ts' /** Get the fallback implementation of a mock if no matching stub is found. */ -export const getFallbackImplementation = ( - mock: Mock, -): AsFunction | undefined => { +export const getFallbackImplementation = ( + mock: TMock, +): AsFunction | undefined => { return ( - (mock.getMockImplementation() as AsFunction | undefined) ?? + (mock.getMockImplementation() as AsFunction | undefined) ?? getTinyspyInternals(mock)?.getOriginal() ) } /** Internal state from Tinyspy, where a mock's default implementation is stored. */ -interface TinyspyInternals { - getOriginal: () => AsFunction | undefined +interface TinyspyInternals { + getOriginal: () => AsFunction | undefined } /** @@ -24,9 +24,9 @@ interface TinyspyInternals { * The implementation remains present in tinyspy internal state, * which is stored on a Symbol key in the mock object. */ -const getTinyspyInternals = ( - mock: Mock, -): TinyspyInternals | undefined => { +const getTinyspyInternals = ( + mock: TMock, +): TinyspyInternals | undefined => { const maybeTinyspy = mock as unknown as Record for (const key of Object.getOwnPropertySymbols(maybeTinyspy)) { @@ -38,7 +38,7 @@ const getTinyspyInternals = ( 'getOriginal' in maybeTinyspyInternals && typeof maybeTinyspyInternals.getOriginal === 'function' ) { - return maybeTinyspyInternals as TinyspyInternals + return maybeTinyspyInternals as TinyspyInternals } } diff --git a/src/stubs.ts b/src/stubs.ts index 9352545..17c1a59 100644 --- a/src/stubs.ts +++ b/src/stubs.ts @@ -1,32 +1,39 @@ import { type BehaviorStack, + type BehaviorStackOf, BehaviorType, createBehaviorStack, } from './behaviors.ts' import { NotAMockFunctionError } from './errors.ts' import { getFallbackImplementation } from './fallback-implementation.ts' -import type { AnyCallable, Mock, ParametersOf } from './types.ts' +import type { + AsFunction, + Mock, + MockSource, + ParametersOf, + ReturnTypeOf, +} from './types.ts' const BEHAVIORS_KEY = Symbol.for('vitest-when:behaviors') -interface WhenStubImplementation { - (...args: ParametersOf): unknown - [BEHAVIORS_KEY]: BehaviorStack +interface WhenStubImplementation { + (...args: ParametersOf): unknown + [BEHAVIORS_KEY]: BehaviorStack, ReturnTypeOf> } -export const configureMock = ( - mock: Mock, -): BehaviorStack => { +export const configureMock = ( + mock: TMock, +): BehaviorStackOf => { const existingBehaviorStack = getBehaviorStack(mock) if (existingBehaviorStack) { return existingBehaviorStack } - const behaviorStack = createBehaviorStack() + const behaviorStack = createBehaviorStack() const fallbackImplementation = getFallbackImplementation(mock) - const implementation = (...args: ParametersOf) => { + const implementation = (...args: ParametersOf) => { const behavior = behaviorStack.use(args)?.behavior ?? { type: BehaviorType.DO, callback: fallbackImplementation, @@ -51,6 +58,7 @@ export const configureMock = ( } case BehaviorType.DO: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return behavior.callback?.(...args) } } @@ -63,26 +71,26 @@ export const configureMock = ( return behaviorStack } -export const validateMock = ( - maybeMock: TFunc | Mock, -): Mock => { +export const validateMock = ( + maybeMock: TSource, +): Mock => { if ( typeof maybeMock === 'function' && 'mockImplementation' in maybeMock && typeof maybeMock.mockImplementation === 'function' ) { - return maybeMock + return maybeMock as Mock } throw new NotAMockFunctionError(maybeMock) } -export const getBehaviorStack = ( - mock: Mock, -): BehaviorStack | undefined => { +export const getBehaviorStack = ( + mock: TMock, +): BehaviorStackOf | undefined => { const existingImplementation = mock.getMockImplementation() as - | WhenStubImplementation - | TFunc + | WhenStubImplementation + | AsFunction | undefined return existingImplementation && BEHAVIORS_KEY in existingImplementation diff --git a/src/types.ts b/src/types.ts index 74bbfb1..13aaaac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,45 +1,71 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /** Common type definitions. */ import type { AsymmetricMatcher } from '@vitest/expect' import type { MockedClass, MockedFunction } from 'vitest' /** Any function. */ -export type AnyFunction = (...args: never[]) => unknown +export type AnyFunction = (...args: any[]) => any /** Any constructor. */ -export type AnyConstructor = new (...args: never[]) => unknown +export type AnyConstructor = new (...args: any[]) => any -/** Any callable, for use in `extends` */ -export type AnyCallable = AnyFunction | AnyConstructor +/** + * Minimally typed version of Vitest's `MockInstance`. + * + * Ensures backwards compatibility with vitest@<=1 + */ +export interface MockInstance { + getMockName(): string + getMockImplementation(): TFunc | undefined + mockImplementation: (impl: TFunc) => this + mock: MockContext +} + +export interface MockContext { + calls: Parameters[] +} + +/** A function, constructor, or `vi.spyOn` return that's been mocked. */ +export type MockSource = AnyFunction | AnyConstructor | MockInstance /** Extract parameters from either a function or constructor. */ -export type ParametersOf = TFunc extends new ( +export type ParametersOf = TMock extends new ( ...args: infer P ) => unknown ? P - : TFunc extends (...args: infer P) => unknown + : TMock extends (...args: infer P) => unknown ? P - : never + : TMock extends MockInstance<(...args: infer P) => unknown> + ? P + : never /** Extract return type from either a function or constructor */ -export type ReturnTypeOf = TFunc extends new ( +export type ReturnTypeOf = TMock extends new ( ...args: never[] ) => infer R ? R - : TFunc extends (...args: never[]) => infer R + : TMock extends (...args: any[]) => infer R ? R - : never + : TMock extends MockInstance<(...args: never[]) => infer R> + ? R + : never -export type AsFunction = ( - ...args: ParametersOf -) => ReturnTypeOf +/** Convert a function or constructor type into a function type. */ +export type AsFunction = ( + ...args: ParametersOf +) => ReturnTypeOf /** Accept a value or an AsymmetricMatcher in an arguments array */ export type WithMatchers = { [K in keyof T]: AsymmetricMatcher | T[K] } -export type Mock = TFunc extends AnyFunction - ? MockedFunction - : TFunc extends AnyConstructor - ? MockedClass - : never +/** A mocked function or constructor. */ +export type Mock = + TMock extends MockInstance + ? MockedFunction + : TMock extends AnyFunction + ? MockedFunction + : TMock extends AnyConstructor + ? MockedClass + : never diff --git a/src/vitest-when.ts b/src/vitest-when.ts index 499f97f..080d80b 100644 --- a/src/vitest-when.ts +++ b/src/vitest-when.ts @@ -2,9 +2,9 @@ import type { WhenOptions } from './behaviors.ts' import { type DebugResult, getDebug } from './debug.ts' import { configureMock, validateMock } from './stubs.ts' import type { - AnyCallable, AsFunction, Mock, + MockSource, ParametersOf, ReturnTypeOf, WithMatchers, @@ -14,24 +14,24 @@ export { type Behavior, BehaviorType, type WhenOptions } from './behaviors.ts' export type { DebugResult, Stubbing } from './debug.ts' export * from './errors.ts' -export interface StubWrapper { - calledWith>( +export interface StubWrapper { + calledWith>( ...args: WithMatchers - ): Stub + ): Stub } -export interface Stub { - thenReturn: (...values: ReturnTypeOf[]) => Mock - thenResolve: (...values: Awaited>[]) => Mock - thenThrow: (...errors: unknown[]) => Mock - thenReject: (...errors: unknown[]) => Mock - thenDo: (...callbacks: AsFunction[]) => Mock +export interface Stub { + thenReturn: (...values: ReturnTypeOf[]) => TMock + thenResolve: (...values: Awaited>[]) => TMock + thenThrow: (...errors: unknown[]) => TMock + thenReject: (...errors: unknown[]) => TMock + thenDo: (...callbacks: AsFunction[]) => TMock } -export const when = ( - mock: TFunc | Mock, +export const when = ( + mock: TMockSource, options: WhenOptions = {}, -): StubWrapper => { +): StubWrapper> => { const validatedMock = validateMock(mock) const behaviorStack = configureMock(validatedMock) @@ -69,8 +69,8 @@ export interface DebugOptions { log?: boolean } -export const debug = ( - mock: TFunc | Mock, +export const debug = ( + mock: MockSource, options: DebugOptions = {}, ): DebugResult => { const log = options.log ?? true diff --git a/test/typing.test-d.ts b/test/typing.test-d.ts index 527c8e0..24cc3cf 100644 --- a/test/typing.test-d.ts +++ b/test/typing.test-d.ts @@ -69,10 +69,9 @@ describe('vitest-when type signatures', () => { }) it('should handle an spied function', () => { - const target = { simple } - vi.spyOn(target, 'simple') + const target = vi.spyOn({ simple }, 'simple') - const result = subject.when(target.simple).calledWith(1).thenReturn('hello') + const result = subject.when(target).calledWith(1).thenReturn('hello') expectTypeOf(result.mock.calls).toEqualTypeOf<[number][]>() expectTypeOf(result).parameters.toEqualTypeOf<[number]>()