Skip to content

Commit c093c8f

Browse files
committed
feat: better error handling in SfCommand.catch
1 parent af10787 commit c093c8f

8 files changed

+578
-113
lines changed

src/SfCommandError.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { SfError, StructuredMessage } from '@salesforce/core';
8+
import { AnyJson } from '@salesforce/ts-types';
9+
import { computeErrorCode } from './errorHandling.js';
10+
import { removeEmpty } from './util.js';
11+
12+
// These types are 90% the same as SfErrorOptions (but they aren't exported to extend)
13+
type ErrorDataProperties = AnyJson;
14+
export type SfCommandErrorOptions<T extends ErrorDataProperties = ErrorDataProperties> = {
15+
message: string;
16+
exitCode: number;
17+
code: string;
18+
name?: string;
19+
commandName: string;
20+
data?: T;
21+
cause?: unknown;
22+
context?: string;
23+
actions?: string[];
24+
result?: unknown;
25+
warnings?: Array<StructuredMessage | string>;
26+
};
27+
28+
type SfCommandErrorJson = {
29+
name: string;
30+
message: string;
31+
exitCode: number;
32+
commandName: string;
33+
context: string;
34+
code: string;
35+
status: string;
36+
stack: string;
37+
actions?: string;
38+
data?: ErrorDataProperties;
39+
cause?: string;
40+
warnings?: Array<StructuredMessage | string>;
41+
result?: unknown;
42+
};
43+
44+
export class SfCommandError extends SfError {
45+
public status: number;
46+
public commandName: string;
47+
public warnings?: Array<StructuredMessage | string>;
48+
public result?: unknown;
49+
public skipOclifErrorHandling: boolean;
50+
public oclif: { exit: number };
51+
52+
/**
53+
* SfCommandError is meant to wrap errors from `SfCommand.catch()` for a common
54+
* error format to be logged, sent to telemetry, and re-thrown. Do not create
55+
* instances from the constructor. Call the static method, `SfCommandError.from()`
56+
* and use the returned `SfCommandError`.
57+
*/
58+
private constructor(input: SfCommandErrorOptions) {
59+
super(input.message, input.name, input.actions, input.exitCode, input.cause);
60+
this.data = input.data;
61+
this.status = input.exitCode;
62+
this.warnings = input.warnings;
63+
this.skipOclifErrorHandling = true;
64+
this.commandName = input.commandName;
65+
this.code = input.code;
66+
this.result = input.result;
67+
this.oclif = { exit: input.exitCode };
68+
this.context = input.context ?? input.commandName;
69+
}
70+
71+
public static from(
72+
err: Error | SfError | SfCommandError,
73+
commandName: string,
74+
warnings?: Array<StructuredMessage | string>
75+
): SfCommandError {
76+
// SfError.wrap() does most of what we want so start with that.
77+
const sfError = SfError.wrap(err);
78+
const exitCode = computeErrorCode(sfError);
79+
sfError.code = 'code' in err ? err.code : exitCode.toString(10);
80+
return new this({
81+
message: sfError.message,
82+
name: err.name ?? 'Error',
83+
actions: 'actions' in err ? err.actions : undefined,
84+
exitCode,
85+
code: sfError.code,
86+
cause: sfError.cause,
87+
commandName: 'commandName' in err ? err.commandName : commandName,
88+
data: 'data' in err ? err.data : undefined,
89+
result: 'result' in err ? err.result : undefined,
90+
context: 'context' in err ? err.context : commandName,
91+
warnings,
92+
});
93+
}
94+
95+
public toJson(): SfCommandErrorJson {
96+
return {
97+
...removeEmpty({
98+
// toObject() returns name, message, exitCode, actions, context, data
99+
...this.toObject(),
100+
stack: this.stack,
101+
cause: this.cause,
102+
warnings: this.warnings,
103+
code: this.code,
104+
status: this.status,
105+
commandName: this.commandName,
106+
result: this.result,
107+
}),
108+
} as SfCommandErrorJson;
109+
}
110+
}

src/errorFormatting.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import { Mode, Messages, envVars } from '@salesforce/core';
8+
import { inspect } from 'node:util';
99
import type { Ansis } from 'ansis';
10+
import { Mode, Messages, envVars } from '@salesforce/core';
1011
import { StandardColors } from './ux/standardColors.js';
11-
import { SfCommandError } from './types.js';
12+
import { SfCommandError } from './SfCommandError.js';
1213

1314
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
1415
const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages');
@@ -45,7 +46,7 @@ export const formatError = (error: SfCommandError): string =>
4546
`${formatErrorPrefix(error)} ${error.message}`,
4647
...formatActions(error.actions ?? []),
4748
error.stack && envVars.getString('SF_ENV') === Mode.DEVELOPMENT
48-
? StandardColors.info(`\n*** Internal Diagnostic ***\n\n${error.stack}\n******\n`)
49+
? StandardColors.info(`\n*** Internal Diagnostic ***\n\n${inspect(error)}\n******\n`)
4950
: [],
5051
].join('\n');
5152

src/errorHandling.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77

