UNPKG

@tevm/test-matchers

Version:

Vite test matchers for Tevm or EVM-related testing in TypeScript.

349 lines (302 loc) 11.3 kB
import { type Assertion, assert, chai, expect } from 'vitest' import type { ChaiContext, ChainableAssertion, ChainState, ChaiStatic, ChaiUtils, ExtractVitestArgs, InferredVitestChainableResult, IsAsync, MatcherResult, VitestMatcherConfig, VitestMatcherFunction, } from './types.js' let chaiUtils: ChaiUtils | undefined export const getChaiUtils = () => chaiUtils // Promise setup helper (waffle-chai pattern) const setupPromise = (assertion: Assertion, utils: ChaiUtils): void => { const existingPromise = utils.flag(assertion, 'callPromise') if (existingPromise) return const obj = utils.flag(assertion, 'object') if (obj && typeof obj.then === 'function') { utils.flag(assertion, 'callPromise', obj) } else { utils.flag(assertion, 'callPromise', Promise.resolve()) } } const isAsyncMatcher = <TReceived, TState>( matcher: VitestMatcherFunction<TReceived, boolean, TState>, ): matcher is VitestMatcherFunction<TReceived, true, TState> => { return matcher.constructor.name === 'AsyncFunction' } // Build chain state from flags - automatically detect any previous matcher const buildChainState = (assertion: Assertion, utils: ChaiUtils): ChainState => { // Get the chain history from a dedicated flag const chainHistory: string[] = utils.flag(assertion, 'chainHistory') || [] if (chainHistory.length === 0) { return { chainedFrom: undefined, previousPassed: undefined, previousValue: undefined, previousState: undefined, previousArgs: undefined, } } // Use the most recent chained matcher const matcherName = chainHistory[chainHistory.length - 1] // For async matchers, use the current object (which gets updated) // rather than the stored value (which might be a Promise) let previousValue = utils.flag(assertion, `${matcherName}.value`) const currentObj = utils.flag(assertion, 'object') // If the stored value is a Promise but current object is resolved, use current object if (previousValue && typeof previousValue.then === 'function' && currentObj !== previousValue) previousValue = currentObj const state = { chainedFrom: matcherName, previousPassed: utils.flag(assertion, `${matcherName}.passed`), previousValue, previousState: utils.flag(assertion, `${matcherName}.state`), previousArgs: utils.flag(assertion, `${matcherName}.args`), } return state } // Used to fail an assertion in chai with vitest highlighting and trace const createInternalResultHandler = () => { return { __expectResult: function (this: any, result: MatcherResult, isNegated: boolean) { assert(chaiUtils !== undefined, 'ChaiUtils not initialized') const shouldFail = isNegated ? result.pass : !result.pass if (shouldFail) { return { pass: false, message: () => result.message(), actual: result.actual, expected: result.expected, } } return { pass: true, message: () => result.message() } }, } } const expectResult = (result: MatcherResult, isNegated: boolean) => { ;( expect(result) as unknown as { __expectResult: (isNegated: boolean) => MatcherResult } ).__expectResult(isNegated) } // Only extract truly common parts const storeChainState = <TName extends string, TAsync extends boolean = false>( assertion: ChaiContext<TAsync>, utils: ChaiUtils, name: TName, obj: unknown, args: readonly unknown[], result: MatcherResult, ) => { utils.flag(assertion, `${name}.passed`, result.pass) utils.flag(assertion, `${name}.value`, obj) utils.flag(assertion, `${name}.state`, result.state) utils.flag(assertion, `${name}.args`, args) // Track chain history const chainHistory: string[] = utils.flag(assertion, 'chainHistory') ?? [] chainHistory.push(name) utils.flag(assertion, 'chainHistory', chainHistory) } export const parseChainArgs = <TData = unknown, TState = unknown>(args: readonly unknown[]) => { const argsWithoutChainState = args.slice(0, -1) const chainState = args[args.length - 1] assert( chainState && typeof chainState === 'object' && 'chainedFrom' in chainState, 'Internal error: no chain state found', ) return { args: argsWithoutChainState, chainState: chainState as ChainState<TData, TState> } } // Vitest matcher wrapper for sync matchers function makeVitestSyncChainable< TName extends string, TArgs extends readonly unknown[], TReceived, TAsync extends boolean, TState, >( this: ChaiContext<false>, name: TName, vitestMatcher: VitestMatcherFunction<TReceived, TAsync, TState>, args: TArgs, ): Assertion { assert(chaiUtils !== undefined, 'ChaiUtils not initialized') // Check if we're in an async chain const callPromise = chaiUtils.flag(this, 'callPromise') if (callPromise && typeof callPromise.then === 'function') { // We're chaining after an async matcher - join the promise chain return makeVitestAsyncChainable.call(this, name, vitestMatcher as VitestMatcherFunction, args) } const obj = chaiUtils.flag(this, 'object') const chainState = buildChainState(this, chaiUtils) // Capture and reset negation flag (sync pattern) const isNegated = chaiUtils.flag(this, 'negate') === true chaiUtils.flag(this, 'negate', false) const result = vitestMatcher(obj as TReceived, ...args, chainState) as MatcherResult<TState> expectResult(result, isNegated) storeChainState(this, chaiUtils, name, obj, args, result) return this } // Helper function to execute a promise-based matcher const executeMatcherLogic = async <TName extends string, TArgs extends readonly unknown[], TReceived, TState>( context: ChaiContext<true>, name: TName, vitestMatcher: VitestMatcherFunction<TReceived, true, TState>, args: TArgs, actualObj: TReceived, isNegated: boolean, chaiUtils: ChaiUtils, ): Promise<TReceived> => { // Update object with the actual value for further chaining chaiUtils.flag(context, 'object', actualObj) // Build chain state AFTER we have the actual object const chainState = buildChainState(context, chaiUtils) // Store and restore negation flag properly const currentNegated = chaiUtils.flag(context, 'negate') === true chaiUtils.flag(context, 'negate', isNegated) const result = await (vitestMatcher(actualObj, ...args, chainState) as Promise<MatcherResult<TState>>) expectResult(result, isNegated) // Restore negation flag chaiUtils.flag(context, 'negate', currentNegated) // Store the results for future chained matchers storeChainState(context, chaiUtils, name, actualObj, args, result) return actualObj } // Updated async chainable function without duplication function makeVitestAsyncChainable< TName extends string, TArgs extends readonly unknown[], TReceived, TAsync extends boolean, TState, >( this: ChaiContext<true>, name: TName, vitestMatcher: VitestMatcherFunction<TReceived, TAsync, TState>, args: TArgs, ): ChainableAssertion { assert(chaiUtils !== undefined, 'ChaiUtils not initialized') setupPromise(this, chaiUtils) const isNegated = chaiUtils.flag(this, 'negate') === true // Get the current object (might be a Promise) const obj = chaiUtils.flag(this, 'object') const callPromiseValue = chaiUtils.flag(this, 'callPromise') // Handle both resolved and rejected promises const derivedPromise = callPromiseValue.then( // Success handler - for normal resolved promises async (resolvedValue: any) => { assert(chaiUtils !== undefined, 'ChaiUtils not initialized') const actualObj = resolvedValue !== undefined ? resolvedValue : await obj return await executeMatcherLogic( this, name, vitestMatcher as VitestMatcherFunction<TReceived, true, TState>, args, actualObj as TReceived, isNegated, chaiUtils, ) }, // Error handler - for rejected promises (like contract reverts) async (error: any) => { assert(chaiUtils !== undefined, 'ChaiUtils not initialized') // Wrap the error in a rejected promise so the matcher can catch and process it const errorPromise = Promise.reject(error) try { // Execute the matcher with the rejected promise // The matcher will catch the error, process it, and return the same rejected promise await executeMatcherLogic( this, name, vitestMatcher as VitestMatcherFunction<TReceived, true, TState>, args, errorPromise as TReceived, isNegated, chaiUtils, ) // If matcher succeeds, convert the error from "rejected" to "resolved" for chaining // Next matcher will receive this through success handler but can access previous state return error } catch (executeError) { // The returned rejected promise throws when awaited - this is expected // Convert from rejected to resolved so the chain can continue if (executeError === error) return error // If it's a different error, the matcher actually failed - re-throw it throw executeError } }, ) // Make thenable (waffle-chai pattern) // biome-ignore lint/suspicious/noThenProperty: binding the promise to replicate chai waffle pattern this.then = derivedPromise.then.bind(derivedPromise) this.catch = derivedPromise.catch.bind(derivedPromise) chaiUtils.flag(this, 'callPromise', derivedPromise) return this as unknown as ChainableAssertion } // Convert existing vitest matcher to chainable with perfect type inference export const createChainableFromVitest = < TName extends string, TReceived = any, TState = unknown, TMatcher extends VitestMatcherFunction<TReceived, boolean, TState> = VitestMatcherFunction< TReceived, boolean, TState >, >(config: { name: TName vitestMatcher: TMatcher }) => { const { name, vitestMatcher } = config const isAsync = isAsyncMatcher(vitestMatcher) return { name, isAsync, methodFunction: function (this: ChaiContext, ...args: ExtractVitestArgs<typeof vitestMatcher>) { if (isAsync) { return (makeVitestAsyncChainable<TName, ExtractVitestArgs<typeof vitestMatcher>, TReceived, true, TState>).call( this, name, vitestMatcher as VitestMatcherFunction<TReceived, true, TState>, args, ) } return (makeVitestSyncChainable<TName, ExtractVitestArgs<typeof vitestMatcher>, TReceived, false, TState>).call( this as ChaiContext<false>, name, vitestMatcher as VitestMatcherFunction<TReceived, false, TState>, args as ExtractVitestArgs<typeof vitestMatcher>, ) }, chainFunction: function (this: ChaiContext): Assertion { assert(chaiUtils !== undefined, 'ChaiUtils not initialized') chaiUtils.flag(this, `${name}.chained`, true) return this }, } as InferredVitestChainableResult<VitestMatcherConfig<TName, TReceived, IsAsync<TMatcher>, TState>> } // Plugin creator with context-aware registration export const createChainablePlugin = ( matchers: Record<string, InferredVitestChainableResult<VitestMatcherConfig<string, any, boolean, any>>>, ) => { return (_chai: ChaiStatic, utils: ChaiUtils) => { // Store utils reference chaiUtils = utils Object.entries(matchers).forEach(([_, matcher]) => { utils.addChainableMethod(_chai.Assertion.prototype, matcher.name, matcher.methodFunction, matcher.chainFunction) }) } } // Convenience function to register chainable matchers export const registerChainableMatchers = ( matchers: Record<string, InferredVitestChainableResult<VitestMatcherConfig<string, any, boolean, any>>>, ): void => { expect.extend(createInternalResultHandler()) chai.use(createChainablePlugin(matchers)) }