diff --git a/lib/child-writer/writer-proxy.js b/lib/child-writer/writer-proxy.js index 25bdb657..63fec58e 100644 --- a/lib/child-writer/writer-proxy.js +++ b/lib/child-writer/writer-proxy.js @@ -26,6 +26,7 @@ const path = require('path'); const sudoPrompt = Bluebird.promisifyAll(require('sudo-prompt')); const utils = require('./utils'); const EXIT_CODES = require('../shared/exit-codes'); +const errors = require('../shared/errors'); const packageJSON = require('../../package.json'); // This script is in charge of spawning the writer process and @@ -151,7 +152,7 @@ return isElevated().then((elevated) => { name: packageJSON.displayName }).then((stdout, stderr) => { if (!_.isEmpty(stderr)) { - throw new Error(stderr); + throw errors.createError(stderr); } }).catch({ message: 'User did not grant permission.' diff --git a/lib/cli/errors.js b/lib/cli/errors.js deleted file mode 100644 index 6aa76801..00000000 --- a/lib/cli/errors.js +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2016 resin.io - * - * 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. - */ - -'use strict'; - -const _ = require('lodash'); -const chalk = require('chalk'); - -/** - * @summary Human-friendly error messages - * @namespace HUMAN_FRIENDLY - * @public - */ -exports.HUMAN_FRIENDLY = { - - /* eslint-disable new-cap */ - - /** - * @property {Function} ENOENT - * @memberof HUMAN_FRIENDLY - * @param {Error} error - error object - * @returns {String} message - */ - ENOENT: (error) => { - return `No such file or directory: ${error.path}`; - }, - - /** - * @property {Function} EPERM - * @memberof HUMAN_FRIENDLY - * @returns {String} message - */ - EPERM: _.constant('You\'re not authorized to perform this operation'), - - /** - * @property {Function} EACCES - * @memberof HUMAN_FRIENDLY - * @returns {String} message - */ - EACCES: _.constant('You don\'t have access to this resource') - - /* eslint-enable new-cap */ - -}; - -/** - * @summary Get default error message - * @function - * @private - * - * @param {Error} error - error - * @returns {String} error message - * - * @example - * const message = defaultMessageGetter(new Error('foo bar')); - * console.log(message); - * > 'foo bar' - * - * @example - * const message = defaultMessageGetter(new Error()); - * console.log(message); - * > 'Unknown error' - */ -const defaultMessageGetter = (error) => { - return error.message || 'Unknown error'; -}; - -/** - * @summary Get error message - * @function - * @public - * - * @param {(String|Error)} error - error - * @returns {String} error message - * - * @example - * const error = new Error('Foo bar'); - * error.description = 'This is a fake error'; - * - * console.log(errors.getErrorMessage(error)); - * > 'Foo bar\n\nThis is a fake error' - */ -exports.getErrorMessage = (error) => { - if (_.isString(error)) { - return exports.getErrorMessage(new Error(error)); - } - - const message = _.attempt(() => { - const title = _.get(exports.HUMAN_FRIENDLY, error.code, defaultMessageGetter)(error); - return error.code ? `${error.code}: ${title}` : title; - }); - - if (error.description) { - return `${message}\n\n${error.description}`; - } - - return message; -}; - -/** - * @summary Print an error to stderr - * @function - * @public - * - * @param {(Error|String)} error - error - * - * @example - * errors.print(new Error('Oops!')); - */ -exports.print = (error) => { - const message = exports.getErrorMessage(error); - console.error(chalk.red(message)); -}; diff --git a/lib/cli/etcher.js b/lib/cli/etcher.js index 876d731d..faa9c08e 100644 --- a/lib/cli/etcher.js +++ b/lib/cli/etcher.js @@ -23,18 +23,22 @@ const visuals = require('resin-cli-visuals'); const form = require('resin-cli-form'); const drivelist = Bluebird.promisifyAll(require('drivelist')); const writer = require('./writer'); -const errors = require('./errors'); +const utils = require('./utils'); const options = require('./options'); const robot = require('../shared/robot'); const messages = require('../shared/messages'); const EXIT_CODES = require('../shared/exit-codes'); +const errors = require('../shared/errors'); const ARGV_IMAGE_PATH_INDEX = 0; const imagePath = options._[ARGV_IMAGE_PATH_INDEX]; isElevated().then((elevated) => { if (!elevated) { - throw new Error(messages.error.elevationRequired()); + throw errors.createUserError( + messages.error.elevationRequired(), + 'This tool requires special permissions to write to external drives' + ); } return form.run([ @@ -62,7 +66,7 @@ isElevated().then((elevated) => { }); }).then((answers) => { if (!answers.yes) { - throw new Error('Aborted'); + throw errors.createUserError('Aborted', 'We can\'t proceed without confirmation'); } const progressBars = { @@ -76,7 +80,7 @@ isElevated().then((elevated) => { }); if (!selectedDrive) { - throw new Error(`Drive not found: ${answers.drive}`); + throw errors.createUserError('Drive not found', `${answers.drive} doesn't exist`); } return writer.writeImage(imagePath, selectedDrive, { @@ -124,7 +128,7 @@ isElevated().then((elevated) => { return robot.printError(error); } - errors.print(error); + utils.printError(error); return Bluebird.resolve(); }).then(() => { if (error.code === 'EVALIDATION') { diff --git a/lib/cli/options.js b/lib/cli/options.js index 85af5cd5..2d9b2c9a 100644 --- a/lib/cli/options.js +++ b/lib/cli/options.js @@ -19,9 +19,10 @@ const _ = require('lodash'); const fs = require('fs'); const yargs = require('yargs'); -const errors = require('./errors'); +const utils = require('./utils'); const robot = require('../shared/robot'); const EXIT_CODES = require('../shared/exit-codes'); +const errors = require('../shared/errors'); const packageJSON = require('../../package.json'); /** @@ -92,11 +93,13 @@ module.exports = yargs // Error reporting .fail((message, error) => { + const errorObject = error || errors.createUserError(message); + if (robot.isEnabled(process.env)) { - robot.printError(error || message); + robot.printError(errorObject); } else { yargs.showHelp(); - errors.print(error || message); + utils.printError(errorObject); } process.exit(EXIT_CODES.GENERAL_ERROR); @@ -110,7 +113,10 @@ module.exports = yargs .check((argv) => { if (robot.isEnabled(process.env) && !argv.drive) { - throw new Error('Missing drive'); + throw errors.createUserError( + 'Missing drive', + 'You need to explicitly pass a drive when enabling robot mode' + ); } return true; diff --git a/lib/cli/utils.js b/lib/cli/utils.js new file mode 100644 index 00000000..e8f26dbd --- /dev/null +++ b/lib/cli/utils.js @@ -0,0 +1,40 @@ +/* + * Copyright 2016 resin.io + * + * 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. + */ + +'use strict'; + +const chalk = require('chalk'); +const errors = require('../shared/errors'); + +/** + * @summary Print an error to stderr + * @function + * @public + * + * @param {Error} error - error + * + * @example + * utils.printError(new Error('Oops!')); + */ +exports.printError = (error) => { + const title = errors.getTitle(error); + const description = errors.getDescription(error); + + console.error(chalk.red(title)); + if (description) { + console.error(`\n${chalk.red(description)}`); + } +}; diff --git a/lib/gui/models/store.js b/lib/gui/models/store.js index bffaf680..3c5756fa 100644 --- a/lib/gui/models/store.js +++ b/lib/gui/models/store.js @@ -21,6 +21,7 @@ const _ = require('lodash'); const redux = require('redux'); const persistState = require('redux-localstorage'); const constraints = require('../../shared/drive-constraints'); +const errors = require('../../shared/errors'); /** * @summary Application default state @@ -118,11 +119,11 @@ const storeReducer = (state = DEFAULT_STATE, action) => { case ACTIONS.SET_AVAILABLE_DRIVES: { if (!action.data) { - throw new Error('Missing drives'); + throw errors.createError('Missing drives'); } if (!_.isArray(action.data) || !_.every(action.data, _.isPlainObject)) { - throw new Error(`Invalid drives: ${action.data}`); + throw errors.createError(`Invalid drives: ${action.data}`); } const newState = state.set('availableDrives', Immutable.fromJS(action.data)); @@ -169,35 +170,35 @@ const storeReducer = (state = DEFAULT_STATE, action) => { case ACTIONS.SET_FLASH_STATE: { if (!state.get('isFlashing')) { - throw new Error('Can\'t set the flashing state when not flashing'); + throw errors.createError('Can\'t set the flashing state when not flashing'); } if (!action.data.type) { - throw new Error('Missing state type'); + throw errors.createError('Missing state type'); } if (!_.isString(action.data.type)) { - throw new Error(`Invalid state type: ${action.data.type}`); + throw errors.createError(`Invalid state type: ${action.data.type}`); } if (_.isNil(action.data.percentage)) { - throw new Error('Missing state percentage'); + throw errors.createError('Missing state percentage'); } if (!_.isNumber(action.data.percentage)) { - throw new Error(`Invalid state percentage: ${action.data.percentage}`); + throw errors.createError(`Invalid state percentage: ${action.data.percentage}`); } if (_.isNil(action.data.eta)) { - throw new Error('Missing state eta'); + throw errors.createError('Missing state eta'); } if (!_.isNumber(action.data.eta)) { - throw new Error(`Invalid state eta: ${action.data.eta}`); + throw errors.createError(`Invalid state eta: ${action.data.eta}`); } if (_.isNil(action.data.speed)) { - throw new Error('Missing state speed'); + throw errors.createError('Missing state speed'); } return state.set('flashState', Immutable.fromJS(action.data)); @@ -217,7 +218,7 @@ const storeReducer = (state = DEFAULT_STATE, action) => { case ACTIONS.UNSET_FLASHING_FLAG: { if (!action.data) { - throw new Error('Missing results'); + throw errors.createError('Missing results'); } _.defaults(action.data, { @@ -225,19 +226,19 @@ const storeReducer = (state = DEFAULT_STATE, action) => { }); if (!_.isBoolean(action.data.cancelled)) { - throw new Error(`Invalid results cancelled: ${action.data.cancelled}`); + throw errors.createError(`Invalid results cancelled: ${action.data.cancelled}`); } if (action.data.cancelled && action.data.sourceChecksum) { - throw new Error('The sourceChecksum value can\'t exist if the flashing was cancelled'); + throw errors.createError('The sourceChecksum value can\'t exist if the flashing was cancelled'); } if (action.data.sourceChecksum && !_.isString(action.data.sourceChecksum)) { - throw new Error(`Invalid results sourceChecksum: ${action.data.sourceChecksum}`); + throw errors.createError(`Invalid results sourceChecksum: ${action.data.sourceChecksum}`); } if (action.data.errorCode && !_.isString(action.data.errorCode) && !_.isNumber(action.data.errorCode)) { - throw new Error(`Invalid results errorCode: ${action.data.errorCode}`); + throw errors.createError(`Invalid results errorCode: ${action.data.errorCode}`); } return state @@ -248,26 +249,26 @@ const storeReducer = (state = DEFAULT_STATE, action) => { case ACTIONS.SELECT_DRIVE: { if (!action.data) { - throw new Error('Missing drive'); + throw errors.createError('Missing drive'); } if (!_.isString(action.data)) { - throw new Error(`Invalid drive: ${action.data}`); + throw errors.createError(`Invalid drive: ${action.data}`); } const selectedDrive = findDrive(state, action.data); if (!selectedDrive) { - throw new Error(`The drive is not available: ${action.data}`); + throw errors.createError(`The drive is not available: ${action.data}`); } if (selectedDrive.get('protected')) { - throw new Error('The drive is write-protected'); + throw errors.createError('The drive is write-protected'); } const image = state.getIn([ 'selection', 'image' ]); if (image && !constraints.isDriveLargeEnough(selectedDrive.toJS(), image.toJS())) { - throw new Error('The drive is not large enough'); + throw errors.createError('The drive is not large enough'); } return state.setIn([ 'selection', 'drive' ], Immutable.fromJS(action.data)); @@ -275,31 +276,31 @@ const storeReducer = (state = DEFAULT_STATE, action) => { case ACTIONS.SELECT_IMAGE: { if (!action.data.path) { - throw new Error('Missing image path'); + throw errors.createError('Missing image path'); } if (!_.isString(action.data.path)) { - throw new Error(`Invalid image path: ${action.data.path}`); + throw errors.createError(`Invalid image path: ${action.data.path}`); } if (!action.data.size) { - throw new Error('Missing image size'); + throw errors.createError('Missing image size'); } if (!_.isNumber(action.data.size)) { - throw new Error(`Invalid image size: ${action.data.size}`); + throw errors.createError(`Invalid image size: ${action.data.size}`); } if (action.data.url && !_.isString(action.data.url)) { - throw new Error(`Invalid image url: ${action.data.url}`); + throw errors.createError(`Invalid image url: ${action.data.url}`); } if (action.data.name && !_.isString(action.data.name)) { - throw new Error(`Invalid image name: ${action.data.name}`); + throw errors.createError(`Invalid image name: ${action.data.name}`); } if (action.data.logo && !_.isString(action.data.logo)) { - throw new Error(`Invalid image logo: ${action.data.logo}`); + throw errors.createError(`Invalid image logo: ${action.data.logo}`); } const selectedDrive = findDrive(state, state.getIn([ 'selection', 'drive' ])); @@ -331,19 +332,19 @@ const storeReducer = (state = DEFAULT_STATE, action) => { const value = action.data.value; if (!key) { - throw new Error('Missing setting key'); + throw errors.createError('Missing setting key'); } if (!_.isString(key)) { - throw new Error(`Invalid setting key: ${key}`); + throw errors.createError(`Invalid setting key: ${key}`); } if (!DEFAULT_STATE.get('settings').has(key)) { - throw new Error(`Unsupported setting: ${key}`); + throw errors.createError(`Unsupported setting: ${key}`); } if (_.isObject(value)) { - throw new Error(`Invalid setting value: ${value}`); + throw errors.createError(`Invalid setting value: ${value}`); } return state.setIn([ 'settings', key ], value); diff --git a/lib/gui/modules/analytics.js b/lib/gui/modules/analytics.js index 5727000c..0cc3b0dd 100644 --- a/lib/gui/modules/analytics.js +++ b/lib/gui/modules/analytics.js @@ -25,6 +25,7 @@ const angular = require('angular'); const username = require('username'); const isRunningInAsar = require('electron-is-running-in-asar'); const app = require('electron').remote.app; +const errors = require('../../shared/errors'); const packageJSON = require('../../../package.json'); // Force Mixpanel snippet to load Mixpanel locally @@ -139,29 +140,6 @@ analytics.service('AnalyticsService', function($log, $window, $mixpanel, Setting this.logDebug(debugMessage); }; - /** - * @summary Check whether an error should be reported to TrackJS - * @function - * @private - * - * @description - * In order to determine whether the error should be reported, we - * check a property called `report`. For backwards compatibility, and - * to properly handle errors that we don't control, an error without - * this property is reported automatically. - * - * @param {Error} error - error - * @returns {Boolean} whether the error should be reported - * - * @example - * if (AnalyticsService.shouldReportError(new Error('foo'))) { - * console.log('We should report this error'); - * } - */ - this.shouldReportError = (error) => { - return !_.has(error, [ 'report' ]) || Boolean(error.report); - }; - /** * @summary Log an exception * @function @@ -179,7 +157,7 @@ analytics.service('AnalyticsService', function($log, $window, $mixpanel, Setting if (_.every([ SettingsModel.get('errorReporting'), isRunningInAsar(), - this.shouldReportError(exception) + errors.shouldReport(exception) ])) { $window.trackJs.track(exception); } diff --git a/lib/gui/modules/error.js b/lib/gui/modules/error.js index 298713cd..06bce31b 100644 --- a/lib/gui/modules/error.js +++ b/lib/gui/modules/error.js @@ -46,7 +46,7 @@ error.service('ErrorService', function(AnalyticsService, OSDialogService) { return; } - OSDialogService.showError(exception, exception.description); + OSDialogService.showError(exception); AnalyticsService.logException(exception); }; diff --git a/lib/gui/os/dialog/services/dialog.js b/lib/gui/os/dialog/services/dialog.js index d1887bf6..013f0901 100644 --- a/lib/gui/os/dialog/services/dialog.js +++ b/lib/gui/os/dialog/services/dialog.js @@ -19,6 +19,7 @@ const _ = require('lodash'); const electron = require('electron'); const imageStream = require('../../../../image-stream'); +const errors = require('../../../../shared/errors'); module.exports = function($q, SupportedFormatsModel) { @@ -146,44 +147,15 @@ module.exports = function($q, SupportedFormatsModel) { * @function * @public * - * @param {(Error|String)} error - error - * @param {String} [description] - error description + * @param {Error} error - error * * @example * OSDialogService.showError(new Error('Foo Bar')); - * - * @example - * OSDialogService.showError(new Error('Foo Bar'), 'A custom description'); - * - * @example - * OSDialogService.showError('Foo Bar', 'An error happened!'); */ - this.showError = (error, description) => { - const errorObject = error || {}; - - // Try to get as most information as possible about the error - // rather than falling back to generic messages right away. - const title = _.attempt(() => { - if (_.isString(errorObject)) { - return errorObject; - } - - return errorObject.message || errorObject.code || 'An error ocurred'; - }); - - const message = description || errorObject.stack || JSON.stringify(errorObject) || ''; - - // Ensure the parameters are strings to prevent the following - // types of obscure errors: - // - // Error: Could not call remote function ''. - // Check that the function signature is correct. - // Underlying error: - // Error processing argument at index 0, conversion failure - // - // This can be thrown if for some reason, either `title` or `message` - // are not strings. - electron.remote.dialog.showErrorBox(title.toString(), message.toString()); + this.showError = (error) => { + const title = errors.getTitle(error); + const message = errors.getDescription(error); + electron.remote.dialog.showErrorBox(title, message); }; }; diff --git a/lib/gui/os/window-progress/services/window-progress.js b/lib/gui/os/window-progress/services/window-progress.js index 034cac2d..646509fa 100644 --- a/lib/gui/os/window-progress/services/window-progress.js +++ b/lib/gui/os/window-progress/services/window-progress.js @@ -17,6 +17,7 @@ 'use strict'; const electron = require('electron'); +const errors = require('../../../../shared/errors'); module.exports = function() { @@ -48,7 +49,7 @@ module.exports = function() { const PERCENTAGE_MAXIMUM = 100; if (percentage > PERCENTAGE_MAXIMUM || percentage < PERCENTAGE_MINIMUM) { - throw new Error(`Invalid window progress percentage: ${percentage}`); + throw errors.createError(`Invalid window progress percentage: ${percentage}`); } this.currentWindow.setProgressBar(percentage / PERCENTAGE_MAXIMUM); diff --git a/lib/gui/pages/main/controllers/image-selection.js b/lib/gui/pages/main/controllers/image-selection.js index ba7b950a..0f16c59c 100644 --- a/lib/gui/pages/main/controllers/image-selection.js +++ b/lib/gui/pages/main/controllers/image-selection.js @@ -19,6 +19,7 @@ const _ = require('lodash'); const Bluebird = require('bluebird'); const messages = require('../../../../shared/messages'); +const errors = require('../../../../shared/errors'); module.exports = function( SupportedFormatsModel, @@ -65,10 +66,11 @@ module.exports = function( */ this.selectImage = (image) => { if (!SupportedFormatsModel.isSupportedImage(image.path)) { - OSDialogService.showError('Invalid image', messages.error.invalidImage({ + const invalidImageError = errors.createUserError('Invalid image', messages.error.invalidImage({ image })); + OSDialogService.showError(invalidImageError); AnalyticsService.logEvent('Invalid image', image); return; } diff --git a/lib/gui/utils/manifest-bind/directives/manifest-bind.js b/lib/gui/utils/manifest-bind/directives/manifest-bind.js index 0d0fc4a6..5281b5f4 100644 --- a/lib/gui/utils/manifest-bind/directives/manifest-bind.js +++ b/lib/gui/utils/manifest-bind/directives/manifest-bind.js @@ -16,6 +16,8 @@ 'use strict'; +const errors = require('../../../../shared/errors'); + /** * @summary ManifestBind directive * @function @@ -39,7 +41,7 @@ module.exports = (ManifestBindService) => { const value = ManifestBindService.get(attributes.manifestBind); if (!value) { - throw new Error(`ManifestBind: Unknown property \`${attributes.manifestBind}\``); + throw errors.createError(`ManifestBind: Unknown property \`${attributes.manifestBind}\``); } element.html(value); diff --git a/lib/image-stream/archive-hooks/zip.js b/lib/image-stream/archive-hooks/zip.js index 9c357a61..6fac610d 100644 --- a/lib/image-stream/archive-hooks/zip.js +++ b/lib/image-stream/archive-hooks/zip.js @@ -20,6 +20,7 @@ const Bluebird = require('bluebird'); const _ = require('lodash'); const StreamZip = require('node-stream-zip'); const yauzl = Bluebird.promisifyAll(require('yauzl')); +const errors = require('../../shared/errors'); /** * @summary Get all archive entries @@ -88,7 +89,7 @@ exports.extractFile = (archive, entries, file) => { if (!_.find(entries, { name: file })) { - throw new Error(`Invalid entry: ${file}`); + throw errors.createError(`Invalid entry: ${file}`); } yauzl.openAsync(archive, { diff --git a/lib/image-stream/archive.js b/lib/image-stream/archive.js index a3ac4c77..a91038a4 100644 --- a/lib/image-stream/archive.js +++ b/lib/image-stream/archive.js @@ -22,6 +22,7 @@ const rindle = require('rindle'); const _ = require('lodash'); const PassThroughStream = require('stream').PassThrough; const supportedFileTypes = require('./supported'); +const errors = require('../shared/errors'); /** * @summary Archive metadata base path @@ -118,9 +119,10 @@ const extractArchiveMetadata = (archive, basePath, options) => { try { return JSON.parse(manifest); } catch (parseError) { - const error = new Error('Invalid archive manifest.json'); - error.description = 'The archive manifest.json file is not valid JSON.'; - throw error; + throw errors.createUserError( + 'Invalid archive manifest.json', + 'The archive manifest.json file is not valid JSON' + ); } }); }) @@ -176,10 +178,10 @@ exports.extractImage = (archive, hooks) => { const VALID_NUMBER_OF_IMAGE_ENTRIES = 1; if (imageEntries.length !== VALID_NUMBER_OF_IMAGE_ENTRIES) { - const error = new Error('Invalid archive image'); - error.description = 'The archive image should contain one and only one top image file.'; - error.report = false; - throw error; + throw errors.createUserError( + 'Invalid archive image', + 'The archive image should contain one and only one top image file' + ); } const imageEntry = _.first(imageEntries); diff --git a/lib/image-stream/index.js b/lib/image-stream/index.js index 88c8ce6a..61588ebe 100644 --- a/lib/image-stream/index.js +++ b/lib/image-stream/index.js @@ -23,6 +23,7 @@ const isStream = require('isstream'); const utils = require('./utils'); const handlers = require('./handlers'); const supportedFileTypes = require('./supported'); +const errors = require('../shared/errors'); /** * @summary Get an image stream from a file @@ -64,7 +65,7 @@ exports.getFromFilePath = (file) => { const type = utils.getArchiveMimeType(file); if (!_.has(handlers, type)) { - throw new Error('Invalid image'); + throw errors.createUserError('Invalid image', `The ${type} format is not supported`); } return fs.statAsync(file).then((fileStats) => { diff --git a/lib/shared/errors.js b/lib/shared/errors.js new file mode 100644 index 00000000..6980959a --- /dev/null +++ b/lib/shared/errors.js @@ -0,0 +1,342 @@ +/* + * Copyright 2016 resin.io + * + * 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. + */ + +'use strict'; + +const _ = require('lodash'); + +/** + * @summary Create an error details object + * @function + * @private + * + * @param {Object} options - options + * @param {(String|Function)} options.title - error title + * @param {(String|Function)} options.description - error description + * @returns {Object} error details object + * + * @example + * const details = createErrorDetails({ + * title: (error) => { + * return `An error happened, the code is ${error.code}`; + * }, + * description: 'This is the error description' + * }); + */ +const createErrorDetails = (options) => { + return _.pick(_.mapValues(options, (value) => { + return _.isFunction(value) ? value : _.constant(value); + }), [ 'title', 'description' ]); +}; + +/** + * @summary Human-friendly error messages + * @namespace HUMAN_FRIENDLY + * @public + */ +exports.HUMAN_FRIENDLY = { + + /* eslint-disable new-cap */ + + /** + * @namespace ENOENT + * @memberof HUMAN_FRIENDLY + */ + ENOENT: createErrorDetails({ + title: (error) => { + return `No such file or directory: ${error.path}`; + }, + description: 'The file you\'re trying to access doesn\'t exist' + }), + + /** + * @namespace EPERM + * @memberof HUMAN_FRIENDLY + */ + EPERM: createErrorDetails({ + title: 'You\'re not authorized to perform this operation', + description: 'Please ensure you have necessary permissions for this task' + }), + + /** + * @namespace EACCES + * @memberof HUMAN_FRIENDLY + */ + EACCES: createErrorDetails({ + title: 'You don\'t have access to this resource', + description: 'Please ensure you have necessary permissions to access this resource' + }), + + /** + * @namespace ENOMEM + * @memberof HUMAN_FRIENDLY + */ + ENOMEM: createErrorDetails({ + title: 'Your system ran out of memory', + description: 'Please make sure your system has enough available memory for this task' + }) + + /* eslint-enable new-cap */ + +}; + +/** + * @summary Get user friendly property from an error + * @function + * @private + * + * @param {Error} error - error + * @param {String} property - HUMAN_FRIENDLY property + * @returns {(String|Undefined)} user friendly message + * + * @example + * const error = new Error('My error'); + * error.code = 'ENOMEM'; + * + * const friendlyDescription = getUserFriendlyMessageProperty(error, 'description'); + * + * if (friendlyDescription) { + * console.log(friendlyDescription); + * } + */ +const getUserFriendlyMessageProperty = (error, property) => { + const code = _.get(error, [ 'code' ]); + + if (_.isNil(code) || !_.isString(code)) { + return null; + } + + return _.invoke(exports.HUMAN_FRIENDLY, [ code, property ], error); +}; + +/** + * @summary Check whether an error should be reported to TrackJS + * @function + * @public + * + * @description + * In order to determine whether the error should be reported, we + * check a property called `report`. For backwards compatibility, and + * to properly handle errors that we don't control, an error without + * this property is reported automatically. + * + * @param {Error} error - error + * @returns {Boolean} whether the error should be reported + * + * @example + * if (errors.shouldReport(new Error('foo'))) { + * console.log('We should report this error'); + * } + */ +exports.shouldReport = (error) => { + return !_.has(error, [ 'report' ]) || Boolean(error.report); +}; + +/** + * @summary Check if a string is blank + * @function + * @private + * + * @param {String} string - string + * @returns {Boolean} whether the string is blank + * + * @example + * if (isBlank(' ')) { + * console.log('The string is blank'); + * } + */ +const isBlank = _.flow([ _.trim, _.isEmpty ]); + +/** + * @summary Get the title of an error + * @function + * @public + * + * @description + * Try to get as much information as possible about the error + * rather than falling back to generic messages right away. + * + * @param {Error} error - error + * @returns {String} error title + * + * @example + * const error = new Error('Foo bar'); + * const title = errors.getTitle(error); + * console.log(title); + */ +exports.getTitle = (error) => { + if (!_.isError(error) && !_.isPlainObject(error) && !_.isNil(error)) { + return _.toString(error); + } + + const codeTitle = getUserFriendlyMessageProperty(error, 'title'); + if (!_.isNil(codeTitle)) { + return codeTitle; + } + + const message = _.get(error, [ 'message' ]); + if (!isBlank(message)) { + return message; + } + + const code = _.get(error, [ 'code' ]); + if (!_.isNil(code) && !isBlank(code)) { + return `Error code: ${code}`; + } + + return 'An error ocurred'; +}; + +/** + * @summary Get the description of an error + * @function + * @public + * + * @param {Error} error - error + * @returns {String} error description + * + * @example + * const error = new Error('Foo bar'); + * const description = errors.getDescription(error); + * console.log(description); + */ +exports.getDescription = (error) => { + if (!_.isError(error) && !_.isPlainObject(error)) { + return ''; + } + + if (!isBlank(error.description)) { + return error.description; + } + + const codeDescription = getUserFriendlyMessageProperty(error, 'description'); + if (!_.isNil(codeDescription)) { + return codeDescription; + } + + if (error.stack) { + return error.stack; + } + + if (_.isEmpty(error)) { + return ''; + } + + const INDENTATION_SPACES = 2; + return JSON.stringify(error, null, INDENTATION_SPACES); +}; + +/** + * @summary Create an error + * @function + * @public + * + * @param {String} title - error title + * @param {String} [description] - error description + * @param {Object} [options] - options + * @param {Boolean} [options.report] - report error + * @returns {Error} error + * + * @example + * const error = errors.createError('Foo', 'Bar'); + * throw error; + */ +exports.createError = (title, description, options = {}) => { + if (isBlank(title)) { + throw new Error(`Invalid error title: ${title}`); + } + + const error = new Error(title); + error.description = description; + + if (!_.isNil(options.report) && !options.report) { + error.report = false; + } + + return error; +}; + +/** + * @summary Create a user error + * @function + * @public + * + * @description + * User errors represent invalid states that the user + * caused, that are not errors on the application itself. + * Therefore, user errors don't get reported to analytics + * and error reporting services. + * + * @param {String} title - error title + * @param {String} [description] - error description + * @returns {Error} user error + * + * @example + * const error = errors.createUserError('Foo', 'Bar'); + * throw error; + */ +exports.createUserError = (title, description) => { + return exports.createError(title, description, { + report: false + }); +}; + +/** + * @summary Convert an Error object to a JSON object + * @function + * @public + * + * @param {Error} error - error object + * @returns {Object} json error + * + * @example + * const error = errors.toJSON(new Error('foo')) + * + * console.log(error.message); + * > 'foo' + */ +exports.toJSON = (error) => { + + // Handle string error objects to be on the safe side + const isErrorLike = _.isError(error) || _.isPlainObject(error); + const errorObject = isErrorLike ? error : new Error(error); + + return { + message: errorObject.message, + description: errorObject.description, + stack: errorObject.stack, + report: errorObject.report, + code: errorObject.code + }; +}; + +/** + * @summary Convert a JSON object to an Error object + * @function + * @public + * + * @param {Error} json - json object + * @returns {Object} error object + * + * @example + * const error = errors.fromJSON(errors.toJSON(new Error('foo'))); + * + * console.log(error.message); + * > 'foo' + */ +exports.fromJSON = (json) => { + return _.assign(new Error(json.message), json); +}; diff --git a/lib/shared/robot/README.md b/lib/shared/robot/README.md index 4140202a..44e2afa6 100644 --- a/lib/shared/robot/README.md +++ b/lib/shared/robot/README.md @@ -110,7 +110,7 @@ functions: `.printError()` and `.recomposeErrorMessage()`. Here's an example of these functions in action: ```javascript -const error = new Error('This is an error'); +const error = errors.createError('This is an error', 'My description'); robot.printError(error); ``` diff --git a/lib/shared/robot/index.js b/lib/shared/robot/index.js index ba0008d5..a3120fdd 100644 --- a/lib/shared/robot/index.js +++ b/lib/shared/robot/index.js @@ -17,6 +17,7 @@ 'use strict'; const _ = require('lodash'); +const errors = require('../errors'); /** * @summary Check whether we should emit parseable output @@ -55,7 +56,7 @@ exports.isEnabled = (environment) => { */ exports.buildMessage = (title, data = {}) => { if (!_.isPlainObject(data)) { - throw new Error(`Invalid data: ${data}`); + throw errors.createError(`Invalid data: ${data}`); } return JSON.stringify({ @@ -88,15 +89,11 @@ exports.parseMessage = (string) => { try { output = JSON.parse(string); } catch (error) { - error.message = 'Invalid message'; - error.description = `${string}, ${error.message}`; - throw error; + throw errors.createError('Invalid message', `${string}, ${error.message}`); } if (!output.command || !output.data) { - const error = new Error('Invalid message'); - error.description = `No command or data: ${string}`; - throw error; + throw errors.createError('Invalid message', `No command or data: ${string}`); } return output; @@ -107,7 +104,7 @@ exports.parseMessage = (string) => { * @function * @private * - * @param {(String|Error)} error - error + * @param {Error} error - error * @returns {String} parseable error message * * @example @@ -119,19 +116,9 @@ exports.parseMessage = (string) => { * * console.log(error.data.message); * > 'foo' - * - * error.data.stacktrace === error.stack; - * > true */ exports.buildErrorMessage = (error) => { - const errorObject = _.isString(error) ? new Error(error) : error; - - return exports.buildMessage('error', { - message: errorObject.message, - description: errorObject.description, - stacktrace: errorObject.stack, - code: errorObject.code - }); + return exports.buildMessage('error', errors.toJSON(error)); }; /** @@ -153,10 +140,7 @@ exports.buildErrorMessage = (error) => { * > 'foo' */ exports.recomposeErrorMessage = (message) => { - const error = new Error(message.data.message); - _.assign(error, _.omit(message.data, 'stacktrace')); - error.stack = message.data.stacktrace; - return error; + return errors.fromJSON(message.data); }; /** @@ -208,7 +192,7 @@ exports.getData = (message) => { * @function * @public * - * @param {(Error|String)} error - error + * @param {Error} error - error * * @example * robot.printError(new Error('This is an error')); diff --git a/tests/cli/errors.spec.js b/tests/cli/errors.spec.js deleted file mode 100644 index 16dcd687..00000000 --- a/tests/cli/errors.spec.js +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2016 resin.io - * - * 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. - */ - -'use strict'; - -const m = require('mochainon'); -const _ = require('lodash'); -const errors = require('../../lib/cli/errors'); - -describe('CLI: Errors', function() { - - describe('.HUMAN_FRIENDLY', function() { - - it('should be a plain object', function() { - m.chai.expect(_.isPlainObject(errors.HUMAN_FRIENDLY)).to.be.true; - }); - - it('should contain function properties', function() { - m.chai.expect(_.every(_.map(errors.HUMAN_FRIENDLY, _.isFunction))).to.be.true; - }); - - }); - - describe('.getErrorMessage()', function() { - - describe('given errors without code properties', function() { - - it('should understand a string error', function() { - const error = 'foo bar'; - m.chai.expect(errors.getErrorMessage(error)).to.equal('foo bar'); - }); - - it('should return a generic error message if there is none', function() { - const error = new Error(); - m.chai.expect(errors.getErrorMessage(error)).to.equal('Unknown error'); - }); - - it('should return a generic error message if error is an empty string', function() { - const error = ''; - m.chai.expect(errors.getErrorMessage(error)).to.equal('Unknown error'); - }); - - it('should return the error message', function() { - const error = new Error('foo bar'); - m.chai.expect(errors.getErrorMessage(error)).to.equal('foo bar'); - }); - - it('should make use of a description if there is one', function() { - const error = new Error('foo bar'); - error.description = 'This is a description'; - m.chai.expect(errors.getErrorMessage(error)).to.equal(_.join([ - 'foo bar', - '', - 'This is a description' - ], '\n')); - }); - - }); - - describe('given errors with code properties', function() { - - it('should provide a friendly message for ENOENT', function() { - const error = new Error('foo bar'); - error.code = 'ENOENT'; - error.path = 'foo.bar'; - const message = 'ENOENT: No such file or directory: foo.bar'; - m.chai.expect(errors.getErrorMessage(error)).to.equal(message); - }); - - it('should provide a friendly message for EPERM', function() { - const error = new Error('foo bar'); - error.code = 'EPERM'; - const message = 'EPERM: You\'re not authorized to perform this operation'; - m.chai.expect(errors.getErrorMessage(error)).to.equal(message); - }); - - it('should provide a friendly message for EACCES', function() { - const error = new Error('foo bar'); - error.code = 'EACCES'; - const message = 'EACCES: You don\'t have access to this resource'; - m.chai.expect(errors.getErrorMessage(error)).to.equal(message); - }); - - it('should make use of a description if there is one', function() { - const error = new Error('foo bar'); - error.code = 'EPERM'; - error.description = 'This is the EPERM description'; - - const message = _.join([ - 'EPERM: You\'re not authorized to perform this operation', - '', - 'This is the EPERM description' - ], '\n'); - - m.chai.expect(errors.getErrorMessage(error)).to.equal(message); - }); - - describe('given the code is not recognised', function() { - - it('should make use of the error message', function() { - const error = new Error('foo bar'); - error.code = 'EFOO'; - const message = 'EFOO: foo bar'; - m.chai.expect(errors.getErrorMessage(error)).to.equal(message); - }); - - it('should return a generic error message if no there is no message', function() { - const error = new Error(); - error.code = 'EFOO'; - m.chai.expect(errors.getErrorMessage(error)).to.equal('EFOO: Unknown error'); - }); - - }); - - }); - - }); - -}); diff --git a/tests/gui/modules/analytics.spec.js b/tests/gui/modules/analytics.spec.js deleted file mode 100644 index f00267d0..00000000 --- a/tests/gui/modules/analytics.spec.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -const m = require('mochainon'); -const angular = require('angular'); -require('angular-mocks'); - -describe('Browser: Analytics', function() { - - beforeEach(angular.mock.module( - require('../../../lib/gui/modules/analytics') - )); - - describe('AnalyticsService', function() { - - let AnalyticsService; - - beforeEach(angular.mock.inject(function(_AnalyticsService_) { - AnalyticsService = _AnalyticsService_; - })); - - describe('.shouldReportError()', function() { - - it('should return true for a basic error', function() { - const error = new Error('foo'); - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.true; - }); - - it('should return true for an error with a report true property', function() { - const error = new Error('foo'); - error.report = true; - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.true; - }); - - it('should return false for an error with a report false property', function() { - const error = new Error('foo'); - error.report = false; - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.false; - }); - - it('should return false for an error with a report undefined property', function() { - const error = new Error('foo'); - error.report = undefined; - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.false; - }); - - it('should return false for an error with a report null property', function() { - const error = new Error('foo'); - error.report = null; - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.false; - }); - - it('should return false for an error with a report 0 property', function() { - const error = new Error('foo'); - error.report = 0; - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.false; - }); - - it('should return true for an error with a report 1 property', function() { - const error = new Error('foo'); - error.report = 1; - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.true; - }); - - it('should cast the report property to boolean', function() { - const error = new Error('foo'); - error.report = ''; - m.chai.expect(AnalyticsService.shouldReportError(error)).to.be.false; - }); - - }); - - }); -}); diff --git a/tests/shared/errors.spec.js b/tests/shared/errors.spec.js new file mode 100644 index 00000000..55fe61ee --- /dev/null +++ b/tests/shared/errors.spec.js @@ -0,0 +1,688 @@ +/* + * Copyright 2016 resin.io + * + * 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. + */ + +'use strict'; + +const m = require('mochainon'); +const _ = require('lodash'); +const errors = require('../../lib/shared/errors'); + +describe('Shared: Errors', function() { + + describe('.HUMAN_FRIENDLY', function() { + + it('should be a plain object', function() { + m.chai.expect(_.isPlainObject(errors.HUMAN_FRIENDLY)).to.be.true; + }); + + it('should contain title and description function properties', function() { + m.chai.expect(_.every(_.map(errors.HUMAN_FRIENDLY, (error) => { + return _.isFunction(error.title) && _.isFunction(error.description); + }))).to.be.true; + }); + + }); + + describe('.shouldReport()', function() { + + it('should return true for a string error', function() { + const error = 'foo'; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for a number 0 error', function() { + const error = 0; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for a number 1 error', function() { + const error = 1; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for a number -1 error', function() { + const error = -1; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for an array error', function() { + const error = [ 1, 2, 3 ]; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for an undefined error', function() { + const error = undefined; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for a null error', function() { + const error = null; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for an empty object error', function() { + const error = {}; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for a basic error', function() { + const error = new Error('foo'); + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return true for an error with a report true property', function() { + const error = new Error('foo'); + error.report = true; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should return false for an error with a report false property', function() { + const error = new Error('foo'); + error.report = false; + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + it('should return false for an error with a report undefined property', function() { + const error = new Error('foo'); + error.report = undefined; + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + it('should return false for an error with a report null property', function() { + const error = new Error('foo'); + error.report = null; + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + it('should return false for an error with a report 0 property', function() { + const error = new Error('foo'); + error.report = 0; + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + it('should return true for an error with a report 1 property', function() { + const error = new Error('foo'); + error.report = 1; + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should cast the report property to boolean', function() { + const error = new Error('foo'); + error.report = ''; + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + }); + + describe('.getTitle()', function() { + + it('should accept a string', function() { + const error = 'This is an error'; + m.chai.expect(errors.getTitle(error)).to.equal('This is an error'); + }); + + it('should accept a number 0', function() { + const error = 0; + m.chai.expect(errors.getTitle(error)).to.equal('0'); + }); + + it('should accept a number 1', function() { + const error = 1; + m.chai.expect(errors.getTitle(error)).to.equal('1'); + }); + + it('should accept a number -1', function() { + const error = -1; + m.chai.expect(errors.getTitle(error)).to.equal('-1'); + }); + + it('should accept an array', function() { + const error = [ 0, 1, 2 ]; + m.chai.expect(errors.getTitle(error)).to.equal('0,1,2'); + }); + + it('should return a generic error message if the error is an empty object', function() { + const error = {}; + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should return a generic error message if the error is undefined', function() { + const error = undefined; + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should return a generic error message if the error is null', function() { + const error = null; + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should return the error message', function() { + const error = new Error('This is an error'); + m.chai.expect(errors.getTitle(error)).to.equal('This is an error'); + }); + + it('should return the error code if there is no message', function() { + const error = new Error(); + error.code = 'MYERROR'; + m.chai.expect(errors.getTitle(error)).to.equal('Error code: MYERROR'); + }); + + it('should prioritise the message over the code', function() { + const error = new Error('Foo bar'); + error.code = 'MYERROR'; + m.chai.expect(errors.getTitle(error)).to.equal('Foo bar'); + }); + + it('should prioritise the code over the message if the message is an empty string', function() { + const error = new Error(''); + error.code = 'MYERROR'; + m.chai.expect(errors.getTitle(error)).to.equal('Error code: MYERROR'); + }); + + it('should prioritise the code over the message if the message is a blank string', function() { + const error = new Error(' '); + error.code = 'MYERROR'; + m.chai.expect(errors.getTitle(error)).to.equal('Error code: MYERROR'); + }); + + it('should understand an error-like object with a code', function() { + const error = { + code: 'MYERROR' + }; + + m.chai.expect(errors.getTitle(error)).to.equal('Error code: MYERROR'); + }); + + it('should understand an error-like object with a message', function() { + const error = { + message: 'Hello world' + }; + + m.chai.expect(errors.getTitle(error)).to.equal('Hello world'); + }); + + it('should understand an error-like object with a message and a code', function() { + const error = { + message: 'Hello world', + code: 'MYERROR' + }; + + m.chai.expect(errors.getTitle(error)).to.equal('Hello world'); + }); + + it('should display an error code 0', function() { + const error = new Error(); + error.code = 0; + m.chai.expect(errors.getTitle(error)).to.equal('Error code: 0'); + }); + + it('should display an error code 1', function() { + const error = new Error(); + error.code = 1; + m.chai.expect(errors.getTitle(error)).to.equal('Error code: 1'); + }); + + it('should display an error code -1', function() { + const error = new Error(); + error.code = -1; + m.chai.expect(errors.getTitle(error)).to.equal('Error code: -1'); + }); + + it('should not display an empty string error code', function() { + const error = new Error(); + error.code = ''; + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should not display a blank string error code', function() { + const error = new Error(); + error.code = ' '; + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should return a generic error message if no information was found', function() { + const error = new Error(); + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should return a generic error message if no code and the message is empty', function() { + const error = new Error(''); + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should return a generic error message if no code and the message is blank', function() { + const error = new Error(' '); + m.chai.expect(errors.getTitle(error)).to.equal('An error ocurred'); + }); + + it('should rephrase an ENOENT error', function() { + const error = new Error('ENOENT error'); + error.path = '/foo/bar'; + error.code = 'ENOENT'; + m.chai.expect(errors.getTitle(error)).to.equal('No such file or directory: /foo/bar'); + }); + + it('should rephrase an EPERM error', function() { + const error = new Error('EPERM error'); + error.code = 'EPERM'; + m.chai.expect(errors.getTitle(error)).to.equal('You\'re not authorized to perform this operation'); + }); + + it('should rephrase an EACCES error', function() { + const error = new Error('EACCES error'); + error.code = 'EACCES'; + m.chai.expect(errors.getTitle(error)).to.equal('You don\'t have access to this resource'); + }); + + it('should rephrase an ENOMEM error', function() { + const error = new Error('ENOMEM error'); + error.code = 'ENOMEM'; + m.chai.expect(errors.getTitle(error)).to.equal('Your system ran out of memory'); + }); + + }); + + describe('.getDescription()', function() { + + it('should return an empty string if the error is a string', function() { + const error = 'My error'; + m.chai.expect(errors.getDescription(error)).to.equal(''); + }); + + it('should return an empty string if the error is a number', function() { + const error = 0; + m.chai.expect(errors.getDescription(error)).to.equal(''); + }); + + it('should return an empty string if the error is an array', function() { + const error = [ 1, 2, 3 ]; + m.chai.expect(errors.getDescription(error)).to.equal(''); + }); + + it('should return an empty string if the error is undefined', function() { + const error = undefined; + m.chai.expect(errors.getDescription(error)).to.equal(''); + }); + + it('should return an empty string if the error is null', function() { + const error = null; + m.chai.expect(errors.getDescription(error)).to.equal(''); + }); + + it('should return an empty string if the error is an empty object', function() { + const error = {}; + m.chai.expect(errors.getDescription(error)).to.equal(''); + }); + + it('should understand an error-like object with a description', function() { + const error = { + description: 'My description' + }; + + m.chai.expect(errors.getDescription(error)).to.equal('My description'); + }); + + it('should understand an error-like object with a stack', function() { + const error = { + stack: 'My stack' + }; + + m.chai.expect(errors.getDescription(error)).to.equal('My stack'); + }); + + it('should understand an error-like object with a description and a stack', function() { + const error = { + description: 'My description', + stack: 'My stack' + }; + + m.chai.expect(errors.getDescription(error)).to.equal('My description'); + }); + + it('should stringify and beautify an object without any known property', function() { + const error = { + name: 'John Doe', + job: 'Developer' + }; + + m.chai.expect(errors.getDescription(error)).to.equal([ + '{', + ' "name": "John Doe",', + ' "job": "Developer"', + '}' + ].join('\n')); + }); + + it('should return the stack for a basic error', function() { + const error = new Error('Foo'); + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should prefer a description property to a stack', function() { + const error = new Error('Foo'); + error.description = 'My description'; + m.chai.expect(errors.getDescription(error)).to.equal('My description'); + }); + + it('should return the stack if the description is an empty string', function() { + const error = new Error('Foo'); + error.description = ''; + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should return the stack if the description is a blank string', function() { + const error = new Error('Foo'); + error.description = ' '; + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should get a generic description for ENOENT', function() { + const error = new Error('Foo'); + error.code = 'ENOENT'; + m.chai.expect(errors.getDescription(error)).to.equal('The file you\'re trying to access doesn\'t exist'); + }); + + it('should get a generic description for EPERM', function() { + const error = new Error('Foo'); + error.code = 'EPERM'; + m.chai.expect(errors.getDescription(error)).to.equal('Please ensure you have necessary permissions for this task'); + }); + + it('should get a generic description for EACCES', function() { + const error = new Error('Foo'); + error.code = 'EACCES'; + const message = 'Please ensure you have necessary permissions to access this resource'; + m.chai.expect(errors.getDescription(error)).to.equal(message); + }); + + it('should get a generic description for ENOMEM', function() { + const error = new Error('Foo'); + error.code = 'ENOMEM'; + const message = 'Please make sure your system has enough available memory for this task'; + m.chai.expect(errors.getDescription(error)).to.equal(message); + }); + + it('should prefer a description property than a code description', function() { + const error = new Error('Foo'); + error.code = 'ENOMEM'; + error.description = 'Memory error'; + m.chai.expect(errors.getDescription(error)).to.equal('Memory error'); + }); + + }); + + describe('.createError()', function() { + + it('should report the resulting error by default', function() { + const error = errors.createError('Foo', 'Something happened'); + m.chai.expect(errors.shouldReport(error)).to.be.true; + }); + + it('should not report the error if report is false', function() { + const error = errors.createError('Foo', 'Something happened', { + report: false + }); + + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + it('should not report the error if report evaluates to false', function() { + const error = errors.createError('Foo', 'Something happened', { + report: 0 + }); + + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + it('should be an instance of Error', function() { + const error = errors.createError('Foo', 'Something happened'); + m.chai.expect(error).to.be.an.instanceof(Error); + }); + + it('should correctly add both a message and a description', function() { + const error = errors.createError('Foo', 'Something happened'); + m.chai.expect(errors.getTitle(error)).to.equal('Foo'); + m.chai.expect(errors.getDescription(error)).to.equal('Something happened'); + }); + + it('should correctly add only a message', function() { + const error = errors.createError('Foo'); + m.chai.expect(errors.getTitle(error)).to.equal('Foo'); + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should ignore an empty description', function() { + const error = errors.createError('Foo', ''); + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should ignore a blank description', function() { + const error = errors.createError('Foo', ' '); + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should throw if no message', function() { + m.chai.expect(() => { + errors.createError(); + }).to.throw('Invalid error title: undefined'); + }); + + it('should throw if message is empty', function() { + m.chai.expect(() => { + errors.createError(''); + }).to.throw('Invalid error title: '); + }); + + it('should throw if message is blank', function() { + m.chai.expect(() => { + errors.createError(' '); + }).to.throw('Invalid error title: '); + }); + + }); + + describe('.createUserError()', function() { + + it('should not report the resulting error', function() { + const error = errors.createUserError('Foo', 'Something happened'); + m.chai.expect(errors.shouldReport(error)).to.be.false; + }); + + it('should be an instance of Error', function() { + const error = errors.createUserError('Foo', 'Something happened'); + m.chai.expect(error).to.be.an.instanceof(Error); + }); + + it('should correctly add both a message and a description', function() { + const error = errors.createUserError('Foo', 'Something happened'); + m.chai.expect(errors.getTitle(error)).to.equal('Foo'); + m.chai.expect(errors.getDescription(error)).to.equal('Something happened'); + }); + + it('should correctly add only a message', function() { + const error = errors.createUserError('Foo'); + m.chai.expect(errors.getTitle(error)).to.equal('Foo'); + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should ignore an empty description', function() { + const error = errors.createUserError('Foo', ''); + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should ignore a blank description', function() { + const error = errors.createUserError('Foo', ' '); + m.chai.expect(errors.getDescription(error)).to.equal(error.stack); + }); + + it('should throw if no message', function() { + m.chai.expect(() => { + errors.createUserError(); + }).to.throw('Invalid error title: undefined'); + }); + + it('should throw if message is empty', function() { + m.chai.expect(() => { + errors.createUserError(''); + }).to.throw('Invalid error title: '); + }); + + it('should throw if message is blank', function() { + m.chai.expect(() => { + errors.createUserError(' '); + }).to.throw('Invalid error title: '); + }); + + }); + + describe('.toJSON()', function() { + + it('should convert a simple error', function() { + const error = new Error('My error'); + m.chai.expect(errors.toJSON(error)).to.deep.equal({ + code: undefined, + description: undefined, + message: 'My error', + stack: error.stack, + report: undefined + }); + }); + + it('should convert an error with a description', function() { + const error = new Error('My error'); + error.description = 'My description'; + + m.chai.expect(errors.toJSON(error)).to.deep.equal({ + code: undefined, + description: 'My description', + message: 'My error', + stack: error.stack, + report: undefined + }); + }); + + it('should convert an error with a code', function() { + const error = new Error('My error'); + error.code = 'ENOENT'; + + m.chai.expect(errors.toJSON(error)).to.deep.equal({ + code: 'ENOENT', + description: undefined, + message: 'My error', + stack: error.stack, + report: undefined + }); + }); + + it('should convert an error with a description and a code', function() { + const error = new Error('My error'); + error.description = 'My description'; + error.code = 'ENOENT'; + + m.chai.expect(errors.toJSON(error)).to.deep.equal({ + code: 'ENOENT', + description: 'My description', + message: 'My error', + stack: error.stack, + report: undefined + }); + }); + + it('should convert an error with a report value', function() { + const error = new Error('My error'); + error.report = true; + + m.chai.expect(errors.toJSON(error)).to.deep.equal({ + code: undefined, + description: undefined, + message: 'My error', + stack: error.stack, + report: true + }); + }); + + it('should convert an error without a message', function() { + const error = new Error(); + + m.chai.expect(errors.toJSON(error)).to.deep.equal({ + code: undefined, + description: undefined, + message: '', + stack: error.stack, + report: undefined + }); + }); + + }); + + describe('.fromJSON()', function() { + + it('should return an Error object', function() { + const error = new Error('My error'); + const result = errors.fromJSON(errors.toJSON(error)); + m.chai.expect(result).to.be.an.instanceof(Error); + }); + + it('should convert a simple JSON error', function() { + const error = new Error('My error'); + const result = errors.fromJSON(errors.toJSON(error)); + + m.chai.expect(result.message).to.equal(error.message); + m.chai.expect(result.description).to.equal(error.description); + m.chai.expect(result.code).to.equal(error.code); + m.chai.expect(result.stack).to.equal(error.stack); + m.chai.expect(result.report).to.equal(error.report); + }); + + it('should convert a JSON error with a description', function() { + const error = new Error('My error'); + error.description = 'My description'; + const result = errors.fromJSON(errors.toJSON(error)); + + m.chai.expect(result.message).to.equal(error.message); + m.chai.expect(result.description).to.equal(error.description); + m.chai.expect(result.code).to.equal(error.code); + m.chai.expect(result.stack).to.equal(error.stack); + m.chai.expect(result.report).to.equal(error.report); + }); + + it('should convert a JSON error with a code', function() { + const error = new Error('My error'); + error.code = 'ENOENT'; + const result = errors.fromJSON(errors.toJSON(error)); + + m.chai.expect(result.message).to.equal(error.message); + m.chai.expect(result.description).to.equal(error.description); + m.chai.expect(result.code).to.equal(error.code); + m.chai.expect(result.stack).to.equal(error.stack); + m.chai.expect(result.report).to.equal(error.report); + }); + + it('should convert a JSON error with a report value', function() { + const error = new Error('My error'); + error.report = false; + const result = errors.fromJSON(errors.toJSON(error)); + + m.chai.expect(result.message).to.equal(error.message); + m.chai.expect(result.description).to.equal(error.description); + m.chai.expect(result.code).to.equal(error.code); + m.chai.expect(result.stack).to.equal(error.stack); + m.chai.expect(result.report).to.equal(error.report); + }); + + }); + +}); diff --git a/tests/shared/robot.spec.js b/tests/shared/robot.spec.js index 5ea96d64..b90ab3c5 100644 --- a/tests/shared/robot.spec.js +++ b/tests/shared/robot.spec.js @@ -101,7 +101,7 @@ describe('Shared: Robot', function() { command: 'error', data: { message: 'foo', - stacktrace: error.stack + stack: error.stack } }); }); @@ -116,7 +116,7 @@ describe('Shared: Robot', function() { data: { message: 'foo', description: 'error description', - stacktrace: error.stack + stack: error.stack } }); }); @@ -131,7 +131,7 @@ describe('Shared: Robot', function() { data: { message: 'foo', code: 'MYERROR', - stacktrace: error.stack + stack: error.stack } }); }); @@ -139,8 +139,8 @@ describe('Shared: Robot', function() { it('should handle a string error', function() { const message = JSON.parse(robot.buildErrorMessage('foo')); m.chai.expect(message.data.message).to.equal('foo'); - m.chai.expect(message.data.stacktrace).to.be.a.string; - m.chai.expect(_.isEmpty(message.data.stacktrace)).to.be.false; + m.chai.expect(message.data.stack).to.be.a.string; + m.chai.expect(_.isEmpty(message.data.stack)).to.be.false; }); });