Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add std/expect #3814

Merged
merged 17 commits into from
Nov 21, 2023
Next Next commit
feat: add expect syntax by wrapping assert syntax
Co-authored-by: Thomas Cruveilher <[email protected]>
kt3k and Sorikairox committed Nov 16, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
commit 6de4de6a1810953d2f5a74a3f3dc4c7e3ac84435
2 changes: 1 addition & 1 deletion assert/assert_instance_of.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@
import { AssertionError } from "./assertion_error.ts";

// deno-lint-ignore no-explicit-any
type AnyConstructor = new (...args: any[]) => any;
export type AnyConstructor = new (...args: any[]) => any;
type GetConstructorType<T extends AnyConstructor> = T extends // deno-lint-ignore no-explicit-any
new (...args: any) => infer C ? C
: never;
2 changes: 2 additions & 0 deletions testing/_matchers/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
export { toEqual } from "./to_equal.ts";
22 changes: 22 additions & 0 deletions testing/_matchers/to__strict_equal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import {
assertNotStrictEquals,
assertStrictEquals,
} from "../../testing/asserts.ts";

/* Similar to assertStrictEquals */
export function toStrictEqual(
context: MatcherContext,
expected: unknown,
): MatchResult {
if (context.isNot) {
return assertNotStrictEquals(
context.value,
expected,
context.customMessage,
);
}
return assertStrictEquals(context.value, expected, context.customMessage);
}
15 changes: 15 additions & 0 deletions testing/_matchers/to_be.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import {
assertNotStrictEquals,
assertStrictEquals,
} from "../../testing/asserts.ts";

/* Similar to assertStrictEquals and assertNotStrictEquals*/
export function toBe(context: MatcherContext, expect: unknown): MatchResult {
if (context.isNot) {
return assertNotStrictEquals(context.value, expect, context.customMessage);
}
return assertStrictEquals(context.value, expect, context.customMessage);
}
34 changes: 34 additions & 0 deletions testing/_matchers/to_be_close_to.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { assertAlmostEquals } from "../../assert/assert_almost_equals.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

/* Similar to assertStrictEquals(value, undefined) and assertNotStrictEquals(value, undefined)*/
export function toBeCloseTo(
context: MatcherContext,
expected: number,
tolerance = 1e-7,
): MatchResult {
if (context.isNot) {
const actual = Number(context.value);
const delta = Math.abs(expected - actual);
if (delta > tolerance) {
return;
}
const msgSuffix = context.customMessage
? `: ${context.customMessage}`
: ".";
const f = (n: number) => Number.isInteger(n) ? n : n.toExponential();
throw new AssertionError(
`Expected actual: "${f(actual)}" to NOT be close to "${f(expected)}": \
delta "${f(delta)}" is greater than "${f(tolerance)}"${msgSuffix}`,
);
}
return assertAlmostEquals(
Number(context.value),
expected,
tolerance,
context.customMessage,
);
}
15 changes: 15 additions & 0 deletions testing/_matchers/to_be_defined.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import {
assertNotStrictEquals,
assertStrictEquals,
} from "../../testing/asserts.ts";

/* Similar to assertStrictEquals(value, undefined) and assertNotStrictEquals(value, undefined)*/
export function toBeDefined(context: MatcherContext): MatchResult {
if (context.isNot) {
return assertStrictEquals(context.value, undefined, context.customMessage);
}
return assertNotStrictEquals(context.value, undefined, context.customMessage);
}
23 changes: 23 additions & 0 deletions testing/_matchers/to_be_falsy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

/* Similar to assertEqual(!!value) */
export function toBeTruthy(
context: MatcherContext,
): MatchResult {
const isFalsy = !(context.value);
if (context.isNot) {
if (isFalsy) {
throw new AssertionError(
`Expected ${context.value} to NOT be falsy`,
);
}
}
if (!isFalsy) {
throw new AssertionError(
`Expected ${context.value} to be falsy`,
);
}
}
24 changes: 24 additions & 0 deletions testing/_matchers/to_be_greater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