88
import { SfError } from '@salesforce/core';
99
import { CLIError } from '@oclif/core/errors';
10-
import { SfCommandError } from './types.js';
11-
import { removeEmpty } from './util.js';
10+
import { SfCommandError } from './SfCommandError.js';
1211

1312
/**
1413
*
@@ -66,28 +65,6 @@ export const errorIsTypeError = (error: Error | SfError): boolean =>
6665
Boolean(error.stack?.includes('TypeError')) ||
6766
('cause' in error && error.cause instanceof Error && errorIsTypeError(error.cause));
6867

69-
export const errorToSfCommandError = (
70-
codeFromError: number,
71-
error: Error | SfError | SfCommandError,
72-
commandName: string
73-
): SfCommandError => ({
74-
...removeEmpty({
75-
code: codeFromError,
76-
actions: 'actions' in error ? error.actions : null,
77-
context: ('context' in error ? error.context : commandName) ?? commandName,
78-
commandName: ('commandName' in error ? error.commandName : commandName) ?? commandName,
79-
data: 'data' in error ? error.data : null,
80-
result: 'result' in error ? error.result : null,
81-
}),
82-
...{
83-
message: error.message,
84-
name: error.name ?? 'Error',
85-
status: codeFromError,
86-
stack: error.stack,
87-
exitCode: codeFromError,
88-
},
89-
});
90-
9168
/** custom typeGuard for handling the fact the SfCommand doesn't know about oclif error structure */
9269
const isOclifError = <T extends Error | SfError | SfCommandError>(e: T): e is T & CLIError =>
9370
'oclif' in e ? true : false;

src/sfCommand.ts

Lines changed: 11 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,10 @@ import type { AnyJson } from '@salesforce/ts-types';
2020
import { Progress } from './ux/progress.js';
2121
import { Spinner } from './ux/spinner.js';
2222
import { Ux } from './ux/ux.js';
23-
import { SfCommandError } from './types.js';
23+
import { SfCommandError } from './SfCommandError.js';
2424
import { formatActions, formatError } from './errorFormatting.js';
2525
import { StandardColors } from './ux/standardColors.js';
2626
import { confirm, secretPrompt, PromptInputs } from './ux/prompts.js';
27-
import { computeErrorCode, errorToSfCommandError } from './errorHandling.js';
2827

2928
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
3029
const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages');
@@ -201,7 +200,7 @@ export abstract class SfCommand<T> extends Command {
201200

202201
const colorizedArgs = [
203202
`${StandardColors.warning(messages.getMessage('warning.prefix'))} ${message}`,
204-
...formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info }),
203+
...formatActions(typeof input === 'string' ? [] : input.actions ?? []),
205204
];
206205

207206
this.logToStderr(colorizedArgs.join(os.EOL));
@@ -216,10 +215,9 @@ export abstract class SfCommand<T> extends Command {
216215
public info(input: SfCommand.Info): void {
217216
const message = typeof input === 'string' ? input : input.message;
218217
this.log(
219-
[
220-
`${StandardColors.info(message)}`,
221-
...formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info }),
222-
].join(os.EOL)
218+
[`${StandardColors.info(message)}`, ...formatActions(typeof input === 'string' ? [] : input.actions ?? [])].join(
219+
os.EOL
220+
)
223221
);
224222
}
225223

@@ -368,66 +366,27 @@ export abstract class SfCommand<T> extends Command {
368366
};
369367
}
370368

371-
/**
372-
* Wrap the command error into the standardized JSON structure.
373-
*/
374-
protected toErrorJson(error: SfCommand.Error): SfCommand.Error {
375-
return {
376-
...error,
377-
warnings: this.warnings,
378-
};
379-
}
380-
381369
// eslint-disable-next-line @typescript-eslint/require-await
382370
protected async catch(error: Error | SfError | SfCommand.Error): Promise<never> {
383371
// stop any spinners to prevent it from unintentionally swallowing output.
384372
// If there is an active spinner, it'll say "Error" instead of "Done"
385373
this.spinner.stop(StandardColors.error('Error'));
386-
// transform an unknown error into one that conforms to the interface
387374

388-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
389-
// const codeFromError = (error.oclif?.exit as number | undefined) ?? (error.exitCode as number | undefined) ?? 1;
390-
const codeFromError = computeErrorCode(error);
391-
process.exitCode = codeFromError;
392-
393-
const sfCommandError = errorToSfCommandError(codeFromError, error, this.statics.name);
375+
// transform an unknown error into a SfCommandError
376+
const sfCommandError = SfCommandError.from(error, this.statics.name, this.warnings);
377+
process.exitCode = sfCommandError.exitCode;
394378

395379
if (this.jsonEnabled()) {
396-
this.logJson(this.toErrorJson(sfCommandError));
380+
this.logJson(sfCommandError.toJson());
397381
} else {
398382
this.logToStderr(formatError(sfCommandError));
399383
}
400384

401-
// Create SfError that can be thrown
402-
const err = new SfError(
403-
error.message,
404-
error.name ?? 'Error',
405-
// @ts-expect-error because actions is not on Error
406-
(error.actions as string[]) ?? [],
407-
process.exitCode
408-
);
409-
if (sfCommandError.data) {
410-
err.data = sfCommandError.data as AnyJson;
411-
}
412-
err.context = sfCommandError.context;
413-
err.stack = sfCommandError.stack;
414-
// @ts-expect-error because code is not on SfError
415-
err.code = codeFromError;
416-
// @ts-expect-error because status is not on SfError
417-
err.status = sfCommandError.status;
418-
419-
// @ts-expect-error because skipOclifErrorHandling is not on SfError
420-
err.skipOclifErrorHandling = true;
421-
422-
// Add oclif exit code to the error so that oclif can use the exit code when exiting.
423-
// @ts-expect-error because oclif is not on SfError
424-
err.oclif = { exit: process.exitCode };
425-
426385
// Emit an event for plugin-telemetry prerun hook to pick up.
427386
// @ts-expect-error because TS is strict about the events that can be emitted on process.
428-
process.emit('sfCommandError', err, this.id);
387+
process.emit('sfCommandError', sfCommandError, this.id);
429388

430-
throw err;
389+
throw sfCommandError;
431390
}
432391

