diff --git a/API.md b/API.md index d1cc06c..73e6605 100644 --- a/API.md +++ b/API.md @@ -79,9 +79,10 @@ async function register(address, name) { Throws the error passed if it matches any of the specified rules where: - `err` - the error. - `type` - a single item or an array of items of: - - An error constructor (e.g. `SyntaxError`). + - An error constructor (e.g. `SyntaxError`) - matches error created from constructor or any subclass. - `'system'` - matches any languange native error or node assertions. - - `'boom'` - matches [**boom**](https://github.com/hapijs/boom) errors. + - `'abort'` - matches an `AbortError`, as generated on an `AbortSignal` by `AbortController.abort()`. + - `'timeout'` - matches a `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. - an object where each property is compared with the error and must match the error property value. All the properties in the object must match the error but do not need to include all the error properties. @@ -90,29 +91,23 @@ Throws the error passed if it matches any of the specified rules where: - `override` - an error used to override `err` when `err` matches. If used with `decorate`, the `override` object is modified. - `return` - if `true`, the error is returned instead of thrown. Defaults to `false`. + - `strict` - if `true`, any non-`Error` `err` throws a `TypeError`. Defaults to `true`. + - `signal` - an `AbortSignal`. Throws `signal.reason` if it has already been aborted. -### `ignore(err, types, [options])` +### `assert(err, types, [options])` The opposite action of `rethrow()`. Ignores any errors matching the specified `types`. Any error not matching is thrown after applying the `options`. ### `background(operation, [action], [types], [options])` -Awaits for the value to resolve in the background and then apply either the `rethrow()` or `ignore()` +Awaits for the value to resolve in the background and then apply either the `rethrow()` or `assert()` actions where: - `operation` - a function, promise, or value that is `await`ed on inside a `try...catch` and any error thrown processed by the `action` rule. -- `action` - one of `'rethrow'` or `'ignore'`. Defaults to `'rethrow'`. -- `types` - same as the `types` argument passed to `rethrow()` or `ignore()`. Defaults to `'system'`. -- `options` - same as the `options` argument passed to `rethrow()` or `ignore()`. - -### `isBoom(err)` - -Returns `true` when `err` is a [**boom**](https://github.com/hapijs/boom) error. - -### `isError(err)` - -Returns `true` when `err` is an error. +- `action` - one of `'rethrow'` or `'assert'`. Defaults to `'rethrow'`. +- `types` - same as the `types` argument passed to `rethrow()` or `assert()`. Defaults to `'system'`. +- `options` - same as the `options` argument passed to `rethrow()` or `assert()`. ### `isSystem(err)` @@ -124,3 +119,18 @@ Return `true` when `err` is one of: - `TypeError` - `URIError` - Node's `AssertionError` +- Hoek's `AssertError` + +### `isAbort(err)` + +Returns `true` when `err` is an `AbortError`, as generated by `AbortSignal.abort()`. + +Note that unlike other errors, `AbortError` cannot be considered a class in itself. +The best way to create a custom `AbortError` is with `new DOMException(message, 'AbortError')`. + +### `isTimeout(err)` + +Returns `true` when `err` is a `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. + +Note that unlike other errors, `TimeoutError` cannot be considered a class in itself. +The best way to create a custom `TimeoutError` is with `new DOMException(message, 'TimeoutError')`. diff --git a/lib/assertions.js b/lib/assertions.js new file mode 100644 index 0000000..e07a576 --- /dev/null +++ b/lib/assertions.js @@ -0,0 +1,17 @@ +'use strict'; + +const Assert = require('assert'); + +const AssertError = require('@hapi/hoek/assertError'); + + +module.exports = [ + + // Node + + Assert.AssertionError, + + // Hoek + + AssertError +]; diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..1d0def0 --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,135 @@ +/** + * Possible type matching rule. One of: + * - An error constructor (e.g. `SyntaxError`). + * - `'system'` - matches any language native error or node assertions. + * - `'abort'` - matches an `AbortError`, as generated on an `AbortSignal` by `AbortController.abort()`. + * - `'timeout'` - matches a `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. + * - an object where each property is compared with the error and must match the error property + * value. All the properties in the object must match the error but do not need to include all + * the error properties. + */ +type TypeRule = 'system' | 'abort' | 'timeout' | ErrorConstructor | { [key: PropertyKey]: any }; + +type Decoration = { [key: string]: any }; + +type TypeMapping = + T extends 'abort' ? Error & { name: 'AbortError' } : + T extends 'timeout' ? Error & { name: 'TimeoutError' } : + T extends abstract new (...args: any) => Error ? InstanceType : + Error; + +interface BounceOptions { + + /** + * An object which is assigned to the `err`, copying the properties onto the error. + */ + decorate?: { [key: string]: any } | undefined; + + /** + * An error used to override `err` when `err` matches. + * If used with `decorate`, the `override` object is modified. + */ + override?: Error | undefined; + + /** + * If `true`, the error is returned instead of thrown. Defaults to `false`. + */ + return?: boolean | undefined; + + /** + * If `true`, any non-`Error` `err` throws a`TypeError`. Defaults to `true`. + */ + strict?: boolean | undefined; + + /** + * An `AbortSignal`. Throws `signal.reason` if it has already been aborted. + */ + signal?: AbortSignal | undefined; +} + +/** + * Throws the error passed if it matches any of the specified rules. + * + * @param err - the error object to test. + * @param type - a single {@link TypeRule `TypeRule`} or an array of {@link TypeRule `TypeRule`}. + * @param options - optional {@link BounceOptions settings}. + * + * @returns possibly an `Error` depending on value of the `return` and `decorate` options. + */ +export function rethrow(err: any, types: TypeRule | TypeRule[], options: BounceOptions & { return: true, decorate: D, override: E }): (E & D) | undefined; +export function rethrow(err: T, types: TypeRule | TypeRule[], options: BounceOptions & { return: true, decorate: D }): (T & D) | undefined; +export function rethrow(err: any, types: TypeRule | TypeRule[], options: BounceOptions & { return: true, override: E }): E | undefined; +export function rethrow(err: T, types: TypeRule | TypeRule[], options: BounceOptions & { return: true }): T | undefined; +export function rethrow(err: any, types: TypeRule | TypeRule[], options?: BounceOptions & { strict?: true | undefined }): asserts err is Error; +export function rethrow(err: any, types: TypeRule | TypeRule[], options?: BounceOptions): void; + +/** + * The opposite action of {@link rethrow `rethrow()`}. Ignores any errors matching the specified `types`. + * Any error not matching is thrown after applying the `options`. + * + * @param err - the error object to test. + * @param type - a single {@link TypeRule `TypeRule`} or an array of {@link TypeRule `TypeRule`}. + * @param options - optional {@link BounceOptions settings}. + * + * @returns possibly an `Error` depending on value of the `return` and `decorate` options. + */ +export function assert(err: any, types: TypeRule | TypeRule[], options: BounceOptions & { return: true, decorate: D, override: E }): (E & D) | undefined; +export function assert(err: T, types: TypeRule | TypeRule[], options: BounceOptions & { return: true, decorate: D }): (T & D) | undefined; +export function assert(err: any, types: TypeRule | TypeRule[], options: BounceOptions & { return: true, override: E }): E | undefined; +export function assert(err: T, types: TypeRule | TypeRule[], options: BounceOptions & { return: true }): T | undefined; +export function assert(err: any, types: T, options: { return?: false | undefined }): asserts err is TypeMapping; +export function assert(err: any, types: T): asserts err is TypeMapping; +export function assert(err: any, types: TypeRule | TypeRule[], options: { return?: false | undefined }): asserts err is Error; +export function assert(err: any, types: TypeRule | TypeRule[]): asserts err is Error; +export function assert(err: any, types: TypeRule | TypeRule[], options?: BounceOptions): void; + +type Action = 'rethrow' | 'assert'; + +/** + * Awaits for the value to resolve in the background and then apply either the {@link rethrow `rethrow()`} or {@link rethrow `assert()`} action. + * + * @param operation - a function, promise, or value that is `await`ed on inside a `try...catch` + * and any error thrown processed by the `action` rule. + * @param action - one of `'rethrow'` or `'assert'`. Defaults to `'rethrow'`. + * @param types - same as the `types` argument passed to {@link rethrow `rethrow()`} or {@link rethrow `assert()`}. Defaults to `'system'`. + * @param options - same as the {@link BounceOptions `options`} argument passed to {@link rethrow `rethrow()`} or {@link rethrow `assert()`}. + */ +export function background(operation: any, action?: Action, types?: TypeRule | TypeRule[], options?: BounceOptions & { return: true, override: E, decorate: D }): Promise<(E & D) | undefined>; +export function background(operation: any, action?: Action, types?: TypeRule | TypeRule[], options?: BounceOptions & { return: true, override: E }): Promise; +export function background(operation: any, action?: Action, types?: TypeRule | TypeRule[], options?: BounceOptions & { return: true }): Promise; +export function background(operation: any, action?: Action, types?: TypeRule | TypeRule[], options?: BounceOptions): Promise; + +/** + * Return `true` when `err` is one of: + * - `EvalError` + * - `RangeError` + * - `ReferenceError` + * - `SyntaxError` + * - `TypeError` + * - `URIError` + * - Node's `AssertionError` + * - Hoek's `AssertError` + * + * @param err - Object to test. + */ +export function isSystem(err: unknown): err is TypeMapping<'system'>; + +/** + * Returns `true` when `err` is an `AbortError`, as generated by `AbortSignal.abort()`. + * + * Note that unlike other errors, `AbortError` cannot be considered a class in itself. + * The best way to create a custom `AbortError` is with `new DOMException(message, 'AbortError')`. + * + * @param err - Object to test. + */ +export function isAbort(err: unknown): err is TypeMapping<'abort'>; + +/** + * Returns `true` when `err` is a `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. + * + * Note that unlike other errors, `TimeoutError` cannot be considered a class in itself. + * The best way to create a custom `TimeoutError` is with `new DOMException(message, 'TimeoutError')`. + * + * @param err - Object to test. + */ +export function isTimeout(err: unknown): err is TypeMapping<'timeout'>; diff --git a/lib/index.js b/lib/index.js index 7854ba1..c6c6308 100755 --- a/lib/index.js +++ b/lib/index.js @@ -1,12 +1,12 @@ 'use strict'; -const Assert = require('assert'); +const Contain = require('@hapi/hoek/contain'); -const Boom = require('@hapi/boom'); -const Hoek = require('@hapi/hoek'); +const Assertions = require('./assertions'); const internals = { + symbol: Symbol('SystemError'), system: [ // JavaScript @@ -18,15 +18,33 @@ const internals = { TypeError, URIError, - // Node + ...Assertions + ] +}; - Assert.AssertionError, - // Hoek +internals.slow = (() => { - Hoek.AssertError - ] -}; + // Add hidden property to prototype that all instances will have + + const slow = []; + const defaultHasInstance = Object[Symbol.hasInstance]; + for (const system of internals.system) { + if (system[Symbol.hasInstance] !== defaultHasInstance) { // Skip classes that use custom instanceof checks + slow.push(system); + continue; + } + + Object.defineProperty(system.prototype, internals.symbol, { + configurable: false, + enumerable: false, + writable: false, + value: true + }); + } + + return slow; +})(); exports.rethrow = function (err, types, options = {}) { @@ -35,7 +53,7 @@ exports.rethrow = function (err, types, options = {}) { }; -exports.ignore = function (err, types, options = {}) { +exports.assert = function (err, types, options = {}) { return internals.catch(err, types, options, false); }; @@ -43,10 +61,33 @@ exports.ignore = function (err, types, options = {}) { internals.catch = function (err, types, options, match) { + if (!types) { + throw new TypeError('Missing or invalid "types" argument'); + } + + if (options.signal?.aborted) { + throw options.signal.reason ?? new DOMException('This operation was aborted', 'AbortError'); + } + + const isErrorType = err instanceof Error; + if (!isErrorType) { + if (options.strict !== false) { + throw new TypeError('Argument is not an Error', { cause: err }); + } + + return match ? undefined : internals.matched(err, options); + } + if (internals.match(err, types) !== match) { return; } + return internals.matched(err, options); +}; + + +internals.matched = function (err, options) { + // Error replacement if (options.override) { @@ -78,34 +119,34 @@ exports.background = async function (operation, action = 'rethrow', types = 'sys } } catch (err) { - exports[action](err, types, options); + return exports[action](err, types, options); } }; -exports.isBoom = function (err) { - - return Boom.isBoom(err); -}; - +exports.isSystem = function (err) { -exports.isError = function (err) { + if (!(err instanceof Error)) { + return false; + } - return err instanceof Error; + return internals.isSystemError(err); }; -exports.isSystem = function (err) { +internals.isSystemError = function (err) { + + // 'err' is always instanceof Error - if (!err) { + if (err.isBoom) { // Boom errors can be instanceof system errors return false; } - if (err.isBoom) { - return false; + if (err[internals.symbol] === true) { + return true; } - for (const system of internals.system) { + for (const system of internals.slow) { if (err instanceof system) { return true; } @@ -115,34 +156,70 @@ exports.isSystem = function (err) { }; +internals.isAbortError = function (err) { + + return err.name === 'AbortError'; +}; + + +exports.isAbort = function (err) { + + return err instanceof Error && internals.isAbortError(err); +}; + + +internals.isTimeoutError = function (err) { + + return err.name === 'TimeoutError'; +}; + + +exports.isTimeout = function (err) { + + return err instanceof Error && err.name === 'TimeoutError'; +}; + + internals.rules = { - system: exports.isSystem, - boom: exports.isBoom + system: internals.isSystemError, + abort: internals.isAbortError, + timeout: internals.isTimeoutError }; internals.match = function (err, types) { - if (!types) { - return true; + if (!Array.isArray(types)) { + return internals._match(err, types); } - types = Array.isArray(types) ? types : [types]; for (const type of types) { - if (typeof type === 'string') { - if (internals.rules[type](err)) { - return true; - } - } - else if (typeof type === 'object') { - if (Hoek.contain(err, type, { deep: true, part: true })) { - return true; - } - } - else if (err instanceof type) { + if (internals._match(err, type)) { return true; } } return false; }; + + +internals._match = function (err, type) { + + try { + switch (typeof type) { + case 'string': + return internals.rules[type](err); + + case 'object': + return Contain(err, type, { deep: true, part: true }); + + case 'function': + return err instanceof type; + } + } + catch (err) { + var error = err; + } + + throw new TypeError('Invalid "types" argument', error ? { reason: error } : undefined); +}; diff --git a/package.json b/package.json index 8e0b078..b9f1d53 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "@hapi/bounce", "description": "Selective error catching and rewrite rules", - "version": "3.0.1", + "version": "3.0.2", "repository": "git://github.com/hapijs/bounce", "main": "lib/index.js", + "types": "lib/index.d.ts", "files": [ "lib" ], @@ -12,16 +13,18 @@ "catch" ], "dependencies": { - "@hapi/boom": "^10.0.1", "@hapi/hoek": "^11.0.4" }, "devDependencies": { + "@hapi/boom": "^10.0.1", "@hapi/code": "^9.0.0", "@hapi/eslint-plugin": "^7.0.0", - "@hapi/lab": "^26.0.0" + "@hapi/lab": "^26.0.0", + "@types/node": "^18.19.57", + "typescript": "~5.6.3" }, "scripts": { - "test": "lab -a @hapi/code -t 100 -L", + "test": "lab -a @hapi/code -t 100 -L -Y", "test-cov-html": "lab -a @hapi/code -r html -o coverage.html -L" }, "license": "BSD-3-Clause" diff --git a/test/index.js b/test/index.js index 3f28ea4..253d763 100755 --- a/test/index.js +++ b/test/index.js @@ -1,5 +1,20 @@ 'use strict'; +// Add custom assertion to assertion list. Must be done before requiring Bounce + +class HasInstanceCheckError { + + static [Symbol.hasInstance](instance) { + + return instance.message === 'custom-error'; + } +} + +const Assertions = require('../lib/assertions'); + +Assertions.push(HasInstanceCheckError); + + const Assert = require('assert'); const Code = require('@hapi/code'); @@ -20,7 +35,7 @@ describe('Bounce', () => { describe('rethrow()', () => { - it('rethrows all errors', () => { + it('throws TypeError on missing types', () => { const orig = new Error('Something'); @@ -31,8 +46,17 @@ describe('Bounce', () => { var error = err; } - expect(error).to.shallow.equal(orig); - expect(error).to.be.an.error('Something'); + expect(error).to.not.shallow.equal(orig); + expect(error).to.be.an.error(TypeError); + }); + + it('throws TypeError on unhandled types', () => { + + expect(() => Bounce.rethrow(new Error(), false)).to.throw(TypeError, 'Missing or invalid "types" argument'); + expect(() => Bounce.rethrow(new Error(), 'invalid')).to.throw(TypeError, 'Invalid "types" argument'); + expect(() => Bounce.rethrow(new Error(), ['invalid'])).to.throw(TypeError, 'Invalid "types" argument'); + expect(() => Bounce.rethrow(new Error(), 123)).to.throw(TypeError, 'Invalid "types" argument'); + expect(() => Bounce.rethrow(new Error(), () => 123)).to.throw(TypeError, 'Invalid "types" argument'); }); it('rethrows only system errors', () => { @@ -59,7 +83,7 @@ describe('Bounce', () => { it('rethrows only boom errors', () => { try { - Bounce.rethrow(new Error('Something'), 'boom'); + Bounce.rethrow(new Error('Something'), Boom.Boom); } catch (err) { var error1 = err; @@ -68,7 +92,7 @@ describe('Bounce', () => { expect(error1).to.not.exist(); try { - Bounce.rethrow(Boom.badRequest('Something'), 'boom'); + Bounce.rethrow(Boom.badRequest('Something'), Boom.Boom); } catch (err) { var error2 = err; @@ -80,7 +104,7 @@ describe('Bounce', () => { it('rethrows only boom/system errors', () => { try { - Bounce.rethrow(new Error('Something'), ['boom', 'system']); + Bounce.rethrow(new Error('Something'), [Boom.Boom, 'system']); } catch (err) { var error1 = err; @@ -89,7 +113,7 @@ describe('Bounce', () => { expect(error1).to.not.exist(); try { - Bounce.rethrow(Boom.badRequest('Something'), ['boom', 'system']); + Bounce.rethrow(Boom.badRequest('Something'), [Boom.Boom, 'system']); } catch (err) { var error2 = err; @@ -98,7 +122,7 @@ describe('Bounce', () => { expect(error2).to.be.an.error('Something'); try { - Bounce.rethrow(new SyntaxError('Something'), ['boom', 'system']); + Bounce.rethrow(new SyntaxError('Something'), [Boom.Boom, 'system']); } catch (err) { var error3 = err; @@ -107,6 +131,52 @@ describe('Bounce', () => { expect(error3).to.be.an.error('Something', SyntaxError); }); + it('rethrows only abort errors', () => { + + try { + Bounce.rethrow(new Error('Something'), 'abort'); + } + catch (err) { + var error1 = err; + } + + expect(error1).to.not.exist(); + + try { + Bounce.rethrow(AbortSignal.abort().reason, 'abort'); + } + catch (err) { + var error2 = err; + } + + expect(error2).to.be.an.error(DOMException); + expect(error2.name).to.equal('AbortError'); + }); + + it('rethrows only timeout errors', async () => { + + try { + Bounce.rethrow(new Error('Something'), 'timeout'); + } + catch (err) { + var error1 = err; + } + + expect(error1).to.not.exist(); + + try { + const signal = AbortSignal.timeout(0); + await Hoek.wait(1); + Bounce.rethrow(signal.reason, 'timeout'); + } + catch (err) { + var error2 = err; + } + + expect(error2).to.be.an.error(DOMException); + expect(error2.name).to.equal('TimeoutError'); + }); + it('rethrows only specified errors', () => { try { @@ -197,6 +267,20 @@ describe('Bounce', () => { expect(error2).to.be.an.error('Something'); }); + it('ignores non-errors matching a pattern', () => { + + const nonErr = { x: 1 }; + + try { + Bounce.rethrow(nonErr, { x: 1 }, { strict: false }); + } + catch (err) { + var error = err; + } + + expect(error).to.not.exist(); + }); + it('rethrows a decorated error', () => { const orig = new Error('Something'); @@ -240,14 +324,100 @@ describe('Bounce', () => { expect(error).to.shallow.equal(orig); expect(error).to.be.an.error('Something'); }); + + it('preserves non-errors', () => { + + try { + Bounce.rethrow('error', [], { strict: false }); + } + catch (err) { + var error = err; + } + + expect(error).to.not.exist(); + }); + + it('rethrows already aborted signal reason', () => { + + const orig = new Error('Something'); + const signal = AbortSignal.abort(new Error('Fail')); + + try { + Bounce.rethrow(orig, Error, { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(signal.reason); + expect(error).to.be.an.error('Fail'); + }); + + it('rethrows already aborted signal with no reason', () => { + + const orig = new Error('Something'); + const signal = AbortSignal.abort(); + + Object.defineProperty(signal, 'reason', { value: undefined }); // Simulate older API without the reason property + + try { + Bounce.rethrow(orig, Error, { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.be.an.error(DOMException, 'This operation was aborted'); + }); + + it('ignores non-aborted signal', () => { + + const orig = new Error('Something'); + const signal = new AbortController().signal; + + try { + Bounce.rethrow(orig, Error, { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(orig); + expect(error).to.be.an.error('Something'); + }); + + it('always throws TypeError for non-errors when strict', () => { + + const orig = 'error'; + + try { + Bounce.rethrow(orig, [], { strict: true }); + } + catch (err) { + var error1 = err; + } + + expect(error1).to.be.an.error(TypeError); + expect(error1.cause).to.shallow.equal(orig); + + try { + Bounce.rethrow(orig, [], { strict: true }); + } + catch (err) { + var error2 = err; + } + + expect(error2).to.be.an.error(TypeError); + expect(error2.cause).to.shallow.equal(orig); + }); }); - describe('ignore()', () => { + describe('assert()', () => { it('ignores system errors', () => { try { - Bounce.ignore(new Error('Something'), 'system'); + Bounce.assert(new Error('Something'), 'system'); } catch (err) { var error1 = err; @@ -256,7 +426,7 @@ describe('Bounce', () => { expect(error1).to.be.an.error('Something', Error); try { - Bounce.ignore(new URIError('Something'), 'system'); + Bounce.assert(new URIError('Something'), 'system'); } catch (err) { var error2 = err; @@ -268,7 +438,7 @@ describe('Bounce', () => { it('ignores boom errors', () => { try { - Bounce.ignore(new Error('Something'), 'boom'); + Bounce.assert(new Error('Something'), Boom.Boom); } catch (err) { var error1 = err; @@ -277,7 +447,7 @@ describe('Bounce', () => { expect(error1).to.be.an.error('Something', Error); try { - Bounce.ignore(Boom.badRequest('Something'), 'boom'); + Bounce.assert(Boom.badRequest('Something'), Boom.Boom); } catch (err) { var error2 = err; @@ -289,7 +459,7 @@ describe('Bounce', () => { it('ignores boom/system errors', () => { try { - Bounce.ignore(new Error('Something'), ['boom', 'system']); + Bounce.assert(new Error('Something'), [Boom.Boom, 'system']); } catch (err) { var error1 = err; @@ -298,7 +468,7 @@ describe('Bounce', () => { expect(error1).to.be.an.error('Something', Error); try { - Bounce.ignore(Boom.badRequest('Something'), ['boom', 'system']); + Bounce.assert(Boom.badRequest('Something'), [Boom.Boom, 'system']); } catch (err) { var error2 = err; @@ -307,7 +477,7 @@ describe('Bounce', () => { expect(error2).to.not.exist(); try { - Bounce.ignore(new ReferenceError('Something'), ['boom', 'system']); + Bounce.assert(new ReferenceError('Something'), [Boom.Boom, 'system']); } catch (err) { var error3 = err; @@ -315,6 +485,77 @@ describe('Bounce', () => { expect(error3).to.not.exist(); }); + + it('always throws non-errors', () => { + + const orig = 'error'; + + try { + Bounce.assert(orig, [], { strict: false }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(orig); + }); + + it('rethrows already aborted signal reason', () => { + + const orig = new Error('Something'); + const signal = AbortSignal.abort(new Error('Fail')); + + try { + Bounce.assert(orig, 'system', { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(signal.reason); + expect(error).to.be.an.error('Fail'); + }); + + it('ignores non-aborted signal', () => { + + const orig = new Error('Something'); + const signal = new AbortController().signal; + + try { + Bounce.assert(orig, 'system', { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(orig); + expect(error).to.be.an.error('Something'); + }); + + it('always throws TypeError for non-errors when strict', () => { + + const orig = 'error'; + + try { + Bounce.assert(orig, [], { strict: true }); + } + catch (err) { + var error1 = err; + } + + expect(error1).to.be.an.error(TypeError); + expect(error1.cause).to.shallow.equal(orig); + + try { + Bounce.assert(orig, [], { strict: true }); + } + catch (err) { + var error2 = err; + } + + expect(error2).to.be.an.error(TypeError); + expect(error2.cause).to.shallow.equal(orig); + }); }); describe('background()', () => { @@ -427,46 +668,18 @@ describe('Bounce', () => { Bounce.background(test, 'rethrow', 'system'); }); - }); - - describe('isBoom()', () => { - - it('identifies Boom as Boom', () => { - - expect(Bounce.isBoom(Boom.badRequest())).to.be.true(); - }); - it('identifies EvalError as non-boom', () => { + it('supports the return option', async () => { - expect(Bounce.isBoom(new EvalError())).to.be.false(); - }); - - it('identifies object as non-boom', () => { - - expect(Bounce.isBoom({})).to.be.false(); - }); - - it('identifies object with isBoom as non-boom', () => { - - expect(Bounce.isBoom({ isBoom: true })).to.be.false(); - }); - }); - - describe('isError()', () => { - - it('identifies Error as error', () => { - - expect(Bounce.isError(new Error())).to.be.true(); - }); - - it('identifies Boom as error', () => { - - expect(Bounce.isError(Boom.badRequest())).to.be.true(); - }); + const test = async () => { - it('identifies object as non-error', () => { + await Hoek.wait(1); + throw new SyntaxError('Something'); + }; - expect(Bounce.isBoom({})).to.be.false(); + const res = await Bounce.background(test(), 'rethrow', 'system', { return: true }); + expect(res).to.exist(); + expect(res).to.be.an.error(SyntaxError); }); }); @@ -507,6 +720,11 @@ describe('Bounce', () => { expect(Bounce.isSystem(new Assert.AssertionError({}))).to.be.true(); }); + it('identifies custom assertion Error as system', () => { + + expect(Bounce.isSystem(new Error('custom-error'))).to.be.true(); + }); + it('identifies hoek Error as system', () => { expect(Bounce.isSystem(new Hoek.AssertError([]))).to.be.true(); @@ -537,4 +755,87 @@ describe('Bounce', () => { expect(Bounce.isSystem(Boom.boomify(new TypeError()))).to.be.false(); }); }); + + describe('isAbort()', () => { + + it('identifies AbortSignal.abort() reason as abort', () => { + + expect(Bounce.isAbort(AbortSignal.abort().reason)).to.be.true(); + }); + + it('identifies DOMException AbortError as abort', () => { + + expect(Bounce.isAbort(new DOMException('aborted', 'AbortError'))).to.be.true(); + }); + + it('identifies Error with name "AbortError" as abort', () => { + + class MyAbort extends Error { + name = 'AbortError'; + } + + expect(Bounce.isAbort(new MyAbort())).to.be.true(); + }); + + it('identifies object as non-abort', () => { + + expect(Bounce.isAbort({})).to.be.false(); + }); + + it('identifies error as non-abort', () => { + + expect(Bounce.isAbort(new Error('failed'))).to.be.false(); + }); + + it('identifies object with name "AbortError" as non-abort', () => { + + expect(Bounce.isAbort({ name: 'AbortError' })).to.be.false(); + }); + + it('identifies AbortSignal.timeout() reason non-abort', async () => { + + const signal = AbortSignal.timeout(0); + await Hoek.wait(1); + expect(Bounce.isAbort(signal.reason)).to.be.false(); + }); + }); + + describe('isTimeout()', () => { + + it('identifies AbortSignal.timeout() reason as timeout', async () => { + + const signal = AbortSignal.timeout(0); + await Hoek.wait(1); + expect(Bounce.isTimeout(signal.reason)).to.be.true(); + }); + + it('identifies DOMException TimeoutError as timeout', () => { + + expect(Bounce.isTimeout(new DOMException('timed out', 'TimeoutError'))).to.be.true(); + }); + + it('identifies Error with name "TimeoutError" as timeout', () => { + + class MyTimeout extends Error { + name = 'TimeoutError'; + } + + expect(Bounce.isTimeout(new MyTimeout())).to.be.true(); + }); + + it('identifies object as non-timeout', () => { + + expect(Bounce.isTimeout({})).to.be.false(); + }); + + it('identifies error as non-timeout', () => { + + expect(Bounce.isTimeout(new Error('failed'))).to.be.false(); + }); + + it('identifies object with name "TimeoutError" as non-timeout', () => { + + expect(Bounce.isTimeout({ name: 'TimeoutError' })).to.be.false(); + }); + }); }); diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..7ea1732 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,146 @@ +import * as Bounce from '..'; +import * as Boom from '@hapi/boom'; +import * as Hoek from '@hapi/hoek'; +import * as Lab from '@hapi/lab'; + +const { expect } = Lab.types; + +// rethrow() + +expect.type(Bounce.rethrow(new Error(), 'system')); +expect.type(Bounce.rethrow(123, Boom.Boom, { strict: false })); +expect.type(Bounce.rethrow(new TypeError(), [Boom.Boom, RangeError, { prop: true }])); +expect.type(Bounce.rethrow(new TypeError(), Boom.Boom, { return: true })); +expect.type(Bounce.rethrow(null, Boom.Boom, { strict: false, return: true, override: new RangeError() })); +expect.type<(TypeError & { prop: string }) | undefined>(Bounce.rethrow(new TypeError(), Boom.Boom, { return: true, decorate: { prop: 'ok' } })); + +// Narrows the error type + +{ + const error = new Error() as unknown; + Bounce.rethrow(error, 'system'); + expect.type(error); +} + +{ + const error = new Error() as unknown; + Bounce.rethrow(error, 'system', { strict: true } as { strict: boolean }); + expect.type(error); +} + +{ + const error = 123; + Bounce.rethrow(error, 'system', { strict: false }); + expect.type(error); +} + +expect.error(Bounce.rethrow(new Error())); +expect.error(Bounce.rethrow(new Error(), 'unknown')); +expect.error(Bounce.rethrow(new Error(), Boom.Boom, true)); +expect.error(Bounce.rethrow(new Error(), Boom.Boom, { unknown: true })); +expect.error(Bounce.rethrow(new Error(), Boom.Boom, { decorate: 123 })); +expect.error(Bounce.rethrow(new Error(), Boom.Boom, { override: {} })); + +// assert() + +expect.type(Bounce.assert(new TypeError(), 'system')); +expect.type(Bounce.assert(new Boom.Boom(), Boom.Boom)); +expect.type(Bounce.assert(new RangeError(), [Boom.Boom, RangeError, { prop: true }])); +expect.type(Bounce.assert(new TypeError(), Boom.Boom, { return: true })); +expect.type(Bounce.assert(null, Boom.Boom, { return: true, override: new RangeError(), strict: false })); +expect.type<(TypeError & { prop: string }) | undefined>(Bounce.assert(new TypeError(), Boom.Boom, { return: true, decorate: { prop: 'ok' } })); + +// Narrows the error type + +{ + const error = new TypeError() as unknown; + Bounce.assert(error, TypeError); + expect.type(error); +} + +{ + const error = new Boom.Boom() as unknown; + Bounce.assert(error, Boom.Boom); + expect.type(error); +} + +{ + class TestError extends Error { + test = true; + } + + const error = new TestError() as unknown; + Bounce.assert(error, TestError); + expect.type(error); +} + +expect.error(Bounce.assert(new Error())); +expect.error(Bounce.assert(new Error(), 'unknown')); +expect.error(Bounce.assert(new Error(), Boom.Boom, true)); +expect.error(Bounce.assert(new Error(), Boom.Boom, { unknown: true })); +expect.error(Bounce.assert(new Error(), Boom.Boom, { decorate: 123 })); +expect.error(Bounce.assert(new Error(), Boom.Boom, { override: {} })); + +// background() + +expect.type>(Bounce.background(async () => undefined, 'assert', 'system', { decorate: { a: true } })); +expect.type>(Bounce.background(async () => undefined, 'rethrow', [RangeError], { return: true })); +expect.type>(Bounce.background(async () => undefined, undefined, undefined, { return: true, override: new TypeError() })); + +expect.error(Bounce.background()); +expect.error(Bounce.background(true, 'unknown')); + +// isSystem() + +expect.type(Bounce.isSystem(new Error())); +{ + const err = new TypeError(); + if (Bounce.isSystem(err)) { + expect.type(err); // Does not narrow type + } +} +{ + const obj = {}; + if (Bounce.isSystem(obj)) { + expect.type(obj); // Narrows type + } +} + +expect.error(Bounce.isSystem()); + +// isAbort() + +expect.type(Bounce.isAbort(0)); +{ + const err = AbortSignal.abort().reason as any; + if (Bounce.isAbort(err)) { + expect.type(err); // Narrows type + expect.type<'AbortError'>(err.name); // Narrows name + } + + Bounce.assert(err, 'abort'); + expect.type<'AbortError'>(err.name); // Narrows name +} + +expect.error(Bounce.isAbort()); + +// isTimeout() + +expect.type(Bounce.isTimeout(0)); +{ + const timeout = AbortSignal.timeout(1); + const err = await new Promise((resolve) => { + + timeout.onabort = () => resolve(timeout.reason); + }); + + if (Bounce.isTimeout(err)) { + expect.type(err); // Narrows type + expect.type<'TimeoutError'>(err.name); // Narrows name + } + + Bounce.assert(err, 'timeout'); + expect.type<'TimeoutError'>(err.name); // Narrows name +} + +expect.error(Bounce.isTimeout());