/* Similar to assertEqual */
export function toBeGreaterThan(
context: MatcherContext,
expected: number,
): MatchResult {
const isGreater = Number(context.value) > Number(expected);
if (context.isNot) {
if (isGreater) {
throw new AssertionError(
`Expected ${context.value} to NOT be greater than ${expected}`,
);
}
}
if (!isGreater) {
throw new AssertionError(
`Expected ${context.value} to be greater than ${expected}`,
);
}
}
24 changes: 24 additions & 0 deletions testing/_matchers/to_be_greater_or_equal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

/* Similar to assertEqual */
export function toBeGreaterThanOrEqual(
context: MatcherContext,
expected: number,
): MatchResult {
const isGreaterOrEqual = Number(context.value) >= Number(expected);
if (context.isNot) {
if (isGreaterOrEqual) {
throw new AssertionError(
`Expected ${context.value} to NOT be greater than or equal ${expected}`,
);
}
}
if (!isGreaterOrEqual) {
throw new AssertionError(
`Expected ${context.value} to be greater than or equal ${expected}`,
);
}
}
19 changes: 19 additions & 0 deletions testing/_matchers/to_be_instance_of.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import {
assertInstanceOf,
assertNotInstanceOf,
} from "../../testing/asserts.ts";
import { AnyConstructor } from "../../assert/assert_instance_of.ts";

/* Similar to assertInstanceOf(value, null) and assertNotInstanceOf(value, null)*/
export function toBeInstanceOf<T extends AnyConstructor>(
context: MatcherContext,
expected: T,
): MatchResult {
if (context.isNot) {
return assertNotInstanceOf(context.value, expected);
}
return assertInstanceOf(context.value, expected);
}
24 changes: 24 additions & 0 deletions testing/_matchers/to_be_less.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

/* Similar to assertEqual */
export function toBeLowerThan(
context: MatcherContext,
expected: number,
): MatchResult {
const isLower = Number(context.value) < Number(expected);
if (context.isNot) {
if (isLower) {
throw new AssertionError(
`Expected ${context.value} to NOT be lower than ${expected}`,
);
}
}
if (!isLower) {
throw new AssertionError(
`Expected ${context.value} to be lower than ${expected}`,
);
}
}
24 changes: 24 additions & 0 deletions testing/_matchers/to_be_less_or_equal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

/* Similar to assertEqual */
export function toBeLessThanOrEqual(
context: MatcherContext,
expected: number,
): MatchResult {
const isLower = Number(context.value) <= Number(expected);
if (context.isNot) {
if (isLower) {
throw new AssertionError(
`Expected ${context.value} to NOT be lower than or equal ${expected}`,
);
}
}
if (!isLower) {
throw new AssertionError(
`Expected ${context.value} to be lower than or equal ${expected}`,
);
}
}
20 changes: 20 additions & 0 deletions testing/_matchers/to_be_nan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { assertEquals, assertNotEquals } from "../../testing/asserts.ts";

/* Similar to assertStrictEquals(isNaN(context.value as number), true) and assertNotStrictEquals(isNaN(context.value as number), true)*/
export function toBeNan(context: MatcherContext): MatchResult {
if (context.isNot) {
return assertNotEquals(
isNaN(Number(context.value)),
true,
context.customMessage || `Expected ${context.value} to not be NaN`,
);
}
return assertEquals(
isNaN(Number(context.value)),
true,
context.customMessage || `Expected ${context.value} to be NaN`,
);
}
23 changes: 23 additions & 0 deletions testing/_matchers/to_be_null.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import {
assertNotStrictEquals,
assertStrictEquals,
} from "../../testing/asserts.ts";

/* Similar to assertStrictEquals(value, null) and assertNotStrictEquals(value, null)*/
export function toBeNull(context: MatcherContext): MatchResult {
if (context.isNot) {
return assertNotStrictEquals(
context.value as number,
null,
context.customMessage || `Expected ${context.value} to not be null`,
);
}
return assertStrictEquals(
context.value as number,
null,
context.customMessage || `Expected ${context.value} to be null`,
);
}
23 changes: 23 additions & 0 deletions testing/_matchers/to_be_truthy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

