|
| 1 | +import { expect } from 'vitest' |
| 2 | + |
| 3 | +import type { BehaviorEntry } from './behaviors.ts' |
| 4 | +import type { AnyFunction } from './types.ts' |
| 5 | + |
| 6 | +const CALLBACK_KEY = Symbol.for('vitest-when:callback-args') |
| 7 | + |
| 8 | +export interface WithCallbackMarker { |
| 9 | + [CALLBACK_KEY]: unknown[] |
| 10 | +} |
| 11 | + |
| 12 | +declare module 'vitest' { |
| 13 | + interface ExpectStatic { |
| 14 | + callback: (...args: unknown[]) => WithCallbackMarker |
| 15 | + } |
| 16 | +} |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a matcher that matches a callback function. Passing no arguments |
| 20 | + * creates an empty callback matcher, in which case the arguments will be picked |
| 21 | + * up from the call to `.thenCallback()`. |
| 22 | + */ |
| 23 | +export function createCallbackMatcher(...args: unknown[]) { |
| 24 | + const matcher = expect.any(Function) as WithCallbackMarker |
| 25 | + matcher[CALLBACK_KEY] = args |
| 26 | + return matcher |
| 27 | +} |
| 28 | + |
| 29 | +expect.callback = createCallbackMatcher |
| 30 | + |
| 31 | +export function isCallback(value: unknown): value is WithCallbackMarker { |
| 32 | + return ( |
| 33 | + typeof value === 'object' && |
| 34 | + value !== null && |
| 35 | + CALLBACK_KEY in value && |
| 36 | + value[CALLBACK_KEY] !== undefined |
| 37 | + ) |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * Append an empty callback matcher if one isn't already present. |
| 42 | + */ |
| 43 | +export function concatImpliedCallback<TArgs extends unknown[]>( |
| 44 | + args: TArgs, |
| 45 | +): TArgs { |
| 46 | + const callbackArguments = args.filter(isCallback) |
| 47 | + |
| 48 | + // The user didn't provide a callback matcher, so we'll add one |
| 49 | + if (callbackArguments.length === 0) |
| 50 | + return [...args, expect.callback()] as TArgs |
| 51 | + |
| 52 | + // The user provided one or more callback matchers, and at least one of them |
| 53 | + // is the special empty one. |
| 54 | + for (const callbackArgument of callbackArguments) { |
| 55 | + const stubbedArgs = callbackArgument[CALLBACK_KEY] |
| 56 | + if (stubbedArgs.length === 0) return args |
| 57 | + } |
| 58 | + |
| 59 | + // The user provided one or more callback matchers, but none of them are the |
| 60 | + // special empty one, so we'll add one. |
| 61 | + return [...args, expect.callback()] as TArgs |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Invokes any callbacks that were stubbed for the given behavior. |
| 66 | + */ |
| 67 | +export const invokeCallbackFor = ( |
| 68 | + behavior: BehaviorEntry<unknown[]>, |
| 69 | + actualArguments: unknown[], |
| 70 | +) => { |
| 71 | + for (const [index, expectedArgument] of behavior.args.entries()) { |
| 72 | + if (!isCallback(expectedArgument)) continue |
| 73 | + |
| 74 | + // Callback here is guaranteed to be a function due to the matcher |
| 75 | + // validation in `createCallbackMatcher()`, so we can cast it as such. |
| 76 | + const callback = actualArguments[index] as AnyFunction |
| 77 | + |
| 78 | + // If this is the empty callback matcher, then we'll use the values passed |
| 79 | + // to `.thenCallback()` as the arguments. |
| 80 | + const callbackArguments = |
| 81 | + expectedArgument[CALLBACK_KEY].length === 0 |
| 82 | + ? behavior.values |
| 83 | + : expectedArgument[CALLBACK_KEY] |
| 84 | + |
| 85 | + // Defer the callback to the next tick to ensure correct async behavior as |
| 86 | + // well as protect the core from any userland errors. |
| 87 | + setImmediate(callback, ...callbackArguments) |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Returns a Promise that resolves on the next tick. When testing callbacks, all |
| 93 | + * invoked callbacks are deferred until the next tick, so you must `await |
| 94 | + * nextTick()` before asserting on the callback arguments. |
| 95 | + */ |
| 96 | +export const nextTick = () => |
| 97 | + new Promise<void>((resolve) => setImmediate(resolve)) |
0 commit comments