diff --git a/package.json b/package.json index c99ee9255..fe03230cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@salesforce/sf-plugins-core", - "version": "10.0.1", + "version": "11.0.0", "description": "Utils for writing Salesforce CLI plugins", "main": "lib/exported", "types": "lib/exported.d.ts", diff --git a/src/SfCommandError.ts b/src/SfCommandError.ts new file mode 100644 index 000000000..7f6d123d2 --- /dev/null +++ b/src/SfCommandError.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * 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 { SfError, StructuredMessage } from '@salesforce/core'; +import { AnyJson } from '@salesforce/ts-types'; +import { computeErrorCode } from './errorHandling.js'; +import { removeEmpty } from './util.js'; + +// These types are 90% the same as SfErrorOptions (but they aren't exported to extend) +type ErrorDataProperties = AnyJson; +export type SfCommandErrorOptions = { + message: string; + exitCode: number; + code: string; + name?: string; + commandName: string; + data?: T; + cause?: unknown; + context?: string; + actions?: string[]; + result?: unknown; + warnings?: Array; +}; + +type SfCommandErrorJson = { + name: string; + message: string; + exitCode: number; + commandName: string; + context: string; + code: string; + status: string; + stack: string; + actions?: string; + data?: ErrorDataProperties; + cause?: string; + warnings?: Array; + result?: unknown; +}; + +export class SfCommandError extends SfError { + public status: number; + public commandName: string; + public warnings?: Array; + public result?: unknown; + public skipOclifErrorHandling: boolean; + public oclif: { exit: number }; + + /** + * SfCommandError is meant to wrap errors from `SfCommand.catch()` for a common + * error format to be logged, sent to telemetry, and re-thrown. Do not create + * instances from the constructor. Call the static method, `SfCommandError.from()` + * and use the returned `SfCommandError`. + */ + private constructor(input: SfCommandErrorOptions) { + super(input.message, input.name, input.actions, input.exitCode, input.cause); + this.data = input.data; + this.status = input.exitCode; + this.warnings = input.warnings; + this.skipOclifErrorHandling = true; + this.commandName = input.commandName; + this.code = input.code; + this.result = input.result; + this.oclif = { exit: input.exitCode }; + this.context = input.context ?? input.commandName; + } + + public static from( + err: Error | SfError | SfCommandError, + commandName: string, + warnings?: Array + ): SfCommandError { + // SfError.wrap() does most of what we want so start with that. + const sfError = SfError.wrap(err); + const exitCode = computeErrorCode(err); + return new this({ + message: sfError.message, + name: err.name ?? 'Error', + actions: 'actions' in err ? err.actions : undefined, + exitCode, + code: 'code' in err && err.code ? err.code : exitCode.toString(10), + cause: sfError.cause, + commandName: 'commandName' in err ? err.commandName : commandName, + data: 'data' in err ? err.data : undefined, + result: 'result' in err ? err.result : undefined, + context: 'context' in err ? err.context : commandName, + warnings, + }); + } + + public toJson(): SfCommandErrorJson { + return { + ...removeEmpty({ + // toObject() returns name, message, exitCode, actions, context, data + ...this.toObject(), + stack: this.stack, + cause: this.cause, + warnings: this.warnings, + code: this.code, + status: this.status, + commandName: this.commandName, + result: this.result, + }), + } as SfCommandErrorJson; + } +} diff --git a/src/errorFormatting.ts b/src/errorFormatting.ts index 8ba9137b3..32cdd1b91 100644 --- a/src/errorFormatting.ts +++ b/src/errorFormatting.ts @@ -5,10 +5,11 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { Mode, Messages, envVars } from '@salesforce/core'; +import { inspect } from 'node:util'; import type { Ansis } from 'ansis'; +import { Mode, Messages, envVars } from '@salesforce/core'; import { StandardColors } from './ux/standardColors.js'; -import { SfCommandError } from './types.js'; +import { SfCommandError } from './SfCommandError.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); @@ -45,7 +46,7 @@ export const formatError = (error: SfCommandError): string => `${formatErrorPrefix(error)} ${error.message}`, ...formatActions(error.actions ?? []), error.stack && envVars.getString('SF_ENV') === Mode.DEVELOPMENT - ? StandardColors.info(`\n*** Internal Diagnostic ***\n\n${error.stack}\n******\n`) + ? StandardColors.info(`\n*** Internal Diagnostic ***\n\n${inspect(error)}\n******\n`) : [], ].join('\n'); diff --git a/src/errorHandling.ts b/src/errorHandling.ts index 4beb96021..7a14eb3de 100644 --- a/src/errorHandling.ts +++ b/src/errorHandling.ts @@ -6,9 +6,7 @@ */ import { SfError } from '@salesforce/core'; -import { CLIError } from '@oclif/core/errors'; -import { SfCommandError } from './types.js'; -import { removeEmpty } from './util.js'; +import { Errors } from '@oclif/core'; /** * @@ -21,7 +19,7 @@ import { removeEmpty } from './util.js'; * - use the process exitCode * - default to 1 */ -export const computeErrorCode = (e: Error | SfError | SfCommandError): number => { +export const computeErrorCode = (e: Error | SfError | Errors.CLIError): number => { // regardless of the exitCode, we'll set gacks and TypeError to a specific exit code if (errorIsGack(e)) { return 20; @@ -66,28 +64,6 @@ export const errorIsTypeError = (error: Error | SfError): boolean => Boolean(error.stack?.includes('TypeError')) || ('cause' in error && error.cause instanceof Error && errorIsTypeError(error.cause)); -export const errorToSfCommandError = ( - codeFromError: number, - error: Error | SfError | SfCommandError, - commandName: string -): SfCommandError => ({ - ...removeEmpty({ - code: codeFromError, - actions: 'actions' in error ? error.actions : null, - context: ('context' in error ? error.context : commandName) ?? commandName, - commandName: ('commandName' in error ? error.commandName : commandName) ?? commandName, - data: 'data' in error ? error.data : null, - result: 'result' in error ? error.result : null, - }), - ...{ - message: error.message, - name: error.name ?? 'Error', - status: codeFromError, - stack: error.stack, - exitCode: codeFromError, - }, -}); - /** custom typeGuard for handling the fact the SfCommand doesn't know about oclif error structure */ -const isOclifError = (e: T): e is T & CLIError => +const isOclifError = (e: T): e is T & Errors.CLIError => 'oclif' in e ? true : false; diff --git a/src/sfCommand.ts b/src/sfCommand.ts index 71daec4d0..6257e336a 100644 --- a/src/sfCommand.ts +++ b/src/sfCommand.ts @@ -5,7 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import os from 'node:os'; -import { Command, Config, HelpSection, Flags } from '@oclif/core'; +import { Errors, Command, Config, HelpSection, Flags } from '@oclif/core'; import { envVars, Messages, @@ -20,11 +20,10 @@ import type { AnyJson } from '@salesforce/ts-types'; import { Progress } from './ux/progress.js'; import { Spinner } from './ux/spinner.js'; import { Ux } from './ux/ux.js'; -import { SfCommandError } from './types.js'; +import { SfCommandError } from './SfCommandError.js'; import { formatActions, formatError } from './errorFormatting.js'; import { StandardColors } from './ux/standardColors.js'; import { confirm, secretPrompt, PromptInputs } from './ux/prompts.js'; -import { computeErrorCode, errorToSfCommandError } from './errorHandling.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); @@ -201,7 +200,7 @@ export abstract class SfCommand extends Command { const colorizedArgs = [ `${StandardColors.warning(messages.getMessage('warning.prefix'))} ${message}`, - ...formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info }), + ...formatActions(typeof input === 'string' ? [] : input.actions ?? []), ]; this.logToStderr(colorizedArgs.join(os.EOL)); @@ -216,10 +215,9 @@ export abstract class SfCommand extends Command { public info(input: SfCommand.Info): void { const message = typeof input === 'string' ? input : input.message; this.log( - [ - `${StandardColors.info(message)}`, - ...formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info }), - ].join(os.EOL) + [`${StandardColors.info(message)}`, ...formatActions(typeof input === 'string' ? [] : input.actions ?? [])].join( + os.EOL + ) ); } @@ -368,66 +366,27 @@ export abstract class SfCommand extends Command { }; } - /** - * Wrap the command error into the standardized JSON structure. - */ - protected toErrorJson(error: SfCommand.Error): SfCommand.Error { - return { - ...error, - warnings: this.warnings, - }; - } - // eslint-disable-next-line @typescript-eslint/require-await - protected async catch(error: Error | SfError | SfCommand.Error): Promise { + protected async catch(error: Error | SfError | Errors.CLIError): Promise { // stop any spinners to prevent it from unintentionally swallowing output. // If there is an active spinner, it'll say "Error" instead of "Done" this.spinner.stop(StandardColors.error('Error')); - // transform an unknown error into one that conforms to the interface - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - // const codeFromError = (error.oclif?.exit as number | undefined) ?? (error.exitCode as number | undefined) ?? 1; - const codeFromError = computeErrorCode(error); - process.exitCode = codeFromError; - - const sfCommandError = errorToSfCommandError(codeFromError, error, this.statics.name); + // transform an unknown error into a SfCommandError + const sfCommandError = SfCommandError.from(error, this.statics.name, this.warnings); + process.exitCode = sfCommandError.exitCode; if (this.jsonEnabled()) { - this.logJson(this.toErrorJson(sfCommandError)); + this.logJson(sfCommandError.toJson()); } else { this.logToStderr(formatError(sfCommandError)); } - // Create SfError that can be thrown - const err = new SfError( - error.message, - error.name ?? 'Error', - // @ts-expect-error because actions is not on Error - (error.actions as string[]) ?? [], - process.exitCode - ); - if (sfCommandError.data) { - err.data = sfCommandError.data as AnyJson; - } - err.context = sfCommandError.context; - err.stack = sfCommandError.stack; - // @ts-expect-error because code is not on SfError - err.code = codeFromError; - // @ts-expect-error because status is not on SfError - err.status = sfCommandError.status; - - // @ts-expect-error because skipOclifErrorHandling is not on SfError - err.skipOclifErrorHandling = true; - - // Add oclif exit code to the error so that oclif can use the exit code when exiting. - // @ts-expect-error because oclif is not on SfError - err.oclif = { exit: process.exitCode }; - // Emit an event for plugin-telemetry prerun hook to pick up. // @ts-expect-error because TS is strict about the events that can be emitted on process. - process.emit('sfCommandError', err, this.id); + process.emit('sfCommandError', sfCommandError, this.id); - throw err; + throw sfCommandError; } public abstract run(): Promise; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 1d89d9fbf..000000000 --- a/src/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2023, salesforce.com, inc. - * All rights reserved. - * 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 { StructuredMessage } from '@salesforce/core'; - -// a standlone type here to avoid circular dependencies -export type SfCommandError = { - status: number; - name: string; - message: string; - stack: string | undefined; - warnings?: Array; - actions?: string[]; - code?: unknown; - exitCode?: number; - data?: unknown; - context?: string; - commandName?: string; -} diff --git a/test/unit/errorFormatting.test.ts b/test/unit/errorFormatting.test.ts new file mode 100644 index 000000000..3d0fe439b --- /dev/null +++ b/test/unit/errorFormatting.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * 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 { Mode, SfError } from '@salesforce/core'; +import { formatError } from '../../src/errorFormatting.js'; +import { SfCommandError } from '../../src/SfCommandError.js'; + +describe('errorFormatting.formatError()', () => { + afterEach(() => { + delete process.env.SF_ENV; + }); + + it('should have correct output for non-development mode, no actions', () => { + const err = SfCommandError.from(new Error('this did not work'), 'thecommand'); + const errorOutput = formatError(err); + expect(errorOutput).to.contain('Error (1)'); + expect(errorOutput).to.contain('this did not work'); + }); + + it('should have correct output for non-development mode, with actions', () => { + const sfError = new SfError('this did not work', 'BadError'); + const err = SfCommandError.from(sfError, 'thecommand'); + err.actions = ['action1', 'action2']; + const errorOutput = formatError(err); + expect(errorOutput).to.contain('Error (BadError)'); + expect(errorOutput).to.contain('this did not work'); + expect(errorOutput).to.contain('Try this:'); + expect(errorOutput).to.contain('action1'); + expect(errorOutput).to.contain('action2'); + }); + + it('should have correct output for development mode, basic error', () => { + process.env.SF_ENV = Mode.DEVELOPMENT; + const err = SfCommandError.from(new SfError('this did not work'), 'thecommand'); + const errorOutput = formatError(err); + expect(errorOutput).to.contain('Error (SfError)'); + expect(errorOutput).to.contain('this did not work'); + expect(errorOutput).to.contain('*** Internal Diagnostic ***'); + expect(errorOutput).to.contain('at Function.from'); + expect(errorOutput).to.contain('actions: undefined'); + expect(errorOutput).to.contain('exitCode: 1'); + expect(errorOutput).to.contain("context: 'thecommand'"); + expect(errorOutput).to.contain('data: undefined'); + expect(errorOutput).to.contain('cause: undefined'); + expect(errorOutput).to.contain('status: 1'); + expect(errorOutput).to.contain("commandName: 'thecommand'"); + expect(errorOutput).to.contain('warnings: undefined'); + expect(errorOutput).to.contain('result: undefined'); + }); + + it('should have correct output for development mode, full error', () => { + process.env.SF_ENV = Mode.DEVELOPMENT; + const sfError = SfError.create({ + name: 'WOMP_WOMP', + message: 'this did not work', + actions: ['action1', 'action2'], + cause: new Error('this is the cause'), + exitCode: 9, + context: 'somecommand', + data: { foo: 'bar' }, + }); + const err = SfCommandError.from(sfError, 'thecommand'); + const errorOutput = formatError(err); + expect(errorOutput).to.contain('Error (WOMP_WOMP)'); + expect(errorOutput).to.contain('this did not work'); + expect(errorOutput).to.contain('*** Internal Diagnostic ***'); + expect(errorOutput).to.contain('at Function.from'); + expect(errorOutput).to.contain("actions: [ 'action1', 'action2' ]"); + expect(errorOutput).to.contain('exitCode: 9'); + expect(errorOutput).to.contain("context: 'somecommand'"); + expect(errorOutput).to.contain("data: { foo: 'bar' }"); + expect(errorOutput).to.contain('cause: Error: this is the cause'); + expect(errorOutput).to.contain('status: 9'); + expect(errorOutput).to.contain("commandName: 'thecommand'"); + expect(errorOutput).to.contain('warnings: undefined'); + expect(errorOutput).to.contain('result: undefined'); + }); +}); diff --git a/test/unit/errorHandling.test.ts b/test/unit/errorHandling.test.ts index b59cd7938..bdfb20e7c 100644 --- a/test/unit/errorHandling.test.ts +++ b/test/unit/errorHandling.test.ts @@ -6,7 +6,8 @@ */ import { expect } from 'chai'; import { SfError } from '@salesforce/core'; -import { computeErrorCode, errorIsGack, errorIsTypeError, errorToSfCommandError } from '../../src/errorHandling.js'; +import { computeErrorCode, errorIsGack, errorIsTypeError } from '../../src/errorHandling.js'; +import { SfCommandError } from '../../src/SfCommandError.js'; describe('typeErrors', () => { let typeError: Error; @@ -123,11 +124,11 @@ describe('precedence', () => { }); }); -describe('errorToSfCommandError', () => { +describe('SfCommandError.toJson()', () => { it('basic', () => { - const result = errorToSfCommandError(1, new Error('foo'), 'the:cmd'); + const result = SfCommandError.from(new Error('foo'), 'the:cmd').toJson(); expect(result).to.deep.include({ - code: 1, + code: '1', status: 1, exitCode: 1, commandName: 'the:cmd', @@ -137,12 +138,33 @@ describe('errorToSfCommandError', () => { }); expect(result.stack).to.be.a('string').and.include('Error: foo'); }); + it('with warnings', () => { + const warnings = ['your version of node is over 10 years old']; + const result = SfCommandError.from(new Error('foo'), 'the:cmd', warnings).toJson(); + expect(result).to.deep.include({ + code: '1', + status: 1, + exitCode: 1, + commandName: 'the:cmd', + context: 'the:cmd', + message: 'foo', + name: 'Error', // this is the default + warnings, + }); + expect(result.stack).to.be.a('string').and.include('Error: foo'); + }); describe('context', () => { it('sfError with context', () => { - const sfError = SfError.create({ name: 'myError', message: 'foo', actions: ['bar'], context: 'myContext' }); - const result = errorToSfCommandError(8, sfError, 'the:cmd'); + const sfError = SfError.create({ + name: 'myError', + message: 'foo', + actions: ['bar'], + context: 'myContext', + exitCode: 8, + }); + const result = SfCommandError.from(sfError, 'the:cmd').toJson(); expect(result).to.deep.include({ - code: 8, + code: 'myError', status: 8, exitCode: 8, commandName: 'the:cmd', @@ -153,10 +175,16 @@ describe('errorToSfCommandError', () => { expect(result.stack).to.be.a('string').and.include('myError: foo'); }); it('sfError with undefined context', () => { - const sfError = SfError.create({ name: 'myError', message: 'foo', actions: ['bar'], context: undefined }); - const result = errorToSfCommandError(8, sfError, 'the:cmd'); + const sfError = SfError.create({ + name: 'myError', + message: 'foo', + actions: ['bar'], + context: undefined, + exitCode: 8, + }); + const result = SfCommandError.from(sfError, 'the:cmd').toJson(); expect(result).to.deep.include({ - code: 8, + code: 'myError', status: 8, exitCode: 8, commandName: 'the:cmd', diff --git a/test/unit/sfCommand.test.ts b/test/unit/sfCommand.test.ts index 39108e978..8369c00fc 100644 --- a/test/unit/sfCommand.test.ts +++ b/test/unit/sfCommand.test.ts @@ -5,14 +5,17 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { Flags } from '@oclif/core'; +import { Errors } from '@oclif/core'; import { Lifecycle } from '@salesforce/core'; import { TestContext } from '@salesforce/core/testSetup'; import { assert, expect } from 'chai'; import { SfError } from '@salesforce/core'; import { Config } from '@oclif/core/interfaces'; import { SfCommand } from '../../src/sfCommand.js'; +import { SfCommandError } from '../../src/SfCommandError.js'; import { StandardColors } from '../../src/ux/standardColors.js'; import { stubSfCommandUx, stubSpinner } from '../../src/stubUx.js'; + class TestCommand extends SfCommand { public static readonly flags = { actions: Flags.boolean({ char: 'a', description: 'show actions' }), @@ -43,15 +46,72 @@ class TestCommand extends SfCommand { } } +const errCause = new Error('the error cause'); +const errActions = ['action1', 'action2']; +const errData = { prop1: 'foo', prop2: 'bar' }; + +// A Command that will throw different kinds of errors to ensure +// consistent error behavior. +class TestCommandErrors extends SfCommand { + public static buildFullError = () => { + const err = new Error('full Error message'); + err.name = 'FullErrorName'; + err.cause = errCause; + return err; + }; + + public static buildFullSfError = () => + SfError.create({ + message: 'full SfError message', + name: 'FullSfErrorName', + actions: errActions, + context: 'TestCmdError', // purposely different from the default + exitCode: 69, + cause: errCause, + data: errData, + }); + + public static buildOclifError = () => { + const err = new Errors.CLIError('Nonexistent flag: --INVALID\nSee more help with --help'); + err.oclif = { exit: 2 }; + err.code = undefined; + return err; + }; + + // eslint-disable-next-line @typescript-eslint/member-ordering + public static errors: { [x: string]: Error } = { + error: new Error('error message'), + sfError: new SfError('sfError message'), + fullError: TestCommandErrors.buildFullError(), + fullSfError: TestCommandErrors.buildFullSfError(), + oclifError: TestCommandErrors.buildOclifError(), + }; + + // eslint-disable-next-line @typescript-eslint/member-ordering + public static readonly flags = { + error: Flags.string({ + char: 'e', + description: 'throw this error', + required: true, + options: Object.keys(TestCommandErrors.errors), + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(TestCommandErrors); + throw TestCommandErrors.errors[flags.error]; + } +} + class NonJsonCommand extends SfCommand { public static enableJsonFlag = false; public async run(): Promise { - await this.parse(TestCommand); + await this.parse(NonJsonCommand); } } describe('jsonEnabled', () => { - beforeEach(() => { + afterEach(() => { delete process.env.SF_CONTENT_TYPE; }); @@ -173,6 +233,317 @@ describe('warning messages', () => { }); }); +describe('error standardization', () => { + const $$ = new TestContext(); + + let sfCommandErrorData: [Error?, string?]; + const sfCommandErrorCb = (err: Error, cmdId: string) => { + sfCommandErrorData = [err, cmdId]; + }; + + beforeEach(() => { + sfCommandErrorData = []; + process.on('sfCommandError', sfCommandErrorCb); + }); + + afterEach(() => { + process.removeListener('sfCommandError', sfCommandErrorCb); + process.exitCode = undefined; + }); + + it('should log correct error when command throws an oclif Error', async () => { + const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); + try { + await TestCommandErrors.run(['--error', 'oclifError']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logToStderrStub.callCount).to.equal(1); + expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); + + // Ensure the error has expected properties + expect(err).to.have.property('actions', undefined); + expect(err).to.have.property('exitCode', 2); + expect(err).to.have.property('context', 'TestCommandErrors'); + expect(err).to.have.property('data', undefined); + expect(err).to.have.property('cause').and.be.ok; + expect(err).to.have.property('code', '2'); + expect(err).to.have.property('status', 2); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 2 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + it('should log correct error when command throws an Error', async () => { + const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); + try { + await TestCommandErrors.run(['--error', 'error']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logToStderrStub.callCount).to.equal(1); + expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); + + // Ensure the error has expected properties + expect(err).to.have.property('actions', undefined); + expect(err).to.have.property('exitCode', 1); + expect(err).to.have.property('context', 'TestCommandErrors'); + expect(err).to.have.property('data', undefined); + expect(err).to.have.property('cause').and.be.ok; + expect(err).to.have.property('code', '1'); + expect(err).to.have.property('status', 1); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 1 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + it('should log correct error when command throws an Error --json', async () => { + const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); + try { + await TestCommandErrors.run(['--error', 'error', '--json']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logJsonStub.callCount).to.equal(1); + expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); + + // Ensure the error has expected properties + expect(err).to.have.property('actions', undefined); + expect(err).to.have.property('exitCode', 1); + expect(err).to.have.property('context', 'TestCommandErrors'); + expect(err).to.have.property('data', undefined); + expect(err).to.have.property('cause').and.be.ok; + expect(err).to.have.property('code', '1'); + expect(err).to.have.property('status', 1); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 1 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + it('should log correct error when command throws an SfError', async () => { + const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); + try { + await TestCommandErrors.run(['--error', 'sfError']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logToStderrStub.callCount).to.equal(1); + expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); + + // Ensure the error has expected properties + expect(err).to.have.property('actions', undefined); + expect(err).to.have.property('exitCode', 1); + expect(err).to.have.property('context', 'TestCommandErrors'); + expect(err).to.have.property('data', undefined); + expect(err).to.have.property('cause', undefined); + expect(err).to.have.property('code', 'SfError'); + expect(err).to.have.property('status', 1); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 1 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + it('should log correct error when command throws an SfError --json', async () => { + const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); + try { + await TestCommandErrors.run(['--error', 'sfError', '--json']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logJsonStub.callCount).to.equal(1); + expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); + + // Ensure the error has expected properties + expect(err).to.have.property('name', 'SfError'); + expect(err).to.have.property('actions', undefined); + expect(err).to.have.property('exitCode', 1); + expect(err).to.have.property('context', 'TestCommandErrors'); + expect(err).to.have.property('data', undefined); + expect(err).to.have.property('cause', undefined); + expect(err).to.have.property('code', 'SfError'); + expect(err).to.have.property('status', 1); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 1 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + // A "full" Error has all props set allowed for an Error + it('should log correct error when command throws a "full" Error', async () => { + const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); + try { + await TestCommandErrors.run(['--error', 'fullError']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logToStderrStub.callCount).to.equal(1); + expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); + + // Ensure the error has expected properties + expect(err).to.have.property('name', 'FullErrorName'); + expect(err).to.have.property('actions', undefined); + expect(err).to.have.property('exitCode', 1); + expect(err).to.have.property('context', 'TestCommandErrors'); + expect(err).to.have.property('data', undefined); + // SfError.wrap() sets the original error as the cause + expect(err.cause).to.have.property('name', 'FullErrorName'); + expect(err.cause).to.have.property('cause', errCause); + expect(err).to.have.property('code', '1'); + expect(err).to.have.property('status', 1); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 1 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + // A "full" Error has all props set allowed for an Error + it('should log correct error when command throws a "full" Error --json', async () => { + const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); + try { + await TestCommandErrors.run(['--error', 'fullError', '--json']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logJsonStub.callCount).to.equal(1); + expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); + + // Ensure the error has expected properties + expect(err).to.have.property('name', 'FullErrorName'); + expect(err).to.have.property('actions', undefined); + expect(err).to.have.property('exitCode', 1); + expect(err).to.have.property('context', 'TestCommandErrors'); + expect(err).to.have.property('data', undefined); + // SfError.wrap() sets the original error as the cause + expect(err.cause).to.have.property('name', 'FullErrorName'); + expect(err.cause).to.have.property('cause', errCause); + expect(err).to.have.property('code', '1'); + expect(err).to.have.property('status', 1); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 1 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + // A "full" SfError has all props set allowed for an SfError + it('should log correct error when command throws a "full" SfError', async () => { + const logToStderrStub = $$.SANDBOX.stub(SfCommand.prototype, 'logToStderr'); + try { + await TestCommandErrors.run(['--error', 'fullSfError']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logToStderrStub.callCount).to.equal(1); + expect(logToStderrStub.firstCall.firstArg).to.contain(err.message); + + // Ensure the error has expected properties + expect(err).to.have.property('name', 'FullSfErrorName'); + expect(err).to.have.property('actions', errActions); + expect(err).to.have.property('exitCode', 69); + expect(err).to.have.property('context', 'TestCmdError'); + expect(err).to.have.property('data', errData); + expect(err).to.have.property('cause', errCause); + expect(err).to.have.property('code', 'FullSfErrorName'); + expect(err).to.have.property('status', 69); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 69 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); + + // A "full" SfError has all props set allowed for an SfError + it('should log correct error when command throws a "full" SfError --json', async () => { + const logJsonStub = $$.SANDBOX.stub(SfCommand.prototype, 'logJson'); + try { + await TestCommandErrors.run(['--error', 'fullSfError', '--json']); + expect(false, 'error should have been thrown').to.be.true; + } catch (e: unknown) { + expect(e).to.be.instanceOf(SfCommandError); + const err = e as SfCommand.Error; + + // Ensure the error was logged to the console + expect(logJsonStub.callCount).to.equal(1); + expect(logJsonStub.firstCall.firstArg).to.deep.equal(err.toJson()); + + // Ensure the error has expected properties + expect(err).to.have.property('name', 'FullSfErrorName'); + expect(err).to.have.property('actions', errActions); + expect(err).to.have.property('exitCode', 69); + expect(err).to.have.property('context', 'TestCmdError'); + expect(err).to.have.property('data', errData); + expect(err).to.have.property('cause', errCause); + expect(err).to.have.property('code', 'FullSfErrorName'); + expect(err).to.have.property('status', 69); + expect(err).to.have.property('stack').and.be.ok; + expect(err).to.have.property('skipOclifErrorHandling', true); + expect(err).to.have.deep.property('oclif', { exit: 69 }); + + // Ensure a sfCommandError event was emitted with the expected data + expect(sfCommandErrorData[0]).to.equal(err); + expect(sfCommandErrorData[1]).to.equal('testcommanderrors'); + } + }); +}); + describe('spinner stops on errors', () => { const $$ = new TestContext();