diff --git a/src/sfError.ts b/src/sfError.ts index 390a55028..e551e5623 100644 --- a/src/sfError.ts +++ b/src/sfError.ts @@ -5,9 +5,20 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { NamedError } from '@salesforce/kit'; import { AnyJson, hasString, isString, JsonMap } from '@salesforce/ts-types'; +export type SfErrorOptions = { + message: string; + exitCode?: number; + name?: string; + data?: T; + cause?: Error; + context?: string; + actions: string[]; +}; + +type ErrorDataProperties = AnyJson; + /** * A generalized sfdx error which also contains an action. The action is used in the * CLI to help guide users past the error. @@ -24,7 +35,8 @@ import { AnyJson, hasString, isString, JsonMap } from '@salesforce/ts-types'; * throw new SfError(message.getMessage('myError'), 'MyErrorName'); * ``` */ -export class SfError extends NamedError { +export class SfError extends Error { + public readonly name: string; /** * Action messages. Hints to the users regarding what can be done to fix related issues. */ @@ -59,13 +71,15 @@ export class SfError extends NamedError { */ public constructor( message: string, - name?: string, + name = 'SfError', actions?: string[], exitCodeOrCause?: number | Error, cause?: Error ) { - cause = exitCodeOrCause instanceof Error ? exitCodeOrCause : cause; - super(name ?? 'SfError', message || name, cause); + const derivedCause = exitCodeOrCause instanceof Error ? exitCodeOrCause : cause; + super(message); + this.name = name; + this.cause = derivedCause; this.actions = actions; if (typeof exitCodeOrCause === 'number') { this.exitCode = exitCodeOrCause; @@ -74,6 +88,10 @@ export class SfError extends NamedError { } } + public get fullStack(): string | undefined { + return recursiveStack(this).join('\nCaused by: '); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public get code(): any { return this.#code ?? this.name; @@ -83,26 +101,39 @@ export class SfError extends NamedError { this.#code = code; } + /** like the constructor, but takes an typed object and let you also set context and data properties */ + public static create(inputs: SfErrorOptions): SfError { + const error = new SfError(inputs.message, inputs.name, inputs.actions, inputs.exitCode, inputs.cause); + error.data = inputs.data; + error.context = inputs.context; + return error; + } /** * Convert an Error to an SfError. * * @param err The error to convert. */ - public static wrap(err: Error | string): SfError { + public static wrap(err: unknown): SfError { if (isString(err)) { - return new SfError(err); + return new SfError(err); } if (err instanceof SfError) { - return err; + return err as SfError; } - const sfError = new SfError(err.message, err.name, undefined, err); + const sfError = + err instanceof Error + ? // a basic error with message and name. We make it the cause to preserve any other properties + new SfError(err.message, err.name, undefined, err) + : // ok, something was throws that wasn't error or string. Convert it to an Error that preserves the information as the cause and wrap that. + SfError.wrap(new TypeError('An unexpected error occurred', { cause: err })); // If the original error has a code, use that instead of name. if (hasString(err, 'code')) { sfError.code = err.code; } + return sfError; } @@ -130,24 +161,16 @@ export class SfError extends NamedError { * Convert an {@link SfError} state to an object. Returns a plain object representing the state of this error. */ public toObject(): JsonMap { - const obj: JsonMap = { + return { name: this.name, message: this.message ?? this.name, exitCode: this.exitCode, actions: this.actions, + ...(this.context ? { context: this.context } : {}), + ...(this.data ? { data: this.data } : {}), }; - - if (this.context) { - obj.context = this.context; - } - - if (this.data) { - // DANGER: data was previously typed as `unknown` and this assertion was here on the toObject. - // TODO in next major release: put proper type constraint on SfError.data to something that can serialize - // while we're making breaking changes, provide a more definite type for toObject - obj.data = this.data as AnyJson; - } - - return obj; } } + +const recursiveStack = (err: Error): string[] => + (err.cause && err.cause instanceof Error ? [err.stack, ...recursiveStack(err.cause)] : [err.stack]).filter(isString); diff --git a/test/unit/sfErrorTest.ts b/test/unit/sfErrorTest.ts index 87969bd2f..2e9c692f6 100644 --- a/test/unit/sfErrorTest.ts +++ b/test/unit/sfErrorTest.ts @@ -4,7 +4,7 @@ * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { expect } from 'chai'; +import { expect, assert } from 'chai'; import { Messages } from '../../src/messages'; import { SfError } from '../../src/sfError'; @@ -31,8 +31,71 @@ describe('SfError', () => { const err = new SfError(msg, 'myErrorName'); expect(err.name).to.equal('myErrorName'); }); + + it('sets actions', () => { + const msg = 'this is a test message'; + const actions = ['Do this action', 'Do that action']; + const err = new SfError(msg, 'myErrorName', actions); + expect(err.actions).to.equal(actions); + }); + + it('cause as 4th property', () => { + const msg = 'this is a test message'; + const cause = new Error('cause'); + const err = new SfError(msg, 'myErrorName', undefined, cause); + expect(err.cause).to.equal(cause); + }); + + it('cause as 5th property + exitCode', () => { + const msg = 'this is a test message'; + const cause = new Error('cause'); + const err = new SfError(msg, 'myErrorName', undefined, 2, cause); + expect(err.cause).to.equal(cause); + expect(err.exitCode).to.equal(2); + }); + + it('exitCode is 1 when undefined is provided', () => { + const msg = 'this is a test message'; + const cause = new Error('cause'); + const err = new SfError(msg, 'myErrorName', undefined, undefined, cause); + expect(err.cause).to.equal(cause); + expect(err.exitCode).to.equal(1); + }); + + it('exitCode is 1 when no arg is provided', () => { + const msg = 'this is a test message'; + const err = new SfError(msg, 'myErrorName'); + expect(err.cause).to.equal(undefined); + expect(err.exitCode).to.equal(1); + }); }); + describe('fullStack', () => { + it('returned `name:message` when no cause', () => { + const err = new SfError('test'); + expect(err.fullStack).to.include('SfError: test'); + expect(err.fullStack).to.include('sfErrorTest.ts'); + expect(err.fullStack).to.not.include('Caused by:'); + }); + it('1 cause', () => { + const nestedError = new Error('nested'); + const err = new SfError('test', undefined, undefined, nestedError); + expect(err.fullStack).to.include('SfError: test'); + expect(err.fullStack).to.include('sfErrorTest.ts'); + expect(err.fullStack).to.include('nested'); + expect(err.fullStack?.match(/Caused by:/g)).to.have.lengthOf(1); + }); + it('recurse through stacked causes', () => { + const nestedError = new Error('nested'); + const nestedError2 = new Error('nested2', { cause: nestedError }); + const err = new SfError('test', undefined, undefined, nestedError2); + expect(err.fullStack).to.include('SfError: test'); + expect(err.fullStack).to.include('sfErrorTest.ts'); + expect(err.fullStack).to.include('nested'); + expect(err.fullStack).to.include('nested2'); + expect(err.fullStack?.match(/Caused by:/g)).to.have.lengthOf(2); + }); + }); describe('wrap', () => { it('should return a wrapped error', () => { const myErrorMsg = 'yikes! What did you do?'; @@ -70,6 +133,55 @@ describe('SfError', () => { expect(mySfError).to.be.an.instanceOf(SfError); expect(mySfError).to.equal(existingSfError); }); + + describe('handling "other" stuff that is not Error', () => { + it('undefined', () => { + const wrapMe = undefined; + const mySfError = SfError.wrap(wrapMe); + expect(mySfError).to.be.an.instanceOf(SfError); + expect(mySfError.message === 'An unexpected error occurred'); + expect(mySfError.name === 'TypeError'); + assert(mySfError.cause instanceof TypeError); + expect(mySfError.cause.message === 'An unexpected error occurred'); + expect(mySfError.cause.cause).to.equal(wrapMe); + }); + it('a number', () => { + const wrapMe = 2; + const mySfError = SfError.wrap(wrapMe); + expect(mySfError).to.be.an.instanceOf(SfError); + assert(mySfError.cause instanceof TypeError); + expect(mySfError.cause.cause).to.equal(wrapMe); + }); + it('an object', () => { + const wrapMe = { a: 2 }; + const mySfError = SfError.wrap(wrapMe); + expect(mySfError).to.be.an.instanceOf(SfError); + assert(mySfError.cause instanceof TypeError); + expect(mySfError.cause.cause).to.equal(wrapMe); + }); + it('an object that has a code', () => { + const wrapMe = { a: 2, code: 'foo' }; + const mySfError = SfError.wrap(wrapMe); + expect(mySfError).to.be.an.instanceOf(SfError); + assert(mySfError.cause instanceof TypeError); + expect(mySfError.cause.cause).to.equal(wrapMe); + expect(mySfError.code).to.equal('foo'); + }); + it('an array', () => { + const wrapMe = [1, 5, 6]; + const mySfError = SfError.wrap(wrapMe); + expect(mySfError).to.be.an.instanceOf(SfError); + assert(mySfError.cause instanceof TypeError); + expect(mySfError.cause.cause).to.equal(wrapMe); + }); + it('a class', () => { + const wrapMe = new (class Test {})(); + const mySfError = SfError.wrap(wrapMe); + expect(mySfError).to.be.an.instanceOf(SfError); + assert(mySfError.cause instanceof TypeError); + expect(mySfError.cause.cause).to.equal(wrapMe); + }); + }); }); describe('generic for data', () => {