433392
public abstract run(): Promise<T>;

src/types.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

test/unit/errorFormatting.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { expect } from 'chai';
8+
import { Mode, SfError } from '@salesforce/core';
9+
import { formatError } from '../../src/errorFormatting.js';
10+
import { SfCommandError } from '../../src/SfCommandError.js';
11+
12+
describe('errorFormatting.formatError()', () => {
13+
afterEach(() => {
14+
delete process.env.SF_ENV;
15+
});
16+
17+
it('should have correct output for non-development mode, no actions', () => {
18+
const err = SfCommandError.from(new Error('this did not work'), 'thecommand');
19+
const errorOutput = formatError(err);
20+
expect(errorOutput).to.contain('Error (1)');
21+
expect(errorOutput).to.contain('this did not work');
22+
});
23+
24+
it('should have correct output for non-development mode, with actions', () => {
25+
const sfError = new SfError('this did not work', 'BadError');
26+
const err = SfCommandError.from(sfError, 'thecommand');
27+
err.actions = ['action1', 'action2'];
28+
const errorOutput = formatError(err);
29+
expect(errorOutput).to.contain('Error (BadError)');
30+
expect(errorOutput).to.contain('this did not work');
31+
expect(errorOutput).to.contain('Try this:');
32+
expect(errorOutput).to.contain('action1');
33+
expect(errorOutput).to.contain('action2');
34+
});
35+
36+
it('should have correct output for development mode, basic error', () => {
37+
process.env.SF_ENV = Mode.DEVELOPMENT;
38+
const err = SfCommandError.from(new SfError('this did not work'), 'thecommand');
39+
const errorOutput = formatError(err);
40+
expect(errorOutput).to.contain('Error (SfError)');
41+
expect(errorOutput).to.contain('this did not work');
42+
expect(errorOutput).to.contain('*** Internal Diagnostic ***');
43+
expect(errorOutput).to.contain('at Function.from');
44+
expect(errorOutput).to.contain('actions: undefined');
45+
expect(errorOutput).to.contain('exitCode: 1');
46+
expect(errorOutput).to.contain("context: 'thecommand'");
47+
expect(errorOutput).to.contain('data: undefined');
48+
expect(errorOutput).to.contain('cause: undefined');
49+
expect(errorOutput).to.contain('status: 1');
50+
expect(errorOutput).to.contain("commandName: 'thecommand'");
51+
expect(errorOutput).to.contain('warnings: undefined');
52+
expect(errorOutput).to.contain('result: undefined');
53+
});
54+
55+
it('should have correct output for development mode, full error', () => {
56+
process.env.SF_ENV = Mode.DEVELOPMENT;
57+
const sfError = SfError.create({
58+
name: 'WOMP_WOMP',
59+
message: 'this did not work',
60+
actions: ['action1', 'action2'],
61+
cause: new Error('this is the cause'),
62+
exitCode: 9,
63+
context: 'somecommand',
64+
data: { foo: 'bar' },
65+
});
66+
const err = SfCommandError.from(sfError, 'thecommand');
67+
const errorOutput = formatError(err);
68+
expect(errorOutput).to.contain('Error (WOMP_WOMP)');
69+
expect(errorOutput).to.contain('this did not work');
70+
expect(errorOutput).to.contain('*** Internal Diagnostic ***');
71+
expect(errorOutput).to.contain('at Function.from');
72+
expect(errorOutput).to.contain("actions: [ 'action1', 'action2' ]");
73+
expect(errorOutput).to.contain('exitCode: 9');
74+
expect(errorOutput).to.contain("context: 'somecommand'");
75+
expect(errorOutput).to.contain("data: { foo: 'bar' }");
76+
expect(errorOutput).to.contain('cause: Error: this is the cause');
77+
expect(errorOutput).to.contain('status: 9');
78+
expect(errorOutput).to.contain("commandName: 'thecommand'");
79+
expect(errorOutput).to.contain('warnings: undefined');
80+
expect(errorOutput).to.contain('result: undefined');
81+
});
82+
});

0 commit comments

Comments
 (0)