import * as plugins from './plugins.js'; import { StringMatchers, ArrayMatchers, NumberMatchers, BooleanMatchers, ObjectMatchers, FunctionMatchers, DateMatchers, TypeMatchers, } from './namespaces/index.js'; /** * Definition of a custom matcher function. * Should return an object with `pass` and optional `message`. */ import type { TMatcher, TExecutionType } from './types.js'; /** * Core assertion class. Generic over the current value type T. */ /** * Internal matcher classes for expect.any and expect.anything */ export class AnyMatcher { constructor(public expectedConstructor: any) {} } export class AnythingMatcher {} export class Assertion { executionMode: TExecutionType; baseReference: any; propertyDrillDown: Array = []; private notSetting = false; private timeoutSetting = 0; /** Registry of user-defined custom matchers */ private static customMatchers: Record = {}; /** Flag for Promise rejection assertions */ private isRejects = false; /** Flag for Promise resolution assertions (default for async) */ private isResolves = false; private failMessage: string; private successMessage: string; /** Computed negation failure message for the current assertion */ private negativeMessage: string; constructor(baseReferenceArg: any, executionModeArg: TExecutionType) { this.baseReference = baseReferenceArg; this.executionMode = executionModeArg; } /** * Register custom matchers to be available on all assertions. * @param matchers An object whose keys are matcher names and values are matcher functions. */ public static extend(matchers: Record): void { for (const [name, fn] of Object.entries(matchers)) { if ((Assertion.prototype as any)[name]) { throw new Error(`Cannot extend. Matcher '${name}' already exists on Assertion.`); } // store in registry Assertion.customMatchers[name] = fn; // add method to prototype (Assertion.prototype as any)[name] = function (...args: any[]) { return this.runCheck(() => { const received = this.getObjectToTestReference(); const result = fn(received, ...args); const pass = result.pass; const msg = result.message; if (!pass) { const message = typeof msg === 'function' ? msg() : msg; throw new Error(message || `Custom matcher '${name}' failed`); } }); }; } } private getObjectToTestReference() { let returnObjectToTestReference = this.baseReference; for (const property of this.propertyDrillDown) { if (returnObjectToTestReference == null) { // if it's null or undefined, stop break; } // We just directly access with bracket notation. // If property is a string, it's like obj["someProp"]; // If property is a number, it's like obj[0]. returnObjectToTestReference = returnObjectToTestReference[property]; } return returnObjectToTestReference; } private formatDrillDown(): string { if (!this.propertyDrillDown || this.propertyDrillDown.length === 0) { return ''; } const path = this.propertyDrillDown.map(prop => { if (typeof prop === 'number') { return `[${prop}]`; } else { return `.${prop}`; } }).join(''); return path; } private formatValue(value: any): string { if (value === null) { return 'null'; } else if (value === undefined) { return 'undefined'; } else if (typeof value === 'object') { try { return JSON.stringify(value); } catch (e) { return `[Object ${value.constructor.name}]`; } } else if (typeof value === 'function') { return `[Function${value.name ? ': ' + value.name : ''}]`; } else if (typeof value === 'string') { return `"${value}"`; } else { return String(value); } } private createErrorMessage(message: string): string { if (this.failMessage) { return this.failMessage; } const testValue = this.getObjectToTestReference(); const formattedValue = this.formatValue(testValue); const drillDown = this.formatDrillDown(); // Replace placeholders in the message return message .replace('{value}', formattedValue) .replace('{path}', drillDown || ''); } /** * Compute a negated failure message by inserting 'not' into the positive message. */ private computeNegationMessage(message: string): string { const idx = message.indexOf(' to '); if (idx !== -1) { return message.slice(0, idx) + ' not' + message.slice(idx); } return 'Negated: ' + message; } public get not() { this.notSetting = true; return this; } /** * Assert that a Promise resolves. */ public get resolves(): this { this.isResolves = true; this.isRejects = false; this.executionMode = 'async'; return this; } /** * Assert that a Promise rejects. */ public get rejects(): this { this.isRejects = true; this.isResolves = false; this.executionMode = 'async'; return this; } /** * @deprecated use `.withTimeout(ms)` instead for clarity * Set a timeout (in ms) for async assertions (Promise must settle before timeout). */ public timeout(millisArg: number) { // eslint-disable-next-line no-console console.warn('[DEPRECATED] .timeout() is deprecated. Use .withTimeout(ms)'); this.timeoutSetting = millisArg; return this; } /** * Set a timeout (in ms) for async assertions (Promise must settle before timeout). */ public withTimeout(millisArg: number) { this.timeoutSetting = millisArg; return this; } public setFailMessage(failMessageArg: string) { this.failMessage = failMessageArg; return this; } public setSuccessMessage(successMessageArg: string) { this.successMessage = successMessageArg; return this; } private runCheck(checkFunction: () => any) { const runDirectOrNegated = (checkFunction: () => any) => { if (!this.notSetting) { return checkFunction(); } else { let isOk = false; try { // attempt positive assertion and expect it to throw checkFunction(); } catch (e) { isOk = true; } if (!isOk) { const msg = this.failMessage || this.negativeMessage || 'Negated assertion failed'; throw new Error(msg); } } }; if (this.executionMode === 'async') { const done = plugins.smartpromise.defer(); const isThenable = this.baseReference && typeof (this.baseReference as any).then === 'function'; if (!isThenable) { done.reject(new Error(`Expected a Promise but received: ${this.formatValue(this.baseReference)}`)); return done.promise; } if (this.timeoutSetting) { plugins.smartdelay.delayFor(this.timeoutSetting).then(() => { if (done.status === 'pending') { done.reject(new Error(`Promise timed out after ${this.timeoutSetting}ms`)); } }); } if (this.isRejects) { (this.baseReference as Promise).then( (res: any) => { done.reject(new Error(`Expected Promise to reject but it resolved with ${this.formatValue(res)}`)); }, (err: any) => { this.baseReference = err; try { const ret = runDirectOrNegated(checkFunction); done.resolve(ret); } catch (e: any) { done.reject(e); } } ); } else { (this.baseReference as Promise).then( (res: any) => { this.baseReference = res; try { const ret = runDirectOrNegated(checkFunction); done.resolve(ret); } catch (e: any) { done.reject(e); } }, (err: any) => { done.reject(err); } ); } return done.promise; } return runDirectOrNegated(checkFunction); } public customAssertion( assertionFunction: (value: any) => boolean, errorMessage: string | ((value: any) => string) ) { // Prepare negation message based on the positive error template, if static if (typeof errorMessage === 'string') { this.negativeMessage = this.computeNegationMessage(errorMessage); } return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!assertionFunction(value)) { const msg = this.failMessage || (typeof errorMessage === 'function' ? errorMessage(value) : errorMessage); throw new Error(msg); } }); } /** * Drill into a property of an object. * @param propertyName Name of the property to navigate into. * @returns Assertion of the property type. */ public property>(propertyName: K): Assertion[K]> { this.propertyDrillDown.push(propertyName as string); return this as unknown as Assertion[K]>; } /** * Drill into an array element by index. * @param index Index of the array item. * @returns Assertion of the element type. */ public arrayItem(index: number): Assertion ? U : unknown> { this.propertyDrillDown.push(index); return this as unknown as Assertion ? U : unknown>; } public log() { console.log(`Current value:`); console.log(JSON.stringify(this.getObjectToTestReference(), null, 2)); console.log(`Path: ${this.formatDrillDown() || '(root)'}`); return this; } // Direct (flat) matcher aliases public toEqual(expected: any) { return this.customAssertion( (v) => plugins.fastDeepEqual(v, expected), `Expected value to equal ${JSON.stringify(expected)}` ); } public toBeTrue() { return this.boolean.toBeTrue(); } public toBeFalse() { return this.boolean.toBeFalse(); } public toBeTruthy() { return this.boolean.toBeTruthy(); } public toBeFalsy() { return this.boolean.toBeFalsy(); } public toThrow(expectedError?: any) { return this.function.toThrow(expectedError); } public toBeGreaterThan(value: number) { return this.number.toBeGreaterThan(value); } public toBeLessThan(value: number) { return this.number.toBeLessThan(value); } public toBeGreaterThanOrEqual(value: number) { return this.number.toBeGreaterThanOrEqual(value); } public toBeLessThanOrEqual(value: number) { return this.number.toBeLessThanOrEqual(value); } public toBeCloseTo(value: number, precision?: number) { return this.number.toBeCloseTo(value, precision); } public toBeArray() { return this.array.toBeArray(); } public toContain(item: any) { return this.array.toContain(item); } public toContainEqual(item: any) { return this.array.toContainEqual(item); } public toContainAll(items: any[]) { return this.array.toContainAll(items); } public toExclude(item: any) { return this.array.toExclude(item); } public toBeEmptyArray() { return this.array.toBeEmptyArray(); } public toStartWith(prefix: string) { return this.string.toStartWith(prefix); } public toEndWith(suffix: string) { return this.string.toEndWith(suffix); } public toInclude(substring: string) { return this.string.toInclude(substring); } public toMatch(regex: RegExp) { return this.string.toMatch(regex); } public toBeOneOf(values: any[]) { return this.string.toBeOneOf(values as string[]); } public toHaveProperty(property: string, value?: any) { return this.object.toHaveProperty(property, value); } public toMatchObject(expected: object) { return this.object.toMatchObject(expected); } public toBeInstanceOf(constructor: any) { return this.object.toBeInstanceOf(constructor); } public toHaveDeepProperty(path: string[]) { return this.object.toHaveDeepProperty(path); } public toBeNull() { return this.object.toBeNull(); } public toBeUndefined() { return this.object.toBeUndefined(); } public toBeNullOrUndefined() { return this.object.toBeNullOrUndefined(); } public toBeDate() { return this.date.toBeDate(); } public toBeBeforeDate(date: Date) { return this.date.toBeBeforeDate(date); } public toBeAfterDate(date: Date) { return this.date.toBeAfterDate(date); } public toBeTypeofString() { return this.type.toBeTypeofString(); } public toBeTypeofNumber() { return this.type.toBeTypeofNumber(); } public toBeTypeofBoolean() { return this.type.toBeTypeofBoolean(); } public toBeTypeOf(typeName: string) { return this.type.toBeTypeOf(typeName); } public toBeDefined() { return this.type.toBeDefined(); } // Namespaced matcher accessors /** String-specific matchers */ public get string() { return new StringMatchers(this as Assertion); } /** Array-specific matchers */ public get array() { return new ArrayMatchers(this as Assertion); } /** Number-specific matchers */ public get number() { return new NumberMatchers(this as Assertion); } /** Boolean-specific matchers */ public get boolean() { return new BooleanMatchers(this as Assertion); } /** Object-specific matchers */ public get object() { return new ObjectMatchers(this as Assertion); } /** Function-specific matchers */ public get function() { return new FunctionMatchers(this as Assertion); } /** Date-specific matchers */ public get date() { return new DateMatchers(this as Assertion); } /** Type-based matchers */ public get type() { return new TypeMatchers(this as Assertion); } }