diff --git a/lib/errors.ts b/lib/errors.ts index 1833b56e..6880c40d 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -21,6 +21,9 @@ import { TypedError } from 'typed-error'; import { getChalk } from './utils/lazy'; import { getHelp } from './utils/messages'; +// Support stubbing of module functions. +let ErrorsModule: any; + export class ExpectedError extends TypedError {} export class NotLoggedInError extends ExpectedError {} @@ -135,11 +138,17 @@ const EXPECTED_ERROR_REGEXES = [ /^BalenaAmbiguousApplication:/, // balena-sdk /^BalenaApplicationNotFound:/, // balena-sdk /^BalenaDeviceNotFound:/, // balena-sdk + /^BalenaExpiredToken:/, // balena-sdk /^Missing \w+$/, // Capitano, oclif parser: RequiredArgsError, RequiredFlagError - /^Unexpected arguments?:/, // oclif parser: UnexpectedArgsError + /^Unexpected argument/, // oclif parser: UnexpectedArgsError /to be one of/, // oclif parser: FlagInvalidOptionError, ArgInvalidOptionError ]; +// Support unit testing of handleError +async function getSentry() { + return await import('@sentry/node'); +} + export async function handleError(error: any) { // Set appropriate exitCode process.exitCode = @@ -149,20 +158,15 @@ export async function handleError(error: any) { // Handle non-Error objects (probably strings) if (!(error instanceof Error)) { - printErrorMessage(String(error)); + ErrorsModule.printErrorMessage(String(error)); return; } // Prepare message const message = [interpret(error)]; - if (error.stack) { - if (process.env.DEBUG) { - message.push('\n' + error.stack); - } else { - // Include first line of stacktrace - message.push('\n' + error.stack.split(`\n`)[0]); - } + if (error.stack && process.env.DEBUG) { + message.push('\n' + error.stack); } // Expected? @@ -172,14 +176,20 @@ export async function handleError(error: any) { // Output/report error if (isExpectedError) { - printExpectedErrorMessage(message.join('\n')); + ErrorsModule.printExpectedErrorMessage(message.join('\n')); } else { - printErrorMessage(message.join('\n')); + ErrorsModule.printErrorMessage(message.join('\n')); // Report "unexpected" errors via Sentry.io - const Sentry = await import('@sentry/node'); + const Sentry = await ErrorsModule.getSentry(); Sentry.captureException(error); - await Sentry.close(1000); + try { + await Sentry.close(1000); + } catch (e) { + if (process.env.DEBUG) { + console.error('Timeout reporting error to sentry.io'); + } + } // Unhandled/unexpected error: ensure that the process terminates. // The exit error code was set above through `process.exitCode`. process.exit(); @@ -223,3 +233,14 @@ export function exitWithExpectedError(message: string | Error): never { printErrorMessage(message); process.exit(1); } + +// Support stubbing of module functions. +export default ErrorsModule = { + ExpectedError, + NotLoggedInError, + getSentry, + handleError, + printErrorMessage, + printExpectedErrorMessage, + exitWithExpectedError, +}; diff --git a/package.json b/package.json index bbfa3267..5408f5af 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "pretest": "npm run build", "test": "mocha --timeout 6000 -r ts-node/register/transpile-only \"tests/**/*.spec.ts\"", "test:fast": "npm run build:fast && mocha --timeout 6000 -r ts-node/register/transpile-only \"tests/**/*.spec.ts\"", + "test:only": "npm run build:fast && mocha --timeout 6000 -r ts-node/register/transpile-only \"tests/**/${npm_config_test}.spec.ts\"", "catch-uncommitted": "ts-node --transpile-only automation/run.ts catch-uncommitted", "ci": "npm run test && npm run catch-uncommitted", "watch": "gulp watch", diff --git a/tests/errors.spec.ts b/tests/errors.spec.ts new file mode 100644 index 00000000..620edc61 --- /dev/null +++ b/tests/errors.spec.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import ErrorsModule from '../build/errors'; +import { getHelp } from '../build/utils/messages'; + +function red(s: string) { + if (process.env.CI) { + // If CI, don't color. + return s; + } + return `\u001b[31m${s}\u001b[39m`; +} + +describe('handleError() function', () => { + const sandbox = sinon.createSandbox(); + let printErrorMessage: any; + let printExpectedErrorMessage: any; + let captureException: any; + let processExit: any; + + beforeEach(() => { + printErrorMessage = sandbox.stub(ErrorsModule, 'printErrorMessage'); + printExpectedErrorMessage = sandbox.stub( + ErrorsModule, + 'printExpectedErrorMessage', + ); + captureException = sinon.stub(); + // @ts-ignore + sandbox.stub(ErrorsModule, 'getSentry').resolves({ captureException }); + processExit = sandbox.stub(process, 'exit'); + + // Force debug mode off (currently set to true in CI env) + sandbox.stub(process, 'env').value({ DEBUG: false }); + }); + afterEach(() => { + sandbox.restore(); + }); + + it('should call printErrorMessage and exit when passed a string', async () => { + const errorString = 'a string'; + + await ErrorsModule.handleError(errorString); + + expect(printErrorMessage.calledOnce).to.be.true; + expect(printErrorMessage.getCall(0).args[0]).to.equal(errorString); + + expect(printExpectedErrorMessage.notCalled).to.be.true; + expect(captureException.notCalled).to.be.true; + expect(processExit.notCalled).to.be.true; + }); + + it('should process ExpectedErrors as expected', async () => { + const errorMessage = 'an expected error'; + const error = new ErrorsModule.ExpectedError(errorMessage); + + await ErrorsModule.handleError(error); + + expect(printExpectedErrorMessage.calledOnce).to.be.true; + expect(printExpectedErrorMessage.getCall(0).args[0]).to.equal(errorMessage); + + expect(printErrorMessage.notCalled).to.be.true; + expect(captureException.notCalled).to.be.true; + expect(processExit.notCalled).to.be.true; + }); + + it('should process subclasses of ExpectedErrors as expected', async () => { + const errorMessage = 'an expected error'; + const error = new ErrorsModule.NotLoggedInError(errorMessage); + + await ErrorsModule.handleError(error); + + expect(printExpectedErrorMessage.calledOnce).to.be.true; + expect(printExpectedErrorMessage.getCall(0).args[0]).to.equal(errorMessage); + + expect(printErrorMessage.notCalled).to.be.true; + expect(captureException.notCalled).to.be.true; + expect(processExit.notCalled).to.be.true; + }); + + it('should process unexpected errors correctly (no debug)', async () => { + const errorMessage = 'an unexpected error'; + await ErrorsModule.handleError(new Error(errorMessage)); + + expect(printErrorMessage.calledOnce).to.be.true; + expect(printErrorMessage.getCall(0).args[0]).to.equal(errorMessage); + expect(captureException.calledOnce).to.be.true; + expect(processExit.calledOnce).to.be.true; + + expect(printExpectedErrorMessage.notCalled); + }); + + it('should process unexpected errors correctly (debug)', async () => { + sandbox.stub(process, 'env').value({ DEBUG: true }); + + const errorMessage = 'an unexpected error'; + const error = new Error(errorMessage); + await ErrorsModule.handleError(error); + + const expectedMessage = errorMessage + '\n\n' + error.stack; + + expect(printErrorMessage.calledOnce).to.be.true; + expect(printErrorMessage.getCall(0).args[0]).to.equal(expectedMessage); + expect(captureException.calledOnce).to.be.true; + expect(processExit.calledOnce).to.be.true; + + expect(printExpectedErrorMessage.notCalled); + }); + + const messagesToMatch = [ + 'BalenaAmbiguousApplication:', + 'BalenaApplicationNotFound:', + 'BalenaDeviceNotFound:', + 'BalenaExpiredToken:', + 'Missing argument', + 'Missing arguments', + 'Unexpected argument', + 'Unexpected arguments', + 'to be one of', + ]; + + messagesToMatch.forEach(message => { + it(`should match as expected: "${message}"`, async () => { + await ErrorsModule.handleError(new Error(message)); + + expect( + printExpectedErrorMessage.calledOnce, + `Pattern not expected: ${message}`, + ).to.be.true; + + expect(printErrorMessage.notCalled).to.be.true; + expect(captureException.notCalled).to.be.true; + expect(processExit.notCalled).to.be.true; + }); + }); +}); + +describe('printErrorMessage() function', () => { + it('should correctly output message', () => { + const consoleError = sinon.spy(console, 'error'); + + const errorMessageLines = [ + 'first line should be red', + 'second line should not be red', + 'third line should not be red', + ]; + + const inputMessage = errorMessageLines.join('\n'); + const expectedOutputMessages = [ + red(errorMessageLines[0]), + errorMessageLines[1], + errorMessageLines[2], + ]; + + ErrorsModule.printErrorMessage(inputMessage); + + expect(consoleError.callCount).to.equal(4); + expect(consoleError.getCall(0).args[0]).to.equal(expectedOutputMessages[0]); + expect(consoleError.getCall(1).args[0]).to.equal(expectedOutputMessages[1]); + expect(consoleError.getCall(2).args[0]).to.equal(expectedOutputMessages[2]); + expect(consoleError.getCall(3).args[0]).to.equal(`\n${getHelp}\n`); + + consoleError.restore(); + }); +}); + +describe('printExpectedErrorMessage() function', () => { + it('should correctly output message', () => { + const consoleError = sinon.spy(console, 'error'); + + const errorMessage = ['first line', 'second line'].join('\n'); + + ErrorsModule.printExpectedErrorMessage(errorMessage); + + expect(consoleError.calledOnce).to.be.true; + expect(consoleError.getCall(0).args[0]).to.equal(errorMessage + '\n'); + + consoleError.restore(); + }); +});