Skip to content

Commit

Permalink
feat: exit codes for gacks and type errors
Browse files Browse the repository at this point in the history
  • Loading branch information
mshanemc committed Nov 6, 2023
1 parent c626965 commit 0e626c7
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 66 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
"node": ">=16.0.0"
},
"dependencies": {
"@oclif/core": "^3.10.0",
"@salesforce/core": "^5.3.17",
"@oclif/core": "^3.10.6",
"@salesforce/core": "^5.3.18",
"@salesforce/kit": "^3.0.15",
"@salesforce/ts-types": "^2.0.9",
"chalk": "^4",
Expand All @@ -46,7 +46,7 @@
"@oclif/test": "^2.5.6",
"@salesforce/dev-scripts": "^6.0.3",
"@types/inquirer": "^8.2.3",
"eslint-plugin-sf-plugin": "^1.16.13",
"eslint-plugin-sf-plugin": "^1.16.14",
"shelljs": "0.8.5",
"strip-ansi": "6.0.1",
"ts-node": "^10.9.1",
Expand Down
85 changes: 85 additions & 0 deletions src/errorHandling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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 { Mode, SfError, Messages, envVars } from '@salesforce/core';
import { SfCommand, StandardColors } from './sfCommand';
import { formatActions } from './formatActions';

/**
*
* Takes an error and returns an exit code.
* Logic:
* - If it looks like a gack, use that code (20)
* - If it looks like a TypeError, use that code (10)
* - use the exitCode if it is a number
* - use the code if it is a number, or 1 if it is present not a number
* - use the process exitCode
* - default to 1
*/
export const computeErrorCode = (e: Error | SfError | SfCommand.Error): number => {
// regardless of the exitCode, we'll set gacks and TypeError to a specific exit code
if (errorIsGack(e)) {
return 20;
}

if (errorIsTypeError(e)) {
return 10;
}

if ('exitCode' in e && typeof e.exitCode === 'number') {
return e.exitCode;
}

if ('code' in e) {
return typeof e.code !== 'number' ? 1 : e.code;
}

return process.exitCode ?? 1;
};

/** identifies gacks via regex. Searches the error message, stack, and recursively checks the cause chain */
export const errorIsGack = (error: Error | SfError): boolean => {
/** see test for samples */
const gackRegex = /\d{9,}-\d{3,} \(-?\d{7,}\)/;
return (
gackRegex.test(error.message) ||
(typeof error.stack === 'string' && gackRegex.test(error.stack)) ||
// recurse down through the error cause tree to find a gack
('cause' in error && error.cause instanceof Error && errorIsGack(error.cause))
);
};

/**
* Format errors and actions for human consumption. Adds 'Error (<ErrorCode>):',
* When there are actions, we add 'Try this:' in blue
* followed by each action in red on its own line.
* If Error.code is present it is output last in parentheses
*
* @returns {string} Returns decorated messages.
*/
export const formatError = (error: SfCommand.Error): string => {
Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages');

const colorizedArgs: string[] = [];
const errorCode = typeof error.code === 'string' || typeof error.code === 'number' ? ` (${error.code})` : '';
const errorPrefix = `${StandardColors.error(messages.getMessage('error.prefix', [errorCode]))}`;
colorizedArgs.push(`${errorPrefix} ${error.message}`);
colorizedArgs.push(...formatActions(error.actions ?? []));
if (error.stack && envVars.getString(SfCommand.SF_ENV) === Mode.DEVELOPMENT) {
colorizedArgs.push(StandardColors.info(`\n*** Internal Diagnostic ***\n\n${error.stack}\n******\n`));
}
return colorizedArgs.join('\n');
};

/** identifies TypeError. Searches the error message, stack, and recursively checks the cause chain */
export const errorIsTypeError = (error: Error | SfError): boolean =>
error instanceof TypeError ||
error.name === 'TypeError' ||
error.message.includes('TypeError') ||
Boolean(error.stack?.includes('TypeError')) ||
('cause' in error && error.cause instanceof Error && errorIsTypeError(error.cause));
23 changes: 23 additions & 0 deletions src/formatActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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 * as chalk from 'chalk';
import { StandardColors, messages } from './sfCommand';

export const formatActions = (
actions: string[],
options: { actionColor: chalk.Chalk } = { actionColor: StandardColors.info }
): string[] => {
const colorizedArgs: string[] = [];
// Format any actions.
if (actions?.length) {
colorizedArgs.push(`\n${StandardColors.info(messages.getMessage('actions.tryThis'))}\n`);
actions.forEach((action) => {
colorizedArgs.push(`${options.actionColor(action)}`);
});
}
return colorizedArgs;
};
61 changes: 9 additions & 52 deletions src/sfCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ import {
SfProject,
StructuredMessage,
Lifecycle,
Mode,
EnvironmentVariable,
SfError,
ConfigAggregator,
} from '@salesforce/core';
import { AnyJson } from '@salesforce/ts-types';
import * as chalk from 'chalk';
import { Progress, Prompter, Spinner, Ux } from './ux';
import { formatActions } from './formatActions';
import { computeErrorCode, formatError } from './errorHandling';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages');
export const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages');

export interface SfCommandInterface extends Command.Class {
configurationVariablesSection?: HelpSection;
Expand Down Expand Up @@ -215,7 +216,7 @@ export abstract class SfCommand<T> extends Command {

colorizedArgs.push(`${StandardColors.warning(messages.getMessage('warning.prefix'))} ${message}`);
colorizedArgs.push(
...this.formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info })
...formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info })
);