/* Similar to assertEqual(!!value) */
export function toBeTruthy(
context: MatcherContext,
): MatchResult {
const isTruthy = !!(context.value);
if (context.isNot) {
if (isTruthy) {
throw new AssertionError(
`Expected ${context.value} to NOT be truthy`,
);
}
}
if (!isTruthy) {
throw new AssertionError(
`Expected ${context.value} to be truthy`,
);
}
}
19 changes: 19 additions & 0 deletions testing/_matchers/to_be_undefined.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import {
assertNotStrictEquals,
assertStrictEquals,
} from "../../testing/asserts.ts";

/* Similar to assertStrictEquals(value, undefined) and assertNotStrictEquals(value, undefined)*/
export function toBeUndefined(context: MatcherContext): MatchResult {
if (context.isNot) {
return assertNotStrictEquals(
context.value,
undefined,
context.customMessage,
);
}
return assertStrictEquals(context.value, undefined, context.customMessage);
}
16 changes: 16 additions & 0 deletions testing/_matchers/to_equal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { assertNotEquals } from "../../assert/assert_not_equals.ts";
import { assertEquals } from "../../assert/assert_equals.ts";

/* Similar to assertEqual */
export function toEqual(
context: MatcherContext,
expected: unknown,
): MatchResult {
if (context.isNot) {
return assertNotEquals(context.value, expected, context.customMessage);
}
return assertEquals(context.value, expected, context.customMessage);
}
221 changes: 221 additions & 0 deletions testing/_matchers/to_equal_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import {
bold,
gray,
green,
red,
stripColor,
yellow,
} from "../../fmt/colors.ts";
import { assertThrows } from "../../assert/assert_throws.ts";
import { AssertionError } from "../../assert/assertion_error.ts";

const createHeader = (): string[] => [
"",
"",
` ${gray(bold("[Diff]"))} ${red(bold("Actual"))} / ${
green(
bold("Expected"),
)
}`,
"",
"",
];

const added: (s: string) => string = (s: string): string =>
green(bold(stripColor(s)));
const removed: (s: string) => string = (s: string): string =>
red(bold(stripColor(s)));

import { expect } from "../expect.ts";

Deno.test({
name: "pass case",
fn() {
expect({ a: 10 }).toEqual({ a: 10 });
expect(true).toEqual(true);
expect(10).toEqual(10);
expect("abc").toEqual("abc");
expect({ a: 10, b: { c: "1" } }).toEqual({ a: 10, b: { c: "1" } });
expect(new Date("invalid")).toEqual(new Date("invalid"));
},
});

Deno.test({
name: "failed with number",
fn() {
assertThrows(
() => expect(1).toEqual(2),
AssertionError,
[
"Values are not equal.",
...createHeader(),
removed(`- ${yellow("1")}`),
added(`+ ${yellow("2")}`),
"",
].join("\n"),
);
},
});

Deno.test({
name: "failed with number vs string",
fn() {
assertThrows(
() => expect(1).toEqual("1"),
AssertionError,
[
"Values are not equal.",
...createHeader(),
removed(`- ${yellow("1")}`),
added(`+ "1"`),
].join("\n"),
);
},
});

Deno.test({
name: "failed with array",
fn() {
assertThrows(
() => expect([1, "2", 3]).toEqual(["1", "2", 3]),
AssertionError,
`
[
- 1,
+ "1",
"2",
3,
]`,
);
},
});

Deno.test({
name: "failed with object",
fn() {
assertThrows(
() => expect({ a: 1, b: "2", c: 3 }).toEqual({ a: 1, b: 2, c: [3] }),
AssertionError,
`
{
a: 1,
+ b: 2,
+ c: [
+ 3,
+ ],
- b: "2",
- c: 3,
}`,
);
},
});

Deno.test({
name: "failed with date",
fn() {
assertThrows(
() =>
expect(new Date(2019, 0, 3, 4, 20, 1, 10)).toEqual(
new Date(2019, 0, 3, 4, 20, 1, 20),
),
AssertionError,
[
"Values are not equal.",
...createHeader(),
removed(`- ${new Date(2019, 0, 3, 4, 20, 1, 10).toISOString()}`),
added(`+ ${new Date(2019, 0, 3, 4, 20, 1, 20).toISOString()}`),
"",
].join("\n"),
);
assertThrows(
() =>
expect(new Date("invalid")).toEqual(new Date(2019, 0, 3, 4, 20, 1, 20)),
AssertionError,
[
"Values are not equal.",
...createHeader(),
removed(`- ${new Date("invalid")}`),
added(`+ ${new Date(2019, 0, 3, 4, 20, 1, 20).toISOString()}`),
"",
].join("\n"),
);
},
});

Deno.test({
name: "failed with custom msg",
fn() {
assertThrows(
() => expect(1, "CUSTOM MESSAGE").toEqual(2),
AssertionError,
[
"Values are not equal: CUSTOM MESSAGE",
...createHeader(),
removed(`- ${yellow("1")}`),
added(`+ ${yellow("2")}`),
"",
].join("\n"),
);
},
});

Deno.test(
"expect().toEqual compares objects structurally if one object's constructor is undefined and the other is Object",
() => {
const a = Object.create(null);
a.prop = "test";
const b = {
prop: "test",
};

expect(a).toEqual(b);
expect(b).toEqual(a);
},
);

Deno.test("expect().toEqual diff for differently ordered objects", () => {
assertThrows(
() => {
expect({
aaaaaaaaaaaaaaaaaaaaaaaa: 0,
bbbbbbbbbbbbbbbbbbbbbbbb: 0,
ccccccccccccccccccccccc: 0,
}).toEqual(
{
ccccccccccccccccccccccc: 1,
aaaaaaaaaaaaaaaaaaaaaaaa: 0,
bbbbbbbbbbbbbbbbbbbbbbbb: 0,
},
);
},
AssertionError,
`
{
aaaaaaaaaaaaaaaaaaaaaaaa: 0,
bbbbbbbbbbbbbbbbbbbbbbbb: 0,
- ccccccccccccccccccccccc: 0,
+ ccccccccccccccccccccccc: 1,
}`,
);
});

Deno.test("expect().toEqual same Set with object keys", () => {
const data = [
{
id: "_1p7ZED73OF98VbT1SzSkjn",
type: { id: "_ETGENUS" },
name: "Thuja",
friendlyId: "g-thuja",
},
{
id: "_567qzghxZmeQ9pw3q09bd3",
type: { id: "_ETGENUS" },
name: "Pinus",
friendlyId: "g-pinus",
},
];
expect(data).toEqual(data);
expect(new Set(data)).toEqual(new Set(data));
});
19 changes: 19 additions & 0 deletions testing/_matchers/to_match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { assertMatch, assertNotMatch } from "../../testing/asserts.ts";

/* Similar to assertStrictEquals(value, undefined) and assertNotStrictEquals(value, undefined)*/
export function toMatch(
context: MatcherContext,
expected: RegExp,
): MatchResult {
if (context.isNot) {
return assertNotMatch(
String(context.value),
expected,
context.customMessage,
);
}
return assertMatch(String(context.value), expected, context.customMessage);
}
40 changes: 40 additions & 0 deletions testing/_matchers/to_match_object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError, assertObjectMatch } from "../../testing/asserts.ts";
import { format } from "../../assert/_format.ts";

/* Similar to assertObjectMatch(value, expected)*/
export function toMatchObject(
context: MatcherContext,
expected: Record<PropertyKey, unknown>,
): MatchResult {
if (context.isNot) {
let objectMatch = false;
try {
assertObjectMatch(
// deno-lint-ignore no-explicit-any
context.value as Record<PropertyKey, any>,
expected,
context.customMessage,
);
objectMatch = true;
const actualString = format(context.value);
const expectedString = format(expected);
throw new AssertionError(
`Expected ${actualString} to NOT match ${expectedString}`,
);
} catch (e) {
if (objectMatch) {
throw e;
}
return;
}
}
return assertObjectMatch(
// deno-lint-ignore no-explicit-any
context.value as Record<PropertyKey, any>,
expected,
context.customMessage,
);
}
38 changes: 38 additions & 0 deletions testing/_matchers/to_throw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { MatcherContext, MatchResult } from "../_types.ts";
import { AssertionError, assertIsError } from "../../testing/asserts.ts";

/* Similar to assertIsError with value thrown error*/
export function toThrow<E extends Error = Error>(
context: MatcherContext,
// deno-lint-ignore no-explicit-any
expected: new (...args: any[]) => E,
): MatchResult {
if (typeof context.value === "function") {
try {
context.value = context.value();
} catch (err) {
context.value = err;
}
}
if (context.isNot) {
let isError = false;
try {
assertIsError(context.value, expected, undefined, context.customMessage);
isError = true;
throw new AssertionError(`Expected to NOT throw ${expected}`);
} catch (e) {
if (isError) {
throw e;
}
return;
}
}
return assertIsError(
context.value,
expected,
undefined,
context.customMessage,
);
}
33 changes: 33 additions & 0 deletions testing/_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

//ISC License
//
// Copyright (c) 2019, Allain Lalonde
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

export interface MatcherContext {
value: unknown;
isNot: boolean;
customMessage: string | undefined;
}

export type Matcher = (
context: MatcherContext,
...args: unknown[]
) => MatchResult;

export type Matchers = {
[key: string]: Matcher;
};
export type MatchResult = void | Promise<void> | boolean;
133 changes: 133 additions & 0 deletions testing/expect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// Copyright 2019 Allain Lalonde. All rights reserved. ISC License.

import * as builtInMatchers from "./_matchers/mod.ts";
import type { Matcher, MatcherContext, Matchers } from "./_types.ts";
import { AssertionError } from "../assert/assertion_error.ts";
import { AnyConstructor } from "../assert/assert_instance_of.ts";

export interface Expected {
/* Similar to assertEqual */
toEqual(candidate: unknown): void;
toStrictEqual(candidate: unknown): void;
toBe(candidate: unknown): void;
toBeCloseTo(candidate: number, tolerance?: number): void;
toBeDefined(): void;
toBeFalsy(): void;
toBeGreater(expected: number): void;
toBeGreaterOrEqual(expected: number): void;
toBeInstanceOf<T extends AnyConstructor>(expected: T): void;
toBeLess(expected: number): void;
toBeLessOrEqual(expected: number): void;
toBeNan(): void;
toBeNull(): void;
toBeTruthy(): void;
toBeUndefined(): void;
toMatch(expected: RegExp): void;
toMatchObject(expected: Record<PropertyKey, unknown>): void;
// deno-lint-ignore no-explicit-any
toThrow<E extends Error = Error>(expected: new (...args: any[]) => E): void;
not: Expected;
resolves: Async<Expected>;
rejects: Async<Expected>;
}

const matchers: Record<string | symbol, Matcher> = {
...builtInMatchers,
};

export function expect(value: unknown, customMessage?: string): Expected {
let isNot = false;
let isPromised = false;
const self: Expected = new Proxy<Expected>(
<Expected> {},
{
get(_, name) {
if (name === "not") {
isNot = !isNot;
return self;
}

if (name === "resolves") {
if (!isPromiseLike(value)) {
throw new AssertionError("expected value must be Promiselike");
}

isPromised = true;
return self;
}

if (name === "rejects") {
if (!isPromiseLike(value)) {
throw new AssertionError("expected value must be a PromiseLike");
}

value = value.then(
(value) => {
throw new AssertionError(
`Promise did not reject. resolved to ${value}`,
);
},
(err) => err,
);
isPromised = true;
return self;
}

const matcher: Matcher = matchers[name as string];
if (!matcher) {
throw new TypeError(
typeof name === "string"
? `matcher not found: ${name}`
: "matcher not found",
);
}

return (...args: unknown[]) => {
function applyMatcher(value: unknown, args: unknown[]) {
const context: MatcherContext = {
value,
isNot: false,
customMessage,
};
if (isNot) {
context.isNot = true;
}
matcher(context, ...args);
}

return isPromised
? (value as Promise<unknown>).then((value: unknown) =>
applyMatcher(value, args)
)
: applyMatcher(value, args);
};
},
},
);

return self;
}

// a helper type to match any function. Used so that we only convert functions
// to return a promise and not properties.
type Fn = (...args: unknown[]) => unknown;

// converts all the methods in an interface to be async functions
export type Async<T> = {
[K in keyof T]: T[K] extends Fn
? (...args: Parameters<T[K]>) => Promise<ReturnType<T[K]>>
: T[K];
};

export function addMatchers(newMatchers: Matchers): void {
Object.assign(matchers, newMatchers);
}

function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
if (value == null) {
return false;
} else {
return typeof ((value as Record<string, unknown>).then) === "function";
}
}