import * as plugins from './smartexpect.plugins.js'; export type TExecutionType = 'sync' | 'async'; export class Assertion { executionMode: TExecutionType; baseReference: any; propertyDrillDown: Array = []; private notSetting = false; private timeoutSetting = 0; private failMessage: string; private successMessage: string; constructor(baseReferenceArg: any, executionModeArg: TExecutionType) { this.baseReference = baseReferenceArg; this.executionMode = executionModeArg; } 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 || ''); } public get not() { this.notSetting = true; return this; } public timeout(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 { runDirectOrNegated(checkFunction()); } catch (e) { isOk = true; } if (!isOk) { throw new Error(this.failMessage || 'Negated assertion failed'); } } }; if (this.executionMode === 'async') { const done = plugins.smartpromise.defer(); if (!(this.baseReference instanceof Promise)) { done.reject(new Error(`Expected a Promise but received: ${this.formatValue(this.baseReference)}`)); } else { if (this.timeoutSetting) { plugins.smartdelay.delayFor(this.timeoutSetting).then(() => { if (done.status === 'pending') { done.reject(new Error(`Promise timed out after ${this.timeoutSetting}ms`)); } }); } this.baseReference.then((promiseResultArg: any) => { this.baseReference = promiseResultArg; done.resolve(runDirectOrNegated(checkFunction)); }); } return done.promise; } else { return runDirectOrNegated(checkFunction); } } public toBeDefined() { return this.runCheck(() => { if (this.getObjectToTestReference() === undefined) { throw new Error( this.createErrorMessage('Expected value{path} to be defined, but got undefined') ); } }); } public toBeTypeofString() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (typeof value !== 'string') { throw new Error( this.createErrorMessage(`Expected value{path} to be of type string, but got ${typeof value}`) ); } }); } public toBeTypeofNumber() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (typeof value !== 'number') { throw new Error( this.createErrorMessage(`Expected value{path} to be of type number, but got ${typeof value}`) ); } }); } public toBeTypeofBoolean() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (typeof value !== 'boolean') { throw new Error( this.createErrorMessage(`Expected value{path} to be of type boolean, but got ${typeof value}`) ); } }); } public toBeTypeOf(expectedType: string) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const actualType = typeof value; if (actualType !== expectedType) { throw new Error( this.createErrorMessage(`Expected value{path} to be of type ${expectedType}, but got ${actualType}`) ); } }); } public toEqual(comparisonObject: any) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = plugins.fastDeepEqual(value, comparisonObject); if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to equal ${this.formatValue(comparisonObject)}`) ); } }); } public toMatch(comparisonObject: RegExp) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = comparisonObject.test(value); if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to match regex ${comparisonObject}`) ); } }); } public toBeTrue() { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = typeof value === 'boolean' && value === true; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be true, but got ${this.formatValue(value)}`) ); } }); } public toBeFalse() { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = typeof value === 'boolean' && value === false; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be false, but got ${this.formatValue(value)}`) ); } }); } public toBeInstanceOf(classArg: any) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = value instanceof classArg; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be an instance of ${classArg.name || 'provided class'}`) ); } }); } public toHaveProperty(propertyArg: string, equalsArg?: any) { return this.runCheck(() => { const obj = this.getObjectToTestReference(); if (!obj || !(propertyArg in obj)) { throw new Error( this.createErrorMessage(`Expected value{path} to have property '${propertyArg}'`) ); } if (equalsArg !== undefined) { if (obj[propertyArg] !== equalsArg) { throw new Error( this.createErrorMessage( `Expected property '${propertyArg}' of value{path} to equal ${this.formatValue(equalsArg)}, but got ${this.formatValue(obj[propertyArg])}` ) ); } } }); } public toHaveDeepProperty(properties: string[]) { return this.runCheck(() => { let obj = this.getObjectToTestReference(); let currentPath = ''; for (const property of properties) { if (currentPath) { currentPath += `.${property}`; } else { currentPath = property; } if (!obj || !(property in obj)) { throw new Error( this.createErrorMessage(`Expected value{path} to have property at path '${currentPath}'`) ); } obj = obj[property]; } }); } public toBeGreaterThan(numberArg: number) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = value > numberArg; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be greater than ${numberArg}, but got ${this.formatValue(value)}`) ); } }); } public toBeLessThan(numberArg: number) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = value < numberArg; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be less than ${numberArg}, but got ${this.formatValue(value)}`) ); } }); } public toBeNull() { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = value === null; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be null, but got ${this.formatValue(value)}`) ); } }); } public toBeUndefined() { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = value === undefined; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be undefined, but got ${this.formatValue(value)}`) ); } }); } public toBeNullOrUndefined() { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = value === null || value === undefined; if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be null or undefined, but got ${this.formatValue(value)}`) ); } }); } // Array checks public toContain(itemArg: any) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = Array.isArray(value) && value.includes(itemArg); if (!result) { throw new Error( this.createErrorMessage(`Expected array{path} to contain ${this.formatValue(itemArg)}`) ); } }); } public toBeEmptyArray() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!Array.isArray(value)) { throw new Error( this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof value}`) ); } if (value.length !== 0) { throw new Error( this.createErrorMessage(`Expected array{path} to be empty, but it has ${value.length} elements`) ); } }); } public toContainAll(values: any[]) { return this.runCheck(() => { const arr = this.getObjectToTestReference(); if (!Array.isArray(arr)) { throw new Error( this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof arr}`) ); } const missing = values.filter(v => !arr.includes(v)); if (missing.length > 0) { throw new Error( this.createErrorMessage(`Expected array{path} to contain all values ${this.formatValue(values)}, but missing: ${this.formatValue(missing)}`) ); } }); } public toExclude(value: any) { return this.runCheck(() => { const arr = this.getObjectToTestReference(); if (!Array.isArray(arr)) { throw new Error( this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof arr}`) ); } if (arr.includes(value)) { throw new Error( this.createErrorMessage(`Expected array{path} to exclude ${this.formatValue(value)}, but it was found`) ); } }); } public toStartWith(itemArg: any) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = typeof value === 'string' && value.startsWith(itemArg); if (!result) { throw new Error( this.createErrorMessage(`Expected string{path} to start with "${itemArg}", but got "${value}"`) ); } }); } public toEndWith(itemArg: any) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = typeof value === 'string' && value.endsWith(itemArg); if (!result) { throw new Error( this.createErrorMessage(`Expected string{path} to end with "${itemArg}", but got "${value}"`) ); } }); } public toBeOneOf(values: any[]) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const result = values.includes(value); if (!result) { throw new Error( this.createErrorMessage(`Expected value{path} to be one of ${this.formatValue(values)}, but got ${this.formatValue(value)}`) ); } }); } public toHaveLength(length: number) { return this.runCheck(() => { const obj = this.getObjectToTestReference(); if (typeof obj.length !== 'number') { throw new Error( this.createErrorMessage(`Expected value{path} to have a length property, but it doesn't`) ); } if (obj.length !== length) { throw new Error( this.createErrorMessage(`Expected value{path} to have length ${length}, but got length ${obj.length}`) ); } }); } public toBeCloseTo(value: number, precision = 2) { return this.runCheck(() => { const actual = this.getObjectToTestReference(); const difference = Math.abs(actual - value); const epsilon = Math.pow(10, -precision) / 2; if (difference > epsilon) { throw new Error( this.createErrorMessage(`Expected value{path} to be close to ${value} (within ${epsilon}), but the difference was ${difference}`) ); } }); } public toThrow(expectedError?: any) { return this.runCheck(() => { const fn = this.getObjectToTestReference(); if (typeof fn !== 'function') { throw new Error( this.createErrorMessage(`Expected value{path} to be a function, but got ${typeof fn}`) ); } let thrown = false; let error: any; try { fn(); } catch (e) { thrown = true; error = e; if (expectedError && !(e instanceof expectedError)) { throw new Error( this.createErrorMessage(`Expected function{path} to throw ${expectedError.name}, but it threw ${e.constructor.name}`) ); } } if (!thrown) { throw new Error( this.createErrorMessage(`Expected function{path} to throw, but it didn't throw any error`) ); } }); } public toBeTruthy() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!value) { throw new Error( this.createErrorMessage(`Expected value{path} to be truthy, but got ${this.formatValue(value)}`) ); } }); } public toBeFalsy() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (value) { throw new Error( this.createErrorMessage(`Expected value{path} to be falsy, but got ${this.formatValue(value)}`) ); } }); } public toBeGreaterThanOrEqual(numberArg: number) { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (value < numberArg) { throw new Error( this.createErrorMessage(`Expected value{path} to be greater than or equal to ${numberArg}, but got ${value}`) ); } }); } public toBeLessThanOrEqual(numberArg: number) { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (value > numberArg) { throw new Error( this.createErrorMessage(`Expected value{path} to be less than or equal to ${numberArg}, but got ${value}`) ); } }); } public toMatchObject(objectArg: object) { return this.runCheck(() => { const value = this.getObjectToTestReference(); const matchResult = plugins.fastDeepEqual(value, objectArg); if (!matchResult) { throw new Error( this.createErrorMessage(`Expected value{path} to match ${this.formatValue(objectArg)}`) ); } }); } public toContainEqual(value: any) { return this.runCheck(() => { const arr = this.getObjectToTestReference(); if (!Array.isArray(arr)) { throw new Error( this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof arr}`) ); } const found = arr.some((item: any) => plugins.fastDeepEqual(item, value)); if (!found) { throw new Error( this.createErrorMessage(`Expected array{path} to contain an item equal to ${this.formatValue(value)}`) ); } }); } public toBeArray() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!Array.isArray(value)) { throw new Error( this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof value}`) ); } }); } public toInclude(substring: string) { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (typeof value !== 'string') { throw new Error( this.createErrorMessage(`Expected value{path} to be a string, but got ${typeof value}`) ); } if (!value.includes(substring)) { throw new Error( this.createErrorMessage(`Expected string{path} to include "${substring}", but it doesn't`) ); } }); } public toHaveLengthGreaterThan(length: number) { return this.runCheck(() => { const obj = this.getObjectToTestReference(); if (typeof obj.length !== 'number') { throw new Error( this.createErrorMessage(`Expected value{path} to have a length property, but it doesn't`) ); } if (obj.length <= length) { throw new Error( this.createErrorMessage(`Expected value{path} to have length greater than ${length}, but got length ${obj.length}`) ); } }); } public toHaveLengthLessThan(length: number) { return this.runCheck(() => { const obj = this.getObjectToTestReference(); if (typeof obj.length !== 'number') { throw new Error( this.createErrorMessage(`Expected value{path} to have a length property, but it doesn't`) ); } if (obj.length >= length) { throw new Error( this.createErrorMessage(`Expected value{path} to have length less than ${length}, but got length ${obj.length}`) ); } }); } public toBeDate() { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!(value instanceof Date)) { throw new Error( this.createErrorMessage(`Expected value{path} to be a Date, but got ${value.constructor ? value.constructor.name : typeof value}`) ); } }); } public toBeBeforeDate(date: Date) { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!(value instanceof Date)) { throw new Error( this.createErrorMessage(`Expected value{path} to be a Date, but got ${value.constructor ? value.constructor.name : typeof value}`) ); } if (value >= date) { throw new Error( this.createErrorMessage(`Expected date{path} to be before ${date.toISOString()}, but got ${value.toISOString()}`) ); } }); } public toBeAfterDate(date: Date) { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!(value instanceof Date)) { throw new Error( this.createErrorMessage(`Expected value{path} to be a Date, but got ${value.constructor ? value.constructor.name : typeof value}`) ); } if (value <= date) { throw new Error( this.createErrorMessage(`Expected date{path} to be after ${date.toISOString()}, but got ${value.toISOString()}`) ); } }); } public customAssertion( assertionFunction: (value: any) => boolean, errorMessage: string ) { return this.runCheck(() => { const value = this.getObjectToTestReference(); if (!assertionFunction(value)) { throw new Error(this.failMessage || errorMessage); } }); } /** * Drill into a property */ public property(propertyNameArg: string) { this.propertyDrillDown.push(propertyNameArg); return this; } /** * Drill into an array index */ public arrayItem(indexArg: number) { // Save the number (instead of "[index]") this.propertyDrillDown.push(indexArg); return this; } public log() { console.log(`Current value:`); console.log(JSON.stringify(this.getObjectToTestReference(), null, 2)); console.log(`Path: ${this.formatDrillDown() || '(root)'}`); return this; } }