this.logToStderr(colorizedArgs.join(os.EOL));
Expand All @@ -233,7 +234,7 @@ export abstract class SfCommand<T> extends Command {

colorizedArgs.push(`${StandardColors.info(message)}`);
colorizedArgs.push(
...this.formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info })
...formatActions(typeof input === 'string' ? [] : input.actions ?? [], { actionColor: StandardColors.info })
);

this.log(colorizedArgs.join(os.EOL));
Expand Down Expand Up @@ -419,10 +420,9 @@ export abstract class SfCommand<T> extends Command {
// 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

// @ts-expect-error because exitCode is not on Error
const codeFromError = (error.exitCode as number) ?? 1;
process.exitCode ??= codeFromError;
const codeFromError = computeErrorCode(error);
// this could be setting it to the same thing it was already, and that's ok.
process.exitCode = codeFromError;

const sfErrorProperties = removeEmpty({
code: codeFromError,
Expand All @@ -448,7 +448,7 @@ export abstract class SfCommand<T> extends Command {
if (this.jsonEnabled()) {
this.logJson(this.toErrorJson(sfCommandError));
} else {
this.logToStderr(this.formatError(sfCommandError));
this.logToStderr(formatError(sfCommandError));
}

// Create SfError that can be thrown
Expand Down Expand Up @@ -476,49 +476,6 @@ export abstract class SfCommand<T> extends Command {
throw err;
}

/**
* Format errors and actions for human consumption. Adds 'Error (<ErrorCode>):',
* When there are actions, we add 'Try this:' in blue
* followed by each action in red on its own line.
* If Error.code is present it is output last in parentheses
*
* @returns {string} Returns decorated messages.
*/
protected formatError(error: SfCommand.Error): string {
const colorizedArgs: string[] = [];
const errorCode = typeof error.code === 'string' || typeof error.code === 'number' ? ` (${error.code})` : '';
const errorPrefix = `${StandardColors.error(messages.getMessage('error.prefix', [errorCode]))}`;
colorizedArgs.push(`${errorPrefix} ${error.message}`);
colorizedArgs.push(...this.formatActions(error.actions ?? []));
if (error.stack && envVars.getString(SfCommand.SF_ENV) === Mode.DEVELOPMENT) {
colorizedArgs.push(StandardColors.info(`\n*** Internal Diagnostic ***\n\n${error.stack}\n******\n`));
}
return colorizedArgs.join('\n');
}

/**
* Utility function to format actions lines
*
* @param actions
* @param options
* @private
*/
// eslint-disable-next-line class-methods-use-this
private formatActions(
actions: string[],
options: { actionColor: chalk.Chalk } = { actionColor: StandardColors.info }
): string[] {
const colorizedArgs: string[] = [];
// Format any actions.
if (actions?.length) {
colorizedArgs.push(`\n${StandardColors.info(messages.getMessage('actions.tryThis'))}\n`);
actions.forEach((action) => {
colorizedArgs.push(`${options.actionColor(action)}`);
});
}
return colorizedArgs;
}

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

Expand Down
70 changes: 70 additions & 0 deletions test/unit/errorHandling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 { SfError } from '@salesforce/core';
import { errorIsGack, errorIsTypeError } from '../../src/errorHandling';

describe('typeErrors', () => {
let typeError: Error;

before(() => {
try {
const n = null;
// @ts-expect-error I know it's wrong, I need an error!
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
n.f();
} catch (e) {
if (e instanceof TypeError) {
typeError = e;
}
}
});
it('matches on TypeError as error name', () => {
expect(errorIsTypeError(typeError)).to.be.true;
});

it('matches on TypeError in ', () => {
expect(errorIsTypeError(typeError)).to.be.true;
});

it('matches on TypeError as cause', () => {
const error = new SfError('some error', 'testError', [], 44, typeError);
expect(errorIsTypeError(error)).to.be.true;
});
});
describe('gacks', () => {
const realGackSamples = [
'963190677-320016 (165202460)',
'1662648879-55786 (-1856191902)',
'694826414-169428 (2085174272)',
'1716315817-543601 (74920438)',
'1035887602-340708 (1781437152)',
'671603042-121307 (-766503277)',
'396937656-5973 (-766503277)',
'309676439-91665 (-153174221)',
'956661320-295482 (2000727581)',
'1988392611-333742 (1222029414)',
'1830254280-281143 (331700540)',
];

it('says true for sample gacks', () => {
realGackSamples.forEach((gack) => {
expect(errorIsGack(new SfError(gack))).to.be.true;
});
});

it('error in stack', () => {
const error = new SfError('some error');
error.stack = realGackSamples[0];
expect(errorIsGack(error)).to.be.true;
});

it('error in sfError cause', () => {
const error = new SfError('some error', 'testError', [], 44, new Error(realGackSamples[0]));
expect(errorIsGack(error)).to.be.true;
});
});
46 changes: 35 additions & 11 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -557,10 +557,10 @@
wordwrap "^1.0.0"
wrap-ansi "^7.0.0"

"@oclif/core@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@oclif/core/-/core-3.10.0.tgz#d9aac186b9300b448479fdee75f1774b4ff4626f"
integrity sha512-gXats+3wTQRztZS2gy8rcZgkKqLY/FexCzcAkNe8Z4E+268hagAHMFxA8tsiUcOKaywgQAL55TCHD1bgni54GA==
"@oclif/core@^3.10.6":
version "3.10.6"
resolved "https://registry.yarnpkg.com/@oclif/core/-/core-3.10.6.tgz#e8489349d87ee25b608c2e7a3e86906dbb2818f0"
integrity sha512-A752i/hnPCiW9+9SIk6y3RZYxY8l28hJK1i6tLN2CiaiOV4TRhuOYPVQCI8pcSsNq0MJpcXSzU1YvsXXQ1/E5g==
dependencies:
ansi-escapes "^4.3.2"
ansi-styles "^4.3.0"
Expand Down Expand Up @@ -597,7 +597,7 @@
"@oclif/core" "^2.15.0"
fancy-test "^2.0.42"

"@salesforce/core@^5.3.14", "@salesforce/core@^5.3.17":
"@salesforce/core@^5.3.17":
version "5.3.17"
resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-5.3.17.tgz#fe7e5b89bdfffc9c1634e0371fab6115b23fe312"
integrity sha512-3BYdpRwQrtaTNHINk+NSrXwlr0xZjKSkJWhMeryjGyTGf9YPRu1JNawl6PPbEpGCQp2y+NLUdp6vMy64jwIDBg==
Expand All @@ -621,6 +621,30 @@
semver "^7.5.4"
ts-retry-promise "^0.7.1"

"@salesforce/core@^5.3.18":
version "5.3.18"
resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-5.3.18.tgz#c0b7b59fbef7f0689e88968c614dd1ae2c420f02"
integrity sha512-/Ag7elFngTT13PRblSPJPB2Q+xk3jR2SX8bYa83fcQljVF7ApGB5qtFpauXmUv8lgRnN+F01HNqM16iszAMP9w==
dependencies:
"@salesforce/kit" "^3.0.15"
"@salesforce/schemas" "^1.6.1"
"@salesforce/ts-types" "^2.0.9"
"@types/semver" "^7.5.4"
ajv "^8.12.0"
change-case "^4.1.2"
faye "^1.4.0"
form-data "^4.0.0"
js2xmlparser "^4.0.1"
jsforce "^2.0.0-beta.28"
jsonwebtoken "9.0.2"
jszip "3.10.1"
pino "^8.16.0"
pino-abstract-transport "^1.0.0"
pino-pretty "^10.2.3"
proper-lockfile "^4.1.2"
semver "^7.5.4"
ts-retry-promise "^0.7.1"

"@salesforce/dev-config@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@salesforce/dev-config/-/dev-config-4.0.1.tgz#662ffaa4409713553aaf68eed93e7d2429c3ff0e"
Expand Down Expand Up @@ -807,7 +831,7 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==

"@types/semver@^7.3.12", "@types/semver@^7.5.0", "@types/semver@^7.5.3":
"@types/semver@^7.3.12", "@types/semver@^7.5.0", "@types/semver@^7.5.3", "@types/semver@^7.5.4":
version "7.5.4"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.4.tgz#0a41252ad431c473158b22f9bfb9a63df7541cff"
integrity sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==
Expand Down Expand Up @@ -2101,12 +2125,12 @@ eslint-plugin-jsdoc@^46.8.2:
semver "^7.5.4"
spdx-expression-parse "^3.0.1"

eslint-plugin-sf-plugin@^1.16.13:
version "1.16.13"
resolved "https://registry.yarnpkg.com/eslint-plugin-sf-plugin/-/eslint-plugin-sf-plugin-1.16.13.tgz#e3ba9ee0014c96b414af67c6aa9e8578451a01fd"
integrity sha512-Cj8r7GXrSrQ7iia78sBOGZH7VFa2/7wl5a3dtoVyIx3bp/Oq7P1yOSPofg13bdH2gZr4O+/3JNxXXvj+kqvE9A==
eslint-plugin-sf-plugin@^1.16.14:
version "1.16.14"
resolved "https://registry.yarnpkg.com/eslint-plugin-sf-plugin/-/eslint-plugin-sf-plugin-1.16.14.tgz#64138f6c597ad7b750d9d7615894e2fe504852ec"
integrity sha512-numvHHhJjExz4ojxK3O25G8Vh8pXtMgZzwEaKGGsKaOJFm4rmSS2NabmfkRPYX2NCO/xn4eNHm1iGTnnQywGvg==
dependencies:
"@salesforce/core" "^5.3.14"
"@salesforce/core" "^5.3.17"
"@typescript-eslint/utils" "^5.59.11"

eslint-plugin-unicorn@^49.0.0:
Expand Down

0 comments on commit 0e626c7

Please sign in to comment.