diff --git a/source/argument-error.ts b/source/argument-error.ts index 8257504c..4e72b97d 100644 --- a/source/argument-error.ts +++ b/source/argument-error.ts @@ -1,17 +1,22 @@ +const wrapStackTrace = (error: ArgumentError, stack: string) => `${error.name}: ${error.message}\n${stack}`; + /** @hidden */ export class ArgumentError extends Error { - constructor(message: string, context: Function) { + readonly validationErrors: ReadonlyMap; + + constructor(message: string, context: Function, stack: string, errors = new Map()) { super(message); + this.name = 'ArgumentError'; + if (Error.captureStackTrace) { - // TODO: Node.js does not preserve the error name in output when using the below, why? Error.captureStackTrace(this, context); } else { - this.stack = (new Error()).stack; + this.stack = wrapStackTrace(this, stack); } - this.name = 'ArgumentError'; + this.validationErrors = errors; } } diff --git a/source/index.ts b/source/index.ts index 1478416f..48587b60 100644 --- a/source/index.ts +++ b/source/index.ts @@ -5,11 +5,12 @@ import {BasePredicate, isPredicate} from './predicates/base-predicate'; import modifiers, {Modifiers} from './modifiers'; import predicates, {Predicates} from './predicates'; import test from './test'; +import {generateStackTrace} from './utils/generate-stack'; /** @hidden */ -export type Main = (value: T, label: string | Function, predicate: BasePredicate) => void; +export type Main = (value: T, label: string | Function, predicate: BasePredicate, stack: string) => void; // Extends is only necessary for the generated documentation to be cleaner. The loaders below infer the correct type. export interface Ow extends Modifiers, Predicates { @@ -61,6 +62,8 @@ export interface ReusableValidator { } const ow = (value: T, labelOrPredicate: unknown, predicate?: BasePredicate) => { + const stack = generateStackTrace(); + if (!isPredicate(labelOrPredicate) && typeof labelOrPredicate !== 'string') { throw new TypeError(`Expected second argument to be a predicate or a string, got \`${typeof labelOrPredicate}\``); } @@ -69,12 +72,12 @@ const ow = (value: T, labelOrPredicate: unknown, predicate?: BasePredicate // If the second argument is a predicate, infer the label const stackFrames = callsites(); - test(value, () => inferLabel(stackFrames), labelOrPredicate); + test(value, () => inferLabel(stackFrames), labelOrPredicate, stack); return; } - test(value, labelOrPredicate, predicate as BasePredicate); + test(value, labelOrPredicate, predicate as BasePredicate, stack); }; Object.defineProperties(ow, { @@ -90,15 +93,17 @@ Object.defineProperties(ow, { }, create: { value: (labelOrPredicate: BasePredicate | string | undefined, predicate?: BasePredicate) => (value: T, label?: string) => { + const stack = generateStackTrace(); + if (isPredicate(labelOrPredicate)) { const stackFrames = callsites(); - test(value, label ?? (() => inferLabel(stackFrames)), labelOrPredicate); + test(value, label ?? (() => inferLabel(stackFrames)), labelOrPredicate, stack); return; } - test(value, label ?? (labelOrPredicate as string), predicate as BasePredicate); + test(value, label ?? (labelOrPredicate as string), predicate as BasePredicate, stack); } } }); diff --git a/source/predicates/any.ts b/source/predicates/any.ts index b5633784..6a0ebb7c 100644 --- a/source/predicates/any.ts +++ b/source/predicates/any.ts @@ -2,6 +2,7 @@ import {ArgumentError} from '../argument-error'; import {BasePredicate, testSymbol} from './base-predicate'; import {PredicateOptions} from './predicate'; import {Main} from '..'; +import {generateArgumentErrorMessage} from '../utils/generate-argument-error-message'; /** @hidden @@ -12,24 +13,47 @@ export class AnyPredicate implements BasePredicate { private readonly options: PredicateOptions = {} ) {} - [testSymbol](value: T, main: Main, label: string | Function): asserts value { - const errors = [ - 'Any predicate failed with the following errors:' - ]; + [testSymbol](value: T, main: Main, label: string | Function, stack: string): asserts value { + const errors = new Map(); for (const predicate of this.predicates) { try { - main(value, label, predicate); + main(value, label, predicate, stack); return; } catch (error: unknown) { if (value === undefined && this.options.optional === true) { return; } - errors.push(`- ${(error as Error).message}`); + // If we received an ArgumentError, then.. + if (error instanceof ArgumentError) { + // Iterate through every error reported. + for (const [key, value] of error.validationErrors.entries()) { + // Get the current errors set, if any. + const alreadyPresent = errors.get(key); + + // If they are present already, create a unique set with both current and new values. + if (alreadyPresent) { + errors.set(key, [...new Set([...alreadyPresent, ...value])]); + } else { + // Add the errors found as is to the map. + errors.set(key, value); + } + } + } } } - throw new ArgumentError(errors.join('\n'), main); + if (errors.size > 0) { + // Generate the `error.message` property. + const message = generateArgumentErrorMessage(errors, true); + + throw new ArgumentError( + `Any predicate failed with the following errors:\n${message}`, + main, + stack, + errors + ); + } } } diff --git a/source/predicates/base-predicate.ts b/source/predicates/base-predicate.ts index e9c584ad..8514044b 100644 --- a/source/predicates/base-predicate.ts +++ b/source/predicates/base-predicate.ts @@ -14,5 +14,5 @@ export const isPredicate = (value: unknown): value is BasePredicate => Boolean(( @hidden */ export interface BasePredicate { - [testSymbol](value: T, main: Main, label: string | Function): void; + [testSymbol](value: T, main: Main, label: string | Function, stack: string): void; } diff --git a/source/predicates/predicate.ts b/source/predicates/predicate.ts index b0f447c0..eebdbb12 100644 --- a/source/predicates/predicate.ts +++ b/source/predicates/predicate.ts @@ -3,6 +3,7 @@ import {ArgumentError} from '../argument-error'; import {not} from '../operators/not'; import {BasePredicate, testSymbol} from './base-predicate'; import {Main} from '..'; +import {generateArgumentErrorMessage} from '../utils/generate-argument-error-message'; /** Function executed when the provided validation fails. @@ -95,30 +96,59 @@ export class Predicate implements BasePredicate { /** @hidden */ - [testSymbol](value: T, main: Main, label: string | Function): asserts value is T { + [testSymbol](value: T, main: Main, label: string | Function, stack: string): asserts value is T { + // Create a map of labels -> received errors. + const errors = new Map(); + for (const {validator, message} of this.context.validators) { if (this.options.optional === true && value === undefined) { continue; } - const result = validator(value); + let result: unknown; + + try { + result = validator(value); + } catch (error: unknown) { + // Any errors caught means validators couldn't process the input. + result = error; + } if (result === true) { continue; } - let label2 = label; + const label2 = is.function_(label) ? label() : label; - if (typeof label === 'function') { - label2 = label(); - } - - label2 = label2 ? + const label_ = label2 ? `${this.type} \`${label2}\`` : this.type; - // TODO: Modify the stack output to show the original `ow()` call instead of this `throw` statement - throw new ArgumentError(message(value, label2, result), main); + const mapKey = label2 || this.type; + + // Get the current errors encountered for this label. + const currentErrors = errors.get(mapKey); + // Pre-generate the error message that will be reported to the user. + const errorMessage = message(value, label_, result); + + // If we already have any errors for this label. + if (currentErrors) { + // If we don't already have this error logged, add it. + if (!currentErrors.includes(errorMessage)) { + currentErrors.push(errorMessage); + } + } else { + // Set this label and error in the full map. + errors.set(mapKey, [errorMessage]); + } + } + + // If we have any errors to report, throw. + if (errors.size > 0) { + // Generate the `error.message` property. + const message = generateArgumentErrorMessage(errors); + + throw new ArgumentError(message, main, stack, errors); } } diff --git a/source/test.ts b/source/test.ts index 64795d70..699d5c4f 100644 --- a/source/test.ts +++ b/source/test.ts @@ -9,6 +9,6 @@ Validate the value against the provided predicate. @param label - Label which should be used in error messages. @param predicate - Predicate to test to value against. */ -export default function test(value: T, label: string | Function, predicate: BasePredicate) { - predicate[testSymbol](value, test, label); +export default function test(value: T, label: string | Function, predicate: BasePredicate, stack: string) { + predicate[testSymbol](value, test, label, stack); } diff --git a/source/utils/generate-argument-error-message.ts b/source/utils/generate-argument-error-message.ts new file mode 100644 index 00000000..dad41c9e --- /dev/null +++ b/source/utils/generate-argument-error-message.ts @@ -0,0 +1,44 @@ +/** +Generates a complete message from all errors generated by predicates. + +@param errors - The errors generated by the predicates. +@param isAny - If this function is called from the any argument. +@hidden +*/ +export const generateArgumentErrorMessage = (errors: Map, isAny = false) => { + const message = []; + + const errorArray = [...errors.values()]; + + const anyErrorWithoutOneItemOnly = errorArray.some(array => array.length !== 1); + + // If only one error "key" is present, enumerate all of those errors only. + if (errors.size === 1) { + const returnedErrors = errorArray[0]!; + + if (!isAny && returnedErrors.length === 1) { + return returnedErrors[0]!; + } + + for (const entry of returnedErrors) { + message.push(`${isAny ? ' - ' : ''}${entry}`); + } + + return message.join('\n'); + } + + // If every predicate returns just one error, enumerate them as is. + if (!anyErrorWithoutOneItemOnly) { + return errorArray.map(([item]) => ` - ${item}`).join('\n'); + } + + // Else, iterate through all the errors and enumerate them. + for (const [key, value] of errors) { + message.push(`Errors from the "${key}" predicate:`); + for (const entry of value) { + message.push(` - ${entry}`); + } + } + + return message.join('\n'); +}; diff --git a/source/utils/generate-stack.ts b/source/utils/generate-stack.ts new file mode 100644 index 00000000..dcd09f03 --- /dev/null +++ b/source/utils/generate-stack.ts @@ -0,0 +1,10 @@ +/** +Generates a useful stacktrace that points to the user's code where the error happened on platforms without the `Error.captureStackTrace()` method. + +@hidden +*/ +export const generateStackTrace = () => { + const stack = new RangeError('INTERNAL_OW_ERROR').stack!; + + return stack; +}; diff --git a/source/utils/match-shape.ts b/source/utils/match-shape.ts index 33d3ba49..5c624ef6 100644 --- a/source/utils/match-shape.ts +++ b/source/utils/match-shape.ts @@ -2,6 +2,7 @@ import is from '@sindresorhus/is'; import test from '../test'; import {isPredicate} from '../predicates/base-predicate'; import {BasePredicate} from '..'; +import {generateStackTrace} from './generate-stack'; // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style export interface Shape { @@ -44,12 +45,13 @@ Test if the `object` matches the `shape` partially. @param parent - Name of the parent property. */ export function partial(object: Record, shape: Shape, parent?: string): boolean | string { + const stack = generateStackTrace(); try { for (const key of Object.keys(shape)) { const label = parent ? `${parent}.${key}` : key; if (isPredicate(shape[key])) { - test(object[key], label, shape[key] as BasePredicate); + test(object[key], label, shape[key] as BasePredicate, stack); } else if (is.plainObject(shape[key])) { const result = partial(object[key], shape[key] as Shape, label); @@ -75,6 +77,7 @@ Test if the `object` matches the `shape` exactly. @param parent - Name of the parent property. */ export function exact(object: Record, shape: Shape, parent?: string): boolean | string { + const stack = generateStackTrace(); try { const objectKeys = new Set(Object.keys(object)); @@ -84,7 +87,7 @@ export function exact(object: Record, shape: Shape, parent?: string const label = parent ? `${parent}.${key}` : key; if (isPredicate(shape[key])) { - test(object[key], label, shape[key] as BasePredicate); + test(object[key], label, shape[key] as BasePredicate, stack); } else if (is.plainObject(shape[key])) { if (!Object.prototype.hasOwnProperty.call(object, key)) { return `Expected \`${label}\` to exist`; diff --git a/test/any-multiple-errors.ts b/test/any-multiple-errors.ts new file mode 100644 index 00000000..8842f602 --- /dev/null +++ b/test/any-multiple-errors.ts @@ -0,0 +1,124 @@ +import test from 'ava'; +import ow, {ArgumentError, BasePredicate, Main} from '../source'; +import {testSymbol} from '../source/predicates/base-predicate'; +import {createAnyError, createAnyPredicateError} from './fixtures/create-error'; + +test('any predicate', t => { + // #region Tests line 49 of predicates/any.ts and lines 16-21 of utils/generate-argument-error-message.ts + const error_1 = t.throws(() => { + ow(5 as any, ow.any(ow.string)); + }, createAnyError('Expected argument to be of type `string` but received type `number`')); + + t.is(error_1.validationErrors.size, 1, 'There should be only one error'); + + const reportedError_1_1 = error_1.validationErrors.get('string')!; + + t.is(reportedError_1_1.length, 1, 'There should be only one element'); + t.deepEqual(reportedError_1_1, [ + 'Expected argument to be of type `string` but received type `number`' + ]); + + // #endregion + + // #region Tests line 49 of predicates/any.ts and lines 36-41 of utils/generate-argument-error-message.ts + const error_2 = t.throws(() => { + ow(21 as any, ow.any( + ow.string.url.minLength(24), + ow.number.greaterThan(42) + )); + }, createAnyPredicateError([ + 'string', + [ + 'Expected argument to be of type `string` but received type `number`', + 'Expected string to be a URL, got `21`', + 'Expected string to have a minimum length of `24`, got `21`' + ] + ], [ + 'number', + ['Expected number to be greater than 42, got 21'] + ])); + + t.is(error_2.validationErrors.size, 2, 'There should be two types of errors reported'); + + const reportedError_2_1 = error_2.validationErrors.get('string')!; + const reportedError_2_2 = error_2.validationErrors.get('number')!; + + t.is(reportedError_2_1.length, 3, 'There should be three errors reported for the string predicate'); + t.is(reportedError_2_2.length, 1, 'There should be one error reported for the number predicate'); + + t.deepEqual(reportedError_2_1, [ + 'Expected argument to be of type `string` but received type `number`', + 'Expected string to be a URL, got `21`', + 'Expected string to have a minimum length of `24`, got `21`' + ]); + + t.deepEqual(reportedError_2_2, [ + 'Expected number to be greater than 42, got 21' + ]); + + // #endregion + + // #region Tests line 49 of predicates/any.ts and lines 31-33 of utils/generate-argument-error-message.ts + const error_3 = t.throws(() => { + ow(null as any, ow.any( + ow.string, + ow.number + )); + }, createAnyError( + 'Expected argument to be of type `string` but received type `null`', + 'Expected argument to be of type `number` but received type `null`' + )); + + t.is(error_3.validationErrors.size, 2, 'There should be two types of errors reported'); + + const reportedError_3_1 = error_3.validationErrors.get('string')!; + const reportedError_3_2 = error_3.validationErrors.get('number')!; + + t.is(reportedError_3_1.length, 1, 'There should be one error reported for the string predicate'); + t.is(reportedError_3_2.length, 1, 'There should be one error reported for the number predicate'); + + t.deepEqual(reportedError_3_1, [ + 'Expected argument to be of type `string` but received type `null`' + ]); + t.deepEqual(reportedError_3_2, [ + 'Expected argument to be of type `number` but received type `null`' + ]); + + const error_4 = t.throws(() => { + ow(21 as any, ow.any( + ow.string.url.minLength(21), + ow.string.url.minLength(42) + )); + }, createAnyError( + 'Expected argument to be of type `string` but received type `number`', + 'Expected string to be a URL, got `21`', + 'Expected string to have a minimum length of `21`, got `21`', + 'Expected string to have a minimum length of `42`, got `21`' + )); + + t.is(error_4.validationErrors.size, 1, 'There should be one type of error reported'); + + const reportedError_4_1 = error_4.validationErrors.get('string')!; + + t.is(reportedError_4_1.length, 4, 'There should be four errors reported for the string predicate'); + + t.deepEqual(reportedError_4_1, [ + 'Expected argument to be of type `string` but received type `number`', + 'Expected string to be a URL, got `21`', + 'Expected string to have a minimum length of `21`, got `21`', + 'Expected string to have a minimum length of `42`, got `21`' + ]); + // #endregion + + // #region Tests line 47,65 + class CustomPredicate implements BasePredicate { + [testSymbol](_value: string, _main: Main, _label: string | Function, _stack: string): void { + throw new Error('Custom error.'); + } + } + + t.notThrows(() => { + ow(5 as any, ow.any(new CustomPredicate())); + }, 'It should not throw when the thrown error from the predicate is not an ArgumentError'); + // #endregion +}); diff --git a/test/custom-message.ts b/test/custom-message.ts index f4b5beca..d42d938b 100644 --- a/test/custom-message.ts +++ b/test/custom-message.ts @@ -1,5 +1,5 @@ import test from 'ava'; -import ow, {Predicate} from '../source'; +import ow, {ArgumentError, Predicate} from '../source'; class CustomPredicate extends Predicate { constructor() { @@ -27,9 +27,23 @@ test('custom validate message', t => { ow('🌈', ow.string.minLength(5).message((value, label) => `Expected ${label}, to be have a minimum length of 5, got \`${value}\``)); }, 'Expected string, to be have a minimum length of 5, got `🌈`'); - t.throws(() => { + const error = t.throws(() => { ow('1234', ow.string.minLength(5).message((value, label) => `Expected ${label}, to be have a minimum length of 5, got \`${value}\``).url.message('This is no url')); - }, 'Expected string, to be have a minimum length of 5, got `1234`'); + }, [ + 'Expected string, to be have a minimum length of 5, got `1234`', + 'This is no url' + ].join('\n')); + + t.is(error.validationErrors.size, 1, 'There is one item in the `validationErrors` map'); + t.true(error.validationErrors.has('string'), 'Validation errors map has key `string`'); + + const result1_ = error.validationErrors.get('string')!; + + t.is(result1_.length, 2, 'There are two reported errors for this input'); + t.deepEqual(result1_, [ + 'Expected string, to be have a minimum length of 5, got `1234`', + 'This is no url' + ], 'There is an error for the string length, and one for invalid URL'); t.throws(() => { ow('12345', ow.string.minLength(5).message((value, label) => `Expected ${label}, to be have a minimum length of 5, got \`${value}\``).url.message('This is no url')); diff --git a/test/fixtures/create-error.ts b/test/fixtures/create-error.ts index a4f83db9..44852631 100644 --- a/test/fixtures/create-error.ts +++ b/test/fixtures/create-error.ts @@ -4,6 +4,21 @@ export const createAnyError = (...errors: readonly string[]) => { return [ 'Any predicate failed with the following errors:', - ...errors.map(error => `- ${error}`) + ...errors.map(error => ` - ${error}`) + ].join('\n'); +}; + +/** +@hidden +*/ +export const createAnyPredicateError = (...allErrors: Array<[predicateName: string, errors: string[]]>) => { + return [ + 'Any predicate failed with the following errors:', + ...allErrors.map(([predicateName, errors]) => { + return [ + `Errors from the "${predicateName}" predicate:`, + ...errors.map(entry => ` - ${entry}`) + ].join('\n'); + }) ].join('\n'); }; diff --git a/test/object.ts b/test/object.ts index 885a475c..e09b7a4b 100644 --- a/test/object.ts +++ b/test/object.ts @@ -89,7 +89,7 @@ test('object.valuesOfType', t => { t.throws(() => { ow(['🦄', true, 1], ow.object.valuesOfType(ow.any(ow.string, ow.boolean))); - }, '(object) Any predicate failed with the following errors:\n- Expected argument to be of type `string` but received type `number`\n- Expected argument to be of type `boolean` but received type `number`'); + }, '(object) Any predicate failed with the following errors:\n - Expected argument to be of type `string` but received type `number`\n - Expected argument to be of type `boolean` but received type `number`'); }); test('object.valuesOfTypeDeep', t => { diff --git a/test/test.ts b/test/test.ts index 5fd5dcc0..7210b911 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1,5 +1,5 @@ import test from 'ava'; -import ow from '../source'; +import ow, {ArgumentError} from '../source'; import {createAnyError} from './fixtures/create-error'; test('not', t => { @@ -300,9 +300,23 @@ test('reusable validator', t => { checkUsername(value); }, 'Expected string to have a minimum length of `3`, got `x`'); - t.throws(() => { + const error = t.throws(() => { checkUsername(5 as any); - }, 'Expected argument to be of type `string` but received type `number`'); + }, [ + 'Expected argument to be of type `string` but received type `number`', + 'Expected string to have a minimum length of `3`, got `5`' + ].join('\n')); + + t.is(error.validationErrors.size, 1, 'There is one item in the `validationErrors` map'); + t.true(error.validationErrors.has('string'), 'Validation errors map has key `string`'); + + const result1_ = error.validationErrors.get('string')!; + + t.is(result1_.length, 2, 'There are two reported errors for this input'); + t.deepEqual(result1_, [ + 'Expected argument to be of type `string` but received type `number`', + 'Expected string to have a minimum length of `3`, got `5`' + ], 'There is an error for invalid input type, and one for minimum length not being satisfied'); }); test('reusable validator called with label', t => { @@ -327,9 +341,23 @@ test('reusable validator called with label', t => { checkUsername(value, label); }, 'Expected string `bar` to have a minimum length of `3`, got `x`'); - t.throws(() => { + const error = t.throws(() => { checkUsername(5 as any, label); - }, 'Expected `bar` to be of type `string` but received type `number`'); + }, [ + 'Expected `bar` to be of type `string` but received type `number`', + 'Expected string `bar` to have a minimum length of `3`, got `5`' + ].join('\n')); + + t.is(error.validationErrors.size, 1, 'There is one item in the `validationErrors` map'); + t.true(error.validationErrors.has('bar'), 'Validation errors map has key `bar`'); + + const result1_ = error.validationErrors.get('bar')!; + + t.is(result1_.length, 2, 'There are two reported errors for this input'); + t.deepEqual(result1_, [ + 'Expected `bar` to be of type `string` but received type `number`', + 'Expected string `bar` to have a minimum length of `3`, got `5`' + ], 'There is an error for invalid input type, and one for minimum length not being satisfied'); }); test('reusable validator with label', t => { @@ -347,9 +375,23 @@ test('reusable validator with label', t => { checkUsername('fo'); }, 'Expected string `foo` to have a minimum length of `3`, got `fo`'); - t.throws(() => { + const error = t.throws(() => { checkUsername(5 as any); - }, 'Expected `foo` to be of type `string` but received type `number`'); + }, [ + 'Expected `foo` to be of type `string` but received type `number`', + 'Expected string `foo` to have a minimum length of `3`, got `5`' + ].join('\n')); + + t.is(error.validationErrors.size, 1, 'There is one item in the `validationErrors` map'); + t.true(error.validationErrors.has('foo'), 'Validation errors map has key `foo`'); + + const result1_ = error.validationErrors.get('foo')!; + + t.is(result1_.length, 2, 'There are two reported errors for this input'); + t.deepEqual(result1_, [ + 'Expected `foo` to be of type `string` but received type `number`', + 'Expected string `foo` to have a minimum length of `3`, got `5`' + ], 'There is an error for invalid input type, and one for minimum length not being satisfied'); }); test('reusable validator with label called with label', t => { @@ -369,9 +411,23 @@ test('reusable validator with label called with label', t => { checkUsername('fo', label); }, 'Expected string `bar` to have a minimum length of `3`, got `fo`'); - t.throws(() => { + const error = t.throws(() => { checkUsername(5 as any, label); - }, 'Expected `bar` to be of type `string` but received type `number`'); + }, [ + 'Expected `bar` to be of type `string` but received type `number`', + 'Expected string `bar` to have a minimum length of `3`, got `5`' + ].join('\n')); + + t.is(error.validationErrors.size, 1, 'There is one item in the `validationErrors` map'); + t.true(error.validationErrors.has('bar'), 'Validation errors map has key `bar`'); + + const result1_ = error.validationErrors.get('bar')!; + + t.is(result1_.length, 2, 'There are two reported errors for this input'); + t.deepEqual(result1_, [ + 'Expected `bar` to be of type `string` but received type `number`', + 'Expected string `bar` to have a minimum length of `3`, got `5`' + ], 'There is an error for invalid input type, and one for minimum length not being satisfied'); }); test('any-reusable validator', t => { @@ -396,7 +452,8 @@ test('any-reusable validator', t => { checkUsername(5 as any); }, createAnyError( 'Expected argument to be of type `string` but received type `number`', - 'Expected argument to be of type `string` but received type `number`' + 'Expected string to include `.`, got `5`', + 'Expected string to have a minimum length of `3`, got `5`' )); }); @@ -414,4 +471,45 @@ test('custom validation function', t => { validator: value === '🌈' }))); }, '(string `unicorn`) Should be `🌈`'); + + t.notThrows(() => { + ow('🦄', 'unicorn', ow.string.validate(value => ({ + message: label => `Expected ${label} to be '🦄', got \`${value}\``, + validator: value === '🦄' + }))); + }); +}); + +test('ow without valid arguments', t => { + t.throws(() => { + ow(5, {} as any); + }, 'Expected second argument to be a predicate or a string, got `object`'); +}); + +// This test is to cover all paths of source/utils/generate-stacks.ts +test('ow without Error.captureStackTrace', t => { + const originalErrorStackTrace = Error.captureStackTrace; + // @ts-expect-error We are manually overwriting this + Error.captureStackTrace = null; + + t.throws(() => { + ow('owo', ow.string.equals('OwO')); + }, 'Expected string to be equal to `OwO`, got `owo`'); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + Object.defineProperty(require('../source/utils/node/is-node'), 'default', { + value: false + }); + + t.throws(() => { + ow('owo', ow.string.equals('OwO')); + }, 'Expected string to be equal to `OwO`, got `owo`'); + + // Re-set the properties back to their default values + Error.captureStackTrace = originalErrorStackTrace; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + Object.defineProperty(require('../source/utils/node/is-node'), 'default', { + value: true + }); });