-
Notifications
You must be signed in to change notification settings - Fork 1
Open
Description
I have an idea for an extensible approach of implementing matchers, which I think would help the current code while providing a mechanism for implementing custom matchers.
// mod.ts
// If colorization is turned off, then the colorize function would just be the identity function `(value) => value`
type MessageFormatter = (colorize: (string: value, color: AnsiColor) => string) => FormattedMessage
export interface Fail {
message: MessageFormatter
}
export interface Expectation {
readonly failed;
fail(): void
}
export type MatcherFn<
Actual = unknown,
Expected extends unknown[] = unknown[],
R extends Promise<void> | void = Promise<void> | void,
> = (
expectation: Expectation,
actual: Actual,
...expected: Expected
) => R;
export type ToExpectFn<Matcher> = Matcher extends
MatcherFn<infer _Actual, infer Expected, infer Return>
? (...expected: Expected) => Return
: never;
/**
* This interface is a registry of all available matchers. Implementations should
* extend this interface via declaration merging.
*/
export interface Matchers<Actual> {}
/**
* This type extracts all of the matchers that can be used on values of type `Actual`.
* For instance, it allows us to hide `toBeVisible` unless the `Actual` type is a `Locator`.
*/
export type ApplicableMatchers<Actual> = {
[
P in keyof Matchers<Actual> as Matchers<Actual>[P] extends MatcherFn<Actual, any, any> ? P
: never
]: ToExpectFn<Matchers<Actual>>;
};
export interface ExpectFunction {
expect: <T>(actual: T, message?: string) => ApplicableMatchers<T>;
}
export interface GlobalExpectFunction {
/**
* This function is used to register a new matcher. Since `Name` is a keyof the
* `Matchers<T>` interface, it will be checked at compile time that the matcher
* has actually been added to the `Matchers<T>` interface.
*/
register<Name extends keyof Matchers<any>>(
name: Name,
matcher: Matchers<any>[Name],
): void;
}The implementation of a matcher (internally) would be something like:
import { expect, type Expectation } from "./mod.ts"
declare module "./mod.ts" {
export interface Matchers<T> {
toEqual(expectation: Expectation, actual: T, expected: AsymmetricMatch<T>)
}
}
expect.register("toEqual", (expectation, actual, expected) => {
if (!equal(actual, expected)) {
expectation.fail({
message: createObjectDiffFormatter(actual, expected)
})
}
})Capturing the execution context, rendering the message, etc. would be handled identically across matchers inside expect, so there would be less duplication. A retrying matcher would just be any matcher that used the withRetry utility:
// mod.ts
function withRetry<Actual, Expected>(fn: MatcherFn<Actual, Expected, any>): Matcher<Actual, Expected, Promise<void>> {
return async (expectation, actual, expected) => {
// Retry `fn` with a new `Expectation` each time and if we're out of retries, fail the `expectation` with the last error from `fn`
}
}
// toHaveText.ts
import { expect, withRetry } from "./mod.ts"
declare module "./mod.ts" {
export interface Matchers<T> {
// Just an example of how a conditional type can be used to hide a function based on the `Actual` type.
toHaveText: T extends Locator ? (expectation: Expectation, actual: Locator, expected: AsymmetricMatch<T>) : never
}
}
expect.register("toEqual", withRetry((expectation, actual, expected) => {
if (!equal(actual, expected)) {
expectation.fail({
message: createObjectDiffFormatter(actual, expected)
})
}
}))I'm not sure if the extensibility is even needed, but I think it would improve our code as well. Each matcher can be written and tested in isolation.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels