1/**
2 * A from-scratch, dependency-free `expect` covering the matcher surface used
3 * by the alchemy test suite. Vitest-compatible semantics for:
4 *
5 * - equality: toBe, toEqual, toStrictEqual
6 * - presence: toBeDefined, toBeUndefined, toBeNull, toBeTruthy, toBeFalsy, toBeNaN
7 * - containers: toContain, toContainEqual, toHaveLength, toHaveProperty
8 * - strings: toMatch
9 * - objects: toMatchObject, toBeInstanceOf, toBeTypeOf
10 * - numbers: toBeGreaterThan(OrEqual), toBeLessThan(OrEqual), toBeCloseTo
11 * - misc: toSatisfy, toBeOneOf, toThrow / toThrowError
12 * - modifiers: .not, .resolves, .rejects
13 * - asymmetric: expect.any, expect.anything, expect.arrayContaining,
14 * expect.objectContaining, expect.stringContaining, expect.stringMatching
15 * - expect.fail
16 *
17 * Assertion failures throw {@link AssertionError} (a plain Error subclass) so
18 * they propagate through Effect as defects and render with a clean message.
19 */
20
21// ---------------------------------------------------------------------------
22// AssertionError
23// ---------------------------------------------------------------------------
24
25export class AssertionError extends Error {
26 override readonly name = "AssertionError";
27 constructor(
28 message: string,
29 readonly actual?: unknown,
30 readonly expected?: unknown,
31 ) {
32 super(message);
33 }
34}
35
36// ---------------------------------------------------------------------------
37// stringify — small, cycle-safe inspector for failure messages
38// ---------------------------------------------------------------------------
39
40const MAX_DEPTH = 4;
41const MAX_ITEMS = 25;
42const MAX_STRING = 2000;
43
44export const stringify = (value: unknown): string => {
45 try {
46 const out = inspect(value, 0, new Set());
47 return out.length > MAX_STRING ? `${out.slice(0, MAX_STRING)}…` : out;
48 } catch {
49 // Coercion-hostile values (e.g. alchemy Output proxies throw on string
50 // coercion) must never break assertion-message rendering.
51 return "[unprintable]";
52 }
53};
54
55const inspect = (value: unknown, depth: number, seen: Set<unknown>): string => {
56 try {
57 return inspectUnsafe(value, depth, seen);
58 } catch {
59 return "[unprintable]";
60 }
61};
62
63const inspectUnsafe = (
64 value: unknown,
65 depth: number,
66 seen: Set<unknown>,
67): string => {
68 if (value === null) return "null";
69 if (value === undefined) return "undefined";
70 switch (typeof value) {
71 case "string":
72 return JSON.stringify(value);
73 case "number":
74 case "boolean":
75 return String(value);
76 case "bigint":
77 return `${value}n`;
78 case "symbol":
79 return value.toString();
80 case "function":
81 return `[Function ${value.name || "anonymous"}]`;
82 }
83 if (isAsymmetric(value)) return value.toString();
84 if (value instanceof Date) return `Date(${value.toISOString()})`;
85 if (value instanceof RegExp) return value.toString();
86 if (value instanceof Error)
87 return `${value.name}(${JSON.stringify(value.message)})`;
88 if (seen.has(value)) return "[Circular]";
89 if (depth > MAX_DEPTH) return Array.isArray(value) ? "[…]" : "{…}";
90 seen.add(value);
91 try {
92 if (Array.isArray(value)) {
93 const items = value
94 .slice(0, MAX_ITEMS)
95 .map((v) => inspect(v, depth + 1, seen));
96 if (value.length > MAX_ITEMS)
97 items.push(`… ${value.length - MAX_ITEMS} more`);
98 return `[ ${items.join(", ")} ]`;
99 }
100 if (value instanceof Map) {
101 const items = [...value.entries()]
102 .slice(0, MAX_ITEMS)
103 .map(
104 ([k, v]) =>
105 `${inspect(k, depth + 1, seen)} => ${inspect(v, depth + 1, seen)}`,
106 );
107 return `Map { ${items.join(", ")} }`;
108 }
109 if (value instanceof Set) {
110 const items = [...value.values()]
111 .slice(0, MAX_ITEMS)
112 .map((v) => inspect(v, depth + 1, seen));
113 return `Set { ${items.join(", ")} }`;
114 }
115 const proto = Object.getPrototypeOf(value);
116 const ctor =
117 proto !== null &&
118 proto !== Object.prototype &&
119 proto.constructor?.name !== "Object"
120 ? `${proto.constructor?.name ?? ""} `
121 : "";
122 const entries = Object.entries(value as Record<string, unknown>).slice(
123 0,
124 MAX_ITEMS,
125 );
126 const body = entries
127 .map(([k, v]) => `${k}: ${inspect(v, depth + 1, seen)}`)
128 .join(", ");
129 return `${ctor}{ ${body} }`;
130 } finally {
131 seen.delete(value);
132 }
133};
134
135// ---------------------------------------------------------------------------
136// Asymmetric matchers
137// ---------------------------------------------------------------------------
138
139interface AsymmetricMatcher {
140 matches(actual: unknown): boolean;
141 toString(): string;
142}
143
144/**
145 * Identity-based registry. A `Symbol in value` check would be unreliable:
146 * alchemy's `Output`/`PropExpr` proxies answer `true` for arbitrary `has`
147 * probes, which would misclassify them as matchers.
148 */
149const asymmetricRegistry = new WeakSet<object>();
150
151const isAsymmetric = (value: unknown): value is AsymmetricMatcher =>
152 typeof value === "object" && value !== null && asymmetricRegistry.has(value);
153
154const asymmetric = (
155 label: string,
156 matches: (actual: unknown) => boolean,
157): AsymmetricMatcher => {
158 const matcher: AsymmetricMatcher = {
159 matches,
160 toString: () => label,
161 };
162 asymmetricRegistry.add(matcher);
163 return matcher;
164};
165
166const anyMatcher = (ctor: any): AsymmetricMatcher =>
167 asymmetric(`Any<${ctor?.name ?? "?"}>`, (actual) => {
168 if (ctor === String)
169 return typeof actual === "string" || actual instanceof String;
170 if (ctor === Number)
171 return typeof actual === "number" || actual instanceof Number;
172 if (ctor === Boolean)
173 return typeof actual === "boolean" || actual instanceof Boolean;
174 if (ctor === BigInt) return typeof actual === "bigint";
175 if (ctor === Symbol) return typeof actual === "symbol";
176 if (ctor === Function) return typeof actual === "function";
177 if (ctor === Object) return typeof actual === "object" && actual !== null;
178 if (ctor === Array) return Array.isArray(actual);
179 return actual instanceof ctor;
180 });
181
182// ---------------------------------------------------------------------------
183// Deep equality (jest `toEqual` semantics: undefined-valued keys are ignored,
184// asymmetric matchers are honored on the expected side)
185// ---------------------------------------------------------------------------
186
187export const equals = (
188 actual: unknown,
189 expected: unknown,
190 strict = false,
191): boolean => eq(actual, expected, strict, new Map());
192
193const definedKeys = (obj: Record<PropertyKey, unknown>): Array<PropertyKey> =>
194 (Reflect.ownKeys(obj) as Array<PropertyKey>).filter(
195 (k) => obj[k as any] !== undefined,
196 );
197
198const eq = (
199 a: unknown,
200 b: unknown,
201 strict: boolean,
202 seen: Map<unknown, unknown>,
203): boolean => {
204 if (isAsymmetric(b)) return b.matches(a);
205 if (isAsymmetric(a)) return a.matches(b);
206 if (Object.is(a, b)) return true;
207 if (typeof a !== typeof b) return false;
208 if (typeof a !== "object" || a === null || b === null) return false;
209 const objA = a as Record<PropertyKey, unknown>;
210 const objB = b as Record<PropertyKey, unknown>;
211
212 if (seen.get(a) === b) return true;
213 seen.set(a, b);
214 try {
215 if (a instanceof Date || b instanceof Date) {
216 return (
217 a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
218 );
219 }
220 if (a instanceof Error || b instanceof Error) {
221 // Vitest semantics: errors match on class, message and own enumerable
222 // fields (e.g. Data.TaggedError payloads) — never on `stack`.
223 if (!(a instanceof Error) || !(b instanceof Error)) return false;
224 if (a.name !== b.name || a.message !== b.message) return false;
225 const fieldsOf = (error: Error): Record<string, unknown> => {
226 const fields: Record<string, unknown> = {};
227 for (const [key, value] of Object.entries(error)) {
228 if (key === "stack" || key === "message" || key === "name") continue;
229 if (value !== undefined) fields[key] = value;
230 }
231 return fields;
232 };
233 return eq(fieldsOf(a), fieldsOf(b), false, seen);
234 }
235 if (a instanceof RegExp || b instanceof RegExp) {
236 return (
237 a instanceof RegExp &&
238 b instanceof RegExp &&
239 a.source === b.source &&
240 a.flags === b.flags
241 );
242 }
243 if (a instanceof URL || b instanceof URL) {
244 return a instanceof URL && b instanceof URL && a.href === b.href;
245 }
246 if (Array.isArray(a) || Array.isArray(b)) {
247 if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
248 return false;
249 }
250 for (let i = 0; i < a.length; i++) {
251 if (!eq(a[i], b[i], strict, seen)) return false;
252 }
253 return true;
254 }
255 if (a instanceof Map || b instanceof Map) {
256 if (!(a instanceof Map) || !(b instanceof Map) || a.size !== b.size) {
257 return false;
258 }
259 for (const [k, v] of a) {
260 if (!b.has(k) || !eq(v, b.get(k), strict, seen)) return false;
261 }
262 return true;
263 }
264 if (a instanceof Set || b instanceof Set) {
265 if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) {
266 return false;
267 }
268 outer: for (const v of a) {
269 if (b.has(v)) continue;
270 for (const w of b) {
271 if (eq(v, w, strict, seen)) continue outer;
272 }
273 return false;
274 }
275 return true;
276 }
277 if (ArrayBuffer.isView(a) || ArrayBuffer.isView(b)) {
278 if (!ArrayBuffer.isView(a) || !ArrayBuffer.isView(b)) return false;
279 const ta = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
280 const tb = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
281 if (ta.length !== tb.length) return false;
282 for (let i = 0; i < ta.length; i++) if (ta[i] !== tb[i]) return false;
283 return true;
284 }
285 if (strict && Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) {
286 return false;
287 }
288 const keysA = strict ? Reflect.ownKeys(objA) : definedKeys(objA);
289 const keysB = strict ? Reflect.ownKeys(objB) : definedKeys(objB);
290 if (keysA.length !== keysB.length) return false;
291 for (const k of keysA) {
292 if (!(k in objB) && objB[k as any] === undefined && !strict) return false;
293 if (!eq(objA[k as any], objB[k as any], strict, seen)) return false;
294 }
295 return true;
296 } finally {
297 seen.delete(a);
298 }
299};
300
301/**
302 * Object-like values for structural matching. Includes functions because
303 * alchemy's `Output` proxies wrap a function target (so they're callable)
304 * while still exposing data properties.
305 */
306const isObjectLike = (value: unknown): value is Record<PropertyKey, unknown> =>
307 value !== null && (typeof value === "object" || typeof value === "function");
308
309/** Subset match for toMatchObject: every key in `expected` must match. */
310const matchesObject = (
311 actual: unknown,
312 expected: unknown,
313 seen: Map<unknown, unknown>,
314): boolean => {
315 if (isAsymmetric(expected)) return expected.matches(actual);
316 if (typeof expected !== "object" || expected === null) {
317 return eq(actual, expected, false, seen);
318 }
319 if (!isObjectLike(actual)) return false;
320 if (Array.isArray(expected)) {
321 if (!Array.isArray(actual) || actual.length !== expected.length)
322 return false;
323 return expected.every((e, i) => matchesObject(actual[i], e, seen));
324 }
325 if (
326 expected instanceof Date ||
327 expected instanceof RegExp ||
328 expected instanceof Map ||
329 expected instanceof Set
330 ) {
331 return eq(actual, expected, false, seen);
332 }
333 if (seen.get(actual) === expected) return true;
334 seen.set(actual, expected);
335 try {
336 for (const k of Reflect.ownKeys(expected as Record<PropertyKey, unknown>)) {
337 const e = (expected as any)[k];
338 if (e === undefined && !(k in (actual as any))) continue;
339 if (!matchesObject((actual as any)[k], e, seen)) return false;
340 }
341 return true;
342 } finally {
343 seen.delete(actual);
344 }
345};
346
347// ---------------------------------------------------------------------------
348// Matchers
349// ---------------------------------------------------------------------------
350
351export interface Matchers<A = unknown> {
352 toBe(expected: unknown): void;
353 toEqual(expected: unknown): void;
354 toStrictEqual(expected: unknown): void;
355 toBeDefined(): void;
356 toBeUndefined(): void;
357 toBeNull(): void;
358 toBeTruthy(): void;
359 toBeFalsy(): void;
360 toBeNaN(): void;
361 toContain(item: unknown): void;
362 toContainEqual(item: unknown): void;
363 toHaveLength(length: number): void;
364 toHaveProperty(path: string | Array<PropertyKey>, value?: unknown): void;
365 toMatch(pattern: string | RegExp): void;
366 toMatchObject(expected: object): void;
367 toThrow(
368 expected?: string | RegExp | Error | (new (...args: Array<any>) => Error),
369 ): void;
370 toThrowError(
371 expected?: string | RegExp | Error | (new (...args: Array<any>) => Error),
372 ): void;
373 toBeGreaterThan(expected: number | bigint): void;
374 toBeGreaterThanOrEqual(expected: number | bigint): void;
375 toBeLessThan(expected: number | bigint): void;
376 toBeLessThanOrEqual(expected: number | bigint): void;
377 toBeCloseTo(expected: number, precision?: number): void;
378 toBeInstanceOf(ctor: abstract new (...args: Array<any>) => unknown): void;
379 toBeTypeOf(
380 type:
381 | "string"
382 | "number"
383 | "boolean"
384 | "bigint"
385 | "symbol"
386 | "function"
387 | "object"
388 | "undefined",
389 ): void;
390 toSatisfy(predicate: (value: A) => boolean): void;
391 toBeOneOf(values: ReadonlyArray<unknown>): void;
392 readonly not: Matchers<A>;
393 readonly resolves: PromiseMatchers<A>;
394 readonly rejects: PromiseMatchers<A>;
395}
396
397export type PromiseMatchers<A> = {
398 readonly [K in Exclude<keyof Matchers<A>, "not" | "resolves" | "rejects">]: (
399 ...args: Parameters<Matchers<A>[K]>
400 ) => Promise<void>;
401} & { readonly not: PromiseMatchers<A> };
402
403const fail = (message: string, actual?: unknown, expected?: unknown): never => {
404 throw new AssertionError(message, actual, expected);
405};
406
407const check = (
408 pass: boolean,
409 negated: boolean,
410 message: () => string,
411 actual?: unknown,
412 expected?: unknown,
413): void => {
414 if (pass === negated) {
415 fail(negated ? `NOT: ${message()}` : message(), actual, expected);
416 }
417};
418
419const resolveThrown = (actual: unknown): { threw: boolean; error: unknown } => {
420 if (typeof actual !== "function") {
421 // expect(error).toThrow — vitest allows passing an already-caught error
422 return { threw: true, error: actual };
423 }
424 try {
425 (actual as () => unknown)();
426 return { threw: false, error: undefined };
427 } catch (error) {
428 return { threw: true, error };
429 }
430};
431
432const throwMatches = (
433 error: unknown,
434 expected?: string | RegExp | Error | (new (...args: Array<any>) => Error),
435): boolean => {
436 if (expected === undefined) return true;
437 const message = error instanceof Error ? error.message : String(error);
438 if (typeof expected === "string") return message.includes(expected);
439 if (expected instanceof RegExp) return expected.test(message);
440 if (expected instanceof Error) return message === expected.message;
441 return error instanceof expected;
442};
443
444const getPath = (
445 target: unknown,
446 path: string | Array<PropertyKey>,
447): { found: boolean; value: unknown } => {
448 const parts = Array.isArray(path)
449 ? path
450 : path.split(".").flatMap((segment) =>
451 segment
452 .split(/[[\]]/)
453 .filter((s) => s !== "")
454 .map((s) => (/^\d+$/.test(s) ? Number(s) : s)),
455 );
456 let current: any = target;
457 for (const part of parts) {
458 if (
459 current === null ||
460 current === undefined ||
461 !(part in Object(current))
462 ) {
463 return { found: false, value: undefined };
464 }
465 current = current[part as any];
466 }
467 return { found: true, value: current };
468};
469
470const makeMatchers = <A>(actual: A, negated: boolean): Matchers<A> => {
471 const str = (v: unknown) => stringify(v);
472 const self: Matchers<A> = {
473 toBe: (expected) =>
474 check(
475 Object.is(actual, expected),
476 negated,
477 () => `expected ${str(actual)} to be ${str(expected)}`,
478 actual,
479 expected,
480 ),
481 toEqual: (expected) =>
482 check(
483 equals(actual, expected),
484 negated,
485 () => `expected ${str(actual)} to equal ${str(expected)}`,
486 actual,
487 expected,
488 ),
489 toStrictEqual: (expected) =>
490 check(
491 equals(actual, expected, true),
492 negated,
493 () => `expected ${str(actual)} to strictly equal ${str(expected)}`,
494 actual,
495 expected,
496 ),
497 toBeDefined: () =>
498 check(
499 actual !== undefined,
500 negated,
501 () => `expected value to be defined`,
502 ),
503 toBeUndefined: () =>
504 check(
505 actual === undefined,
506 negated,
507 () => `expected ${str(actual)} to be undefined`,
508 ),
509 toBeNull: () =>
510 check(
511 actual === null,
512 negated,
513 () => `expected ${str(actual)} to be null`,
514 ),
515 toBeTruthy: () =>
516 check(!!actual, negated, () => `expected ${str(actual)} to be truthy`),
517 toBeFalsy: () =>
518 check(!actual, negated, () => `expected ${str(actual)} to be falsy`),
519 toBeNaN: () =>
520 check(
521 typeof actual === "number" && Number.isNaN(actual),
522 negated,
523 () => `expected ${str(actual)} to be NaN`,
524 ),
525 toContain: (item) => {
526 let pass = false;
527 if (typeof actual === "string") {
528 pass = typeof item === "string" && actual.includes(item);
529 } else if (Array.isArray(actual)) {
530 pass = actual.includes(item);
531 } else if (actual instanceof Set) {
532 pass = actual.has(item);
533 } else if (
534 actual !== null &&
535 typeof actual === "object" &&
536 Symbol.iterator in (actual as object)
537 ) {
538 pass = [...(actual as unknown as Iterable<unknown>)].includes(item);
539 }
540 check(
541 pass,
542 negated,
543 () => `expected ${str(actual)} to contain ${str(item)}`,
544 actual,
545 item,
546 );
547 },
548 toContainEqual: (item) => {
549 const values = Array.isArray(actual)
550 ? actual
551 : actual instanceof Set
552 ? [...actual]
553 : actual !== null &&
554 typeof actual === "object" &&
555 Symbol.iterator in (actual as object)
556 ? [...(actual as unknown as Iterable<unknown>)]
557 : undefined;
558 check(
559 values !== undefined && values.some((v) => equals(v, item)),
560 negated,
561 () =>
562 `expected ${str(actual)} to contain an element equal to ${str(item)}`,
563 actual,
564 item,
565 );
566 },
567 toHaveLength: (length) => {
568 const actualLength =
569 typeof actual === "string" || Array.isArray(actual)
570 ? actual.length
571 : ((actual as any)?.length ?? (actual as any)?.size);
572 check(
573 actualLength === length,
574 negated,
575 () =>
576 `expected length ${str(actualLength)} to be ${length} (value: ${str(actual)})`,
577 actualLength,
578 length,
579 );
580 },
581 toHaveProperty: (path, ...rest) => {
582 const { found, value } = getPath(actual, path);
583 const pass = found && (rest.length === 0 || equals(value, rest[0]));
584 check(
585 pass,
586 negated,
587 () =>
588 rest.length === 0
589 ? `expected ${str(actual)} to have property ${JSON.stringify(String(path))}`
590 : `expected property ${JSON.stringify(String(path))} of ${str(actual)} to equal ${str(rest[0])}, got ${str(value)}`,
591 value,
592 rest[0],
593 );
594 },
595 toMatch: (pattern) => {
596 const text = typeof actual === "string" ? actual : undefined;
597 const pass =
598 text !== undefined &&
599 (typeof pattern === "string"
600 ? text.includes(pattern)
601 : pattern.test(text));
602 check(
603 pass,
604 negated,
605 () => `expected ${str(actual)} to match ${str(pattern)}`,
606 actual,
607 pattern,
608 );
609 },
610 toMatchObject: (expected) =>
611 check(
612 matchesObject(actual, expected, new Map()),
613 negated,
614 () => `expected ${str(actual)} to match object ${str(expected)}`,
615 actual,
616 expected,
617 ),
618 toThrow: (expected) => {
619 const { threw, error } = resolveThrown(actual);
620 check(
621 threw && throwMatches(error, expected),
622 negated,
623 () =>
624 threw
625 ? `expected thrown error ${str(error)} to match ${str(expected)}`
626 : `expected function to throw`,
627 error,
628 expected,
629 );
630 },
631 toThrowError: (expected) => self.toThrow(expected),
632 toBeGreaterThan: (expected) =>
633 check(
634 (actual as any) > expected,
635 negated,
636 () => `expected ${str(actual)} to be greater than ${str(expected)}`,
637 actual,
638 expected,
639 ),
640 toBeGreaterThanOrEqual: (expected) =>
641 check(
642 (actual as any) >= expected,
643 negated,
644 () => `expected ${str(actual)} to be >= ${str(expected)}`,
645 actual,
646 expected,
647 ),
648 toBeLessThan: (expected) =>
649 check(
650 (actual as any) < expected,
651 negated,
652 () => `expected ${str(actual)} to be less than ${str(expected)}`,
653 actual,
654 expected,
655 ),
656 toBeLessThanOrEqual: (expected) =>
657 check(
658 (actual as any) <= expected,
659 negated,
660 () => `expected ${str(actual)} to be <= ${str(expected)}`,
661 actual,
662 expected,
663 ),
664 toBeCloseTo: (expected, precision = 2) =>
665 check(
666 typeof actual === "number" &&
667 Math.abs(actual - expected) < 10 ** -precision / 2,
668 negated,
669 () =>
670 `expected ${str(actual)} to be close to ${expected} (precision ${precision})`,
671 actual,
672 expected,
673 ),
674 toBeInstanceOf: (ctor) =>
675 check(
676 actual instanceof ctor,
677 negated,
678 () => `expected ${str(actual)} to be an instance of ${ctor.name}`,
679 actual,
680 ctor,
681 ),
682 toBeTypeOf: (type) =>
683 check(
684 typeof actual === type,
685 negated,
686 () =>
687 `expected ${str(actual)} (${typeof actual}) to be of type ${type}`,
688 typeof actual,
689 type,
690 ),
691 toSatisfy: (predicate) =>
692 check(
693 predicate(actual),
694 negated,
695 () =>
696 `expected ${str(actual)} to satisfy ${predicate.name || "predicate"}`,
697 actual,
698 ),
699 toBeOneOf: (values) =>
700 check(
701 values.some((v) => equals(actual, v)),
702 negated,
703 () => `expected ${str(actual)} to be one of ${str(values)}`,
704 actual,
705 values,
706 ),
707 get not() {
708 return makeMatchers(actual, !negated);
709 },
710 get resolves() {
711 return makePromiseMatchers(actual, negated, "resolves");
712 },
713 get rejects() {
714 return makePromiseMatchers(actual, negated, "rejects");
715 },
716 };
717 return self;
718};
719
720const makePromiseMatchers = <A>(
721 actual: A,
722 negated: boolean,
723 kind: "resolves" | "rejects",
724): PromiseMatchers<A> => {
725 const settle = async (): Promise<unknown> => {
726 const promise = typeof actual === "function" ? (actual as any)() : actual;
727 if (kind === "resolves") return await promise;
728 try {
729 const value = await promise;
730 fail(
731 `expected promise to reject, but it resolved with ${stringify(value)}`,
732 );
733 } catch (error) {
734 if (error instanceof AssertionError) throw error;
735 return error;
736 }
737 };
738 return new Proxy({} as PromiseMatchers<A>, {
739 get(_target, prop) {
740 if (prop === "not") return makePromiseMatchers(actual, !negated, kind);
741 if (prop === "then") return undefined;
742 return (...args: Array<unknown>) =>
743 settle().then((value) => {
744 const matchers = makeMatchers(value, negated) as any;
745 return matchers[prop](...args);
746 });
747 },
748 });
749};
750
751// ---------------------------------------------------------------------------
752// expect
753// ---------------------------------------------------------------------------
754
755export interface Expect {
756 <A>(actual: A, message?: string): Matchers<A>;
757 any(ctor: any): any;
758 anything(): any;
759 arrayContaining(items: ReadonlyArray<unknown>): any;
760 objectContaining(subset: Record<string, unknown>): any;
761 stringContaining(substring: string): any;
762 stringMatching(pattern: string | RegExp): any;
763 toSatisfy(predicate: (value: any) => boolean, description?: string): any;
764 fail(message?: string): never;
765}
766
767export const expect: Expect = Object.assign(
768 <A>(actual: A, _message?: string): Matchers<A> => makeMatchers(actual, false),
769 {
770 any: anyMatcher,
771 anything: () =>
772 asymmetric(
773 "Anything",
774 (actual) => actual !== null && actual !== undefined,
775 ),
776 arrayContaining: (items: ReadonlyArray<unknown>) =>
777 asymmetric(
778 `ArrayContaining ${stringify(items)}`,
779 (actual) =>
780 Array.isArray(actual) &&
781 items.every((item) => actual.some((v) => equals(v, item))),
782 ),
783 objectContaining: (subset: Record<string, unknown>) =>
784 asymmetric(`ObjectContaining ${stringify(subset)}`, (actual) =>
785 matchesObject(actual, subset, new Map()),
786 ),
787 stringContaining: (substring: string) =>
788 asymmetric(
789 `StringContaining ${JSON.stringify(substring)}`,
790 (actual) => typeof actual === "string" && actual.includes(substring),
791 ),
792 stringMatching: (pattern: string | RegExp) =>
793 asymmetric(`StringMatching ${String(pattern)}`, (actual) => {
794 if (typeof actual !== "string") return false;
795 return typeof pattern === "string"
796 ? actual.includes(pattern)
797 : pattern.test(actual);
798 }),
799 toSatisfy: (predicate: (value: any) => boolean, description?: string) =>
800 asymmetric(
801 `Satisfies ${description ?? predicate.name ?? "predicate"}`,
802 (actual) => predicate(actual),
803 ),
804 fail: (message?: string): never => {
805 throw new AssertionError(message ?? "expect.fail()");
806 },
807 },
808);
809
810// ---------------------------------------------------------------------------
811// assert
812// ---------------------------------------------------------------------------
813
814export interface Assert {
815 (condition: unknown, message?: string): asserts condition;
816 strictEqual(actual: unknown, expected: unknown, message?: string): void;
817 deepStrictEqual(actual: unknown, expected: unknown, message?: string): void;
818 isDefined<A>(value: A): asserts value is NonNullable<A>;
819 fail(message?: string): never;
820}
821
822const assertFn = (condition: unknown, message?: string): asserts condition => {
823 if (!condition) {
824 throw new AssertionError(message ?? "assertion failed");
825 }
826};
827
828export const assert: Assert = Object.assign(assertFn, {
829 strictEqual: (actual: unknown, expected: unknown, message?: string) => {
830 if (!Object.is(actual, expected)) {
831 throw new AssertionError(
832 message ?? `expected ${stringify(actual)} to be ${stringify(expected)}`,
833 actual,
834 expected,
835 );
836 }
837 },
838 deepStrictEqual: (actual: unknown, expected: unknown, message?: string) => {
839 if (!equals(actual, expected, true)) {
840 throw new AssertionError(
841 message ??
842 `expected ${stringify(actual)} to deep equal ${stringify(expected)}`,
843 actual,
844 expected,
845 );
846 }
847 },
848 isDefined: <A>(value: A): asserts value is NonNullable<A> => {
849 if (value === null || value === undefined) {
850 throw new AssertionError(`expected value to be defined`);
851 }
852 },
853 fail: (message?: string): never => {
854 throw new AssertionError(message ?? "assert.fail()");
855 },
856}) as Assert;