feat(GUI): improve analytics events (#1111)

* feat(GUI): improve analytics events

This commit adds more events to our current analytics.
Will further improve in a future commit.

Change-Type: patch
See: https://github.com/resin-io/etcher/issues/1100

* refactor(gui): use single function to set normal and dangerous settings

Change-Type: patch
Signed-off-by: Juan Cruz Viotti <jviotti@openmailbox.org>
This commit is contained in:
Ștefan Daniel Mihăilă 2017-03-10 21:18:49 +02:00 committed by Juan Cruz Viotti
parent 33e044806b
commit 34c85eb150
22 changed files with 477 additions and 82 deletions

View File

@ -30,6 +30,7 @@ const electron = require('electron');
const Bluebird = require('bluebird');
const EXIT_CODES = require('../shared/exit-codes');
const messages = require('../shared/messages');
const packageJSON = require('../../package.json');
const Store = require('./models/store');
@ -85,26 +86,47 @@ app.run(() => {
app.run((AnalyticsService, ErrorService, UpdateNotifierService, SelectionStateModel) => {
AnalyticsService.logEvent('Application start');
if (UpdateNotifierService.shouldCheckForUpdates() && !process.env.ETCHER_DISABLE_UPDATES) {
AnalyticsService.logEvent('Checking for updates');
const shouldCheckForUpdates = UpdateNotifierService.shouldCheckForUpdates();
UpdateNotifierService.isLatestVersion().then((isLatestVersion) => {
// In case the internet connection is not good and checking the
// latest published version takes too long, only show notify
// the user about the new version if he didn't start the flash
// process (e.g: selected an image), otherwise such interruption
// might be annoying.
if (!isLatestVersion && !SelectionStateModel.hasImage()) {
AnalyticsService.logEvent('Notifying update');
return UpdateNotifierService.notify();
}
return Bluebird.resolve();
}).catch(ErrorService.reportException);
if (!shouldCheckForUpdates || process.env.ETCHER_DISABLE_UPDATES) {
AnalyticsService.logEvent('Not checking for updates', {
shouldCheckForUpdates,
disableUpdatesEnvironmentVariable: process.env.ETCHER_DISABLE_UPDATES
});
return;
}
AnalyticsService.logEvent('Checking for updates', {
currentVersion: packageJSON.version
});
UpdateNotifierService.isLatestVersion().then((isLatestVersion) => {
if (isLatestVersion) {
AnalyticsService.logEvent('Update notification skipped', {
reason: 'Latest version'
});
return Bluebird.resolve();
}
// In case the internet connection is not good and checking the
// latest published version takes too long, only show notify
// the user about the new version if he didn't start the flash
// process (e.g: selected an image), otherwise such interruption
// might be annoying.
if (SelectionStateModel.hasImage()) {
AnalyticsService.logEvent('Update notification skipped', {
reason: 'Image selected'
});
return Bluebird.resolve();
}
AnalyticsService.logEvent('Notifying update');
return UpdateNotifierService.notify();
}).catch(ErrorService.reportException);
});
app.run((AnalyticsService, OSWindowProgressService, FlashStateModel) => {
@ -158,11 +180,14 @@ app.run(($timeout, DriveScannerService, DrivesModel, ErrorService) => {
DriveScannerService.start();
});
app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogService) => {
app.run(($window, AnalyticsService, WarningModalService, ErrorService, FlashStateModel, OSDialogService) => {
let popupExists = false;
$window.addEventListener('beforeunload', (event) => {
if (!FlashStateModel.isFlashing() || popupExists) {
AnalyticsService.logEvent('Close application', {
isFlashing: FlashStateModel.isFlashing()
});
return;
}
@ -172,6 +197,8 @@ app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogSe
// Don't open any more popups
popupExists = true;
AnalyticsService.logEvent('Close attempt while flashing');
OSDialogService.showWarning({
confirmationLabel: 'Yes, quit',
rejectionLabel: 'Cancel',
@ -179,6 +206,7 @@ app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogSe
description: messages.warning.exitWhileFlashing()
}).then((confirmed) => {
if (confirmed) {
AnalyticsService.logEvent('Close confirmed while flashing');
// This circumvents the 'beforeunload' event unlike
// electron.remote.app.quit() which does not.
@ -186,11 +214,27 @@ app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogSe
}
AnalyticsService.logEvent('Close rejected while flashing');
popupExists = false;
}).catch(ErrorService.reportException);
});
});
app.run(($rootScope, AnalyticsService) => {
$rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
// Ignore first navigation
if (!fromState.name) {
return;
}
AnalyticsService.logEvent('Navigate', {
to: toState.name,
from: fromState.name
});
});
});
app.config(($urlRouterProvider) => {
$urlRouterProvider.otherwise('/main');
});

View File

@ -25,7 +25,9 @@ module.exports = function(
DrivesModel,
SelectionStateModel,
WarningModalService,
DriveConstraintsModel) {
DriveConstraintsModel,
AnalyticsService
) {
/**
* @summary The drive selector state
@ -105,10 +107,17 @@ module.exports = function(
* });
*/
this.toggleDrive = (drive) => {
AnalyticsService.logEvent('Toggle drive', {
drive,
previouslySelected: SelectionStateModel.isCurrentDrive(drive.device)
});
return shouldChangeDriveSelectionState(drive).then((canChangeDriveSelectionState) => {
if (canChangeDriveSelectionState) {
SelectionStateModel.toggleSetDrive(drive.device);
}
});
};
@ -153,6 +162,9 @@ module.exports = function(
return shouldChangeDriveSelectionState(drive).then((canChangeDriveSelectionState) => {
if (canChangeDriveSelectionState) {
SelectionStateModel.setDrive(drive.device);
AnalyticsService.logEvent('Drive selected (double click)');
this.closeModal();
}
});

View File

@ -28,7 +28,8 @@ const DriveSelector = angular.module(MODULE_NAME, [
require('../../models/drives'),
require('../../models/selection-state'),
require('../../models/drive-constraints'),
require('../../utils/byte-size/byte-size')
require('../../utils/byte-size/byte-size'),
require('../../modules/analytics')
]);
DriveSelector.controller('DriveSelectorController', require('./controllers/drive-selector'));

View File

@ -23,7 +23,8 @@
const angular = require('angular');
const MODULE_NAME = 'Etcher.Components.Modal';
const Modal = angular.module(MODULE_NAME, [
require('angular-ui-bootstrap')
require('angular-ui-bootstrap'),
require('../../modules/analytics')
]);
Modal.service('ModalService', require('./services/modal'));

View File

@ -18,7 +18,7 @@
const _ = require('lodash');
module.exports = function($uibModal, $q) {
module.exports = function($uibModal, $q, AnalyticsService) {
/**
* @summary Open a modal
@ -44,6 +44,10 @@ module.exports = function($uibModal, $q) {
size: 'sm'
});
AnalyticsService.logEvent('Open modal', {
template: options.template
});
const modal = $uibModal.open({
animation: true,
templateUrl: options.template,
@ -55,17 +59,29 @@ module.exports = function($uibModal, $q) {
return {
close: modal.close,
result: $q((resolve, reject) => {
modal.result
.then(resolve)
.catch((error) => {
// Bootstrap doesn't 'resolve' these but cancels the dialog
if (error === 'escape key press' || error === 'backdrop click') {
return resolve();
}
return reject(error);
modal.result.then((value) => {
AnalyticsService.logEvent('Modal accepted', {
value
});
resolve(value);
}).catch((error) => {
// Bootstrap doesn't 'resolve' these but cancels the dialog
if (error === 'escape key press' || error === 'backdrop click') {
AnalyticsService.logEvent('Modal rejected', {
method: error
});
return resolve();
}
AnalyticsService.logEvent('Modal rejected', {
value: error
});
return reject(error);
});
})
};
};

View File

@ -16,7 +16,7 @@
'use strict';
module.exports = function($uibModalInstance, SettingsModel, UPDATE_NOTIFIER_SLEEP_DAYS, options) {
module.exports = function($uibModalInstance, SettingsModel, AnalyticsService, UPDATE_NOTIFIER_SLEEP_DAYS, options) {
// We update this value in this controller since its the only place
// where we can be sure the modal was really presented to the user.
@ -56,6 +56,11 @@ module.exports = function($uibModalInstance, SettingsModel, UPDATE_NOTIFIER_SLEE
* UpdateNotifierController.closeModal();
*/
this.closeModal = () => {
AnalyticsService.logEvent('Close update modal', {
sleepUpdateCheck: this.sleepUpdateCheck,
notifyVersion: options.version
});
$uibModalInstance.dismiss();
};

View File

@ -26,7 +26,8 @@ const UpdateNotifier = angular.module(MODULE_NAME, [
require('../modal/modal'),
require('../../models/settings'),
require('../../utils/manifest-bind/manifest-bind'),
require('../../os/open-external/open-external')
require('../../os/open-external/open-external'),
require('../../modules/analytics')
]);
/**

View File

@ -24,9 +24,11 @@ const _ = require('lodash');
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 os = require('os');
const packageJSON = require('../../../package.json');
const arch = require('arch');
const utils = require('../../shared/utils');
// Force Mixpanel snippet to load Mixpanel locally
// instead of using a CDN for performance reasons
@ -40,6 +42,26 @@ const analytics = angular.module(MODULE_NAME, [
require('../models/settings')
]);
/**
* @summary Get host architecture
* @function
* @private
*
* @description
* We need this because node's os.arch() returns the process architecture
* See: https://github.com/nodejs/node-v0.x-archive/issues/2862
*
* @returns {String} Host architecture
*
* @example
* if (getHostArchitecture() === 'x64') {
* console.log('Host architecture is x64');
* }
*/
const getHostArchitecture = () => {
return _.includes([ 'ia32', 'x64' ], process.arch) ? arch().replace('x86', 'ia32') : process.arch;
};
// Mixpanel integration
// https://github.com/kuhnza/angular-mixpanel
@ -47,17 +69,16 @@ analytics.config(($mixpanelProvider) => {
$mixpanelProvider.apiKey('63e5fc4563e00928da67d1226364dd4c');
$mixpanelProvider.superProperties({
/* eslint-disable camelcase */
distinct_id: username.sync(),
/* eslint-enable camelcase */
electron: app.getVersion(),
electron: process.versions.electron,
node: process.version,
arch: process.arch,
version: packageJSON.version
version: packageJSON.version,
osPlatform: os.platform(),
hostArch: getHostArchitecture(),
osRelease: os.release(),
cpuCores: os.cpus().length,
totalMemory: os.totalmem(),
startFreeMemory: os.freemem()
});
});
@ -120,18 +141,14 @@ analytics.service('AnalyticsService', function($log, $window, $mixpanel, Setting
* });
*/
this.logEvent = (message, data) => {
const flatStartCaseData = utils.makeFlatStartCaseObject(data);
if (SettingsModel.get('errorReporting') && isRunningInAsar()) {
// Clone data before passing it to `mixpanel.track`
// since this function mutates the object adding
// some custom private Mixpanel properties.
$mixpanel.track(message, _.clone(data));
$mixpanel.track(message, flatStartCaseData);
}
const debugMessage = _.attempt(() => {
if (data) {
return `${message} (${JSON.stringify(data)})`;
if (flatStartCaseData) {
return `${message} (${JSON.stringify(flatStartCaseData)})`;
}
return message;

View File

@ -24,7 +24,9 @@ const angular = require('angular');
const url = require('url');
const MODULE_NAME = 'Etcher.OS.OpenExternal';
const OSOpenExternal = angular.module(MODULE_NAME, []);
const OSOpenExternal = angular.module(MODULE_NAME, [
require('../../modules/analytics')
]);
OSOpenExternal.service('OSOpenExternalService', require('./services/open-external'));
OSOpenExternal.directive('osOpenExternal', require('./directives/open-external'));

View File

@ -18,7 +18,7 @@
const electron = require('electron');
module.exports = function() {
module.exports = function(AnalyticsService) {
/**
* @summary Open an external resource
@ -31,6 +31,10 @@ module.exports = function() {
* OSOpenExternalService.open('https://www.google.com');
*/
this.open = (url) => {
AnalyticsService.logEvent('Open external link', {
url
});
if (url) {
electron.shell.openExternal(url);
}

View File

@ -16,7 +16,7 @@
'use strict';
module.exports = function(SelectionStateModel, AnalyticsService, ErrorService, DriveSelectorService) {
module.exports = function(SelectionStateModel, AnalyticsService, ErrorService, DriveSelectorService, SettingsModel) {
/**
* @summary Open drive selector
@ -35,7 +35,8 @@ module.exports = function(SelectionStateModel, AnalyticsService, ErrorService, D
SelectionStateModel.setDrive(drive.device);
AnalyticsService.logEvent('Select drive', {
device: drive.device
device: drive.device,
unsafeMode: SettingsModel.get('unsafeMode')
});
}).catch(ErrorService.reportException);
};

View File

@ -36,11 +36,14 @@ module.exports = function(
* @function
* @public
*
* @param {String} image - image path
* @param {Object} image - image
* @param {Object} drive - drive
*
* @example
* FlashController.flashImageToDrive('rpi.img', {
* FlashController.flashImageToDrive({
* path: 'rpi.img',
* size: 1000000000
* }, {
* device: '/dev/disk2',
* description: 'Foo',
* size: 99999,
@ -59,10 +62,11 @@ module.exports = function(
AnalyticsService.logEvent('Flash', {
image,
device: drive.device
drive,
unmountOnSuccess: SettingsModel.get('unmountOnSuccess')
});
ImageWriterService.flash(image, drive).then(() => {
ImageWriterService.flash(image.path, drive).then(() => {
if (FlashStateModel.wasLastFlashCancelled()) {
return;
}
@ -83,7 +87,9 @@ module.exports = function(
} else {
FlashErrorModalService.show(messages.error.genericFlashError());
ErrorService.reportException(error);
AnalyticsService.logEvent('Flash error');
AnalyticsService.logEvent('Flash error', {
error
});
}
})

View File

@ -115,11 +115,14 @@ module.exports = function(
* ImageSelectionController.openImageSelector();
*/
this.openImageSelector = () => {
AnalyticsService.logEvent('Open image selector');
OSDialogService.selectImage().then((image) => {
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!image) {
AnalyticsService.logEvent('Image selector closed');
return;
}
@ -136,8 +139,11 @@ module.exports = function(
* ImageSelectionController.reselectImage();
*/
this.reselectImage = () => {
AnalyticsService.logEvent('Reselect image', {
previousImage: SelectionStateModel.getImage()
});
this.openImageSelector();
AnalyticsService.logEvent('Reselect image');
};
};

View File

@ -23,7 +23,8 @@ module.exports = function(
SettingsModel,
TooltipModalService,
ErrorService,
OSOpenExternalService
OSOpenExternalService,
AnalyticsService
) {
// Expose several modules to the template for convenience
@ -76,6 +77,10 @@ module.exports = function(
* MainController.showSelectedImageDetails()
*/
this.showSelectedImageDetails = () => {
AnalyticsService.logEvent('Show selected image tooltip', {
imagePath: SelectionStateModel.getImagePath()
});
return TooltipModalService.show({
title: 'Image File Name',
message: SelectionStateModel.getImagePath()

View File

@ -83,7 +83,7 @@
percentage="main.state.getFlashState().percentage"
striped="{{ main.state.getFlashState().type == 'check' }}"
ng-attr-active="{{ main.state.isFlashing() }}"
ng-click="flash.flashImageToDrive(main.selection.getImagePath(), main.selection.getDrive())"
ng-click="flash.flashImageToDrive(main.selection.getImage(), main.selection.getDrive())"
ng-disabled="main.shouldFlashStepBeDisabled() || main.state.getLastFlashErrorCode()">
<span ng-bind="flash.getProgressButtonLabel()"></span>
</progress-button>

View File

@ -17,8 +17,9 @@
'use strict';
const os = require('os');
const _ = require('lodash');
module.exports = function(WarningModalService, SettingsModel, ErrorService) {
module.exports = function(WarningModalService, SettingsModel, ErrorService, AnalyticsService) {
/**
* @summary Client platform
@ -53,34 +54,48 @@ module.exports = function(WarningModalService, SettingsModel, ErrorService) {
this.model = SettingsModel;
/**
* @summary Enable a dangerous setting
* @summary Toggle setting
* @function
* @public
*
* @param {String} name - setting name
* @param {Object} options - options
* @param {String} options.description - modal description
* @param {String} options.confirmationLabel - modal confirmation label
* @description
* If warningOptions is given, it should be an object having `description` and `confirmationLabel`;
* these will be used to present a user confirmation modal before enabling the setting.
* If warningOptions is missing, no confirmation modal is displayed.
*
* @param {String} setting - setting key
* @param {Object} [options] - options
* @param {String} [options.description] - warning modal description
* @param {String} [options.confirmationLabel] - warning modal confirmation label
* @returns {Undefined}
*
* @example
* SettingsController.enableDangerousSetting('unsafeMode', {
* SettingsController.toggle('unsafeMode', {
* description: 'Don\'t do this!',
* confirmationLabel: 'Do it!'
* });
*/
this.enableDangerousSetting = (name, options) => {
if (!this.currentData[name]) {
this.model.set(name, false);
return this.refreshSettings();
this.toggle = (setting, options) => {
const value = this.currentData[setting];
const dangerous = !_.isUndefined(options);
AnalyticsService.logEvent('Toggle setting', {
setting,
value,
dangerous
});
if (!value || !dangerous) {
return this.model.set(setting, value);
}
// Keep the checkbox unchecked until the user confirms
this.currentData[name] = false;
this.currentData[setting] = false;
return WarningModalService.display(options).then((userAccepted) => {
if (userAccepted) {
this.model.set(name, true);
this.model.set(setting, true);
this.refreshSettings();
}
}).catch(ErrorService.reportException);

View File

@ -26,7 +26,8 @@ const SettingsPage = angular.module(MODULE_NAME, [
require('angular-ui-router'),
require('../../components/warning-modal/warning-modal'),
require('../../models/settings'),
require('../../modules/error')
require('../../modules/error'),
require('../../modules/analytics')
]);
SettingsPage.controller('SettingsController', require('./controllers/settings'));

View File

@ -5,7 +5,7 @@
<label>
<input type="checkbox"
ng-model="settings.currentData.errorReporting"
ng-change="settings.model.set('errorReporting', settings.currentData.errorReporting)">
ng-change="settings.toggle('errorReporting')">
<span>Report errors</span>
</label>
@ -15,7 +15,7 @@
<label>
<input type="checkbox"
ng-model="settings.currentData.unmountOnSuccess"
ng-change="settings.model.set('unmountOnSuccess', settings.currentData.unmountOnSuccess)">
ng-change="settings.toggle('unmountOnSuccess')">
<!-- On Windows, "Unmounting" basically means "ejecting". -->
<!-- On top of that, Windows users are usually not even -->
@ -34,7 +34,7 @@
<label>
<input type="checkbox"
ng-model="settings.currentData.validateWriteOnSuccess"
ng-change="settings.model.set('validateWriteOnSuccess', settings.currentData.validateWriteOnSuccess)">
ng-change="settings.toggle('validateWriteOnSuccess')">
<span>Validate write on success</span>
</label>
@ -46,7 +46,7 @@
<label>
<input type="checkbox"
ng-model="settings.currentData.unsafeMode"
ng-change="settings.enableDangerousSetting('unsafeMode', {
ng-change="settings.toggle('unsafeMode', {
description: 'Are you sure you want to turn this on? You will be able to overwrite your system drives if you\'re not careful.',
confirmationLabel: 'Enable unsafe mode'
})">

83
lib/shared/utils.js Normal file
View File

@ -0,0 +1,83 @@
/*
* Copyright 2017 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 flatten = require('flat').flatten;
const deepMapKeys = require('deep-map-keys');
/**
* @summary Create a flattened copy of the object with all keys transformed in start case
* @function
* @public
*
* @param {*} object - object to transform
* @returns {*} transformed object
*
* @example
* const object = makeFlatStartCaseObject({
* image: {
* size: 10000000000,
* recommendedSize: 10000000000
* }
* })
*
* console.log(object)
* > {
* > 'Image Size': 10000000000,
* > 'Image Recommended Size': 10000000000
* > }
*/
exports.makeFlatStartCaseObject = (object) => {
if (_.isUndefined(object)) {
return object;
}
// Transform primitives to objects
if (!_.isObject(object)) {
return {
Value: object
};
}
if (_.isArray(object)) {
return _.map(object, (property) => {
if (_.isObject(property)) {
return exports.makeFlatStartCaseObject(property);
}
return property;
});
}
const transformedKeysObject = deepMapKeys(object, (key) => {
// Preserve environment variables
const regex = /^[A-Z_]+$/;
if (regex.test(key)) {
return key;
}
return _.startCase(key);
});
return flatten(transformedKeysObject, {
delimiter: ' ',
safe: true
});
};

45
npm-shrinkwrap.json generated
View File

@ -64,6 +64,11 @@
"from": "any-promise@>=1.1.0 <2.0.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz"
},
"arch": {
"version": "2.1.0",
"from": "arch@2.1.0",
"resolved": "https://registry.npmjs.org/arch/-/arch-2.1.0.tgz"
},
"archive-type": {
"version": "3.2.0",
"from": "archive-type@>=3.2.0 <4.0.0",
@ -233,6 +238,11 @@
"from": "cross-spawn-async@>=2.1.1 <3.0.0",
"resolved": "https://registry.npmjs.org/cross-spawn-async/-/cross-spawn-async-2.2.4.tgz"
},
"d": {
"version": "0.1.1",
"from": "d@>=0.1.1 <0.2.0",
"resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz"
},
"debug": {
"version": "2.6.0",
"from": "debug@>=2.2.0 <3.0.0",
@ -243,6 +253,11 @@
"from": "decamelize@>=1.1.1 <2.0.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
},
"deep-map-keys": {
"version": "1.2.0",
"from": "deep-map-keys@1.2.0",
"resolved": "https://registry.npmjs.org/deep-map-keys/-/deep-map-keys-1.2.0.tgz"
},
"defaults": {
"version": "1.0.3",
"from": "defaults@>=1.0.3 <2.0.0",
@ -287,6 +302,26 @@
"from": "error-ex@>=1.2.0 <2.0.0",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.0.tgz"
},
"es5-ext": {
"version": "0.10.12",
"from": "es5-ext@>=0.10.11 <0.11.0",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.12.tgz"
},
"es6-iterator": {
"version": "2.0.0",
"from": "es6-iterator@>=2.0.0 <3.0.0",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.0.tgz"
},
"es6-symbol": {
"version": "3.1.0",
"from": "es6-symbol@>=3.1.0 <3.2.0",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.0.tgz"
},
"es6-weak-map": {
"version": "2.0.1",
"from": "es6-weak-map@>=2.0.1 <3.0.0",
"resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.1.tgz"
},
"escape-string-regexp": {
"version": "1.0.5",
"from": "escape-string-regexp@>=1.0.2 <2.0.0",
@ -381,6 +416,11 @@
"from": "find-up@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz"
},
"flat": {
"version": "2.0.1",
"from": "flat@2.0.1",
"resolved": "https://registry.npmjs.org/flat/-/flat-2.0.1.tgz"
},
"flexboxgrid": {
"version": "6.3.0",
"from": "flexboxgrid@>=6.3.0 <7.0.0",
@ -465,6 +505,11 @@
"from": "is-arrayish@>=0.2.1 <0.3.0",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz"
},
"is-buffer": {
"version": "1.1.4",
"from": "is-buffer@>=1.1.0 <2.0.0",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.4.tgz"
},
"is-builtin-module": {
"version": "1.0.0",
"from": "is-builtin-module@>=1.0.0 <2.0.0",

View File

@ -69,15 +69,18 @@
"angular-seconds-to-date": "^1.0.0",
"angular-ui-bootstrap": "^2.5.0",
"angular-ui-router": "^0.4.2",
"arch": "^2.1.0",
"archive-type": "^3.2.0",
"bluebird": "^3.0.5",
"bootstrap-sass": "^3.3.5",
"chalk": "^1.1.3",
"deep-map-keys": "^1.2.0",
"drivelist": "^5.0.16",
"electron-is-running-in-asar": "^1.0.0",
"etcher-image-write": "^9.0.1",
"etcher-latest-version": "^1.0.0",
"file-tail": "^0.3.0",
"flat": "^2.0.1",
"flexboxgrid": "^6.3.0",
"gzip-uncompressed-size": "^1.0.0",
"immutable": "^3.8.1",

127
tests/shared/utils.spec.js Normal file
View File

@ -0,0 +1,127 @@
/*
* Copyright 2017 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 utils = require('../../lib/shared/utils');
describe('Shared: Utils', function() {
describe('.makeFlatStartCaseObject()', function() {
it('should return undefined if given undefined', function() {
m.chai.expect(utils.makeFlatStartCaseObject(undefined)).to.be.undefined;
});
it('should return flat object with start case keys if given nested object', function() {
const object = {
person: {
firstName: 'John',
lastName: 'Doe',
address: {
streetNumber: 13,
streetName: 'Elm'
}
}
};
m.chai.expect(utils.makeFlatStartCaseObject(object)).to.deep.equal({
'Person First Name': 'John',
'Person Last Name': 'Doe',
'Person Address Street Number': 13,
'Person Address Street Name': 'Elm'
});
});
it('should return an object with the key `value` if given `false`', function() {
m.chai.expect(utils.makeFlatStartCaseObject(false)).to.deep.equal({
Value: false
});
});
it('should return an object with the key `value` if given `null`', function() {
m.chai.expect(utils.makeFlatStartCaseObject(null)).to.deep.equal({
Value: null
});
});
it('should preserve environment variable', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
ETCHER_DISABLE_UPDATES: true
})).to.deep.equal({
ETCHER_DISABLE_UPDATES: true
});
});
it('should preserve environment variables inside objects', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
foo: {
FOO_BAR_BAZ: 3
}
})).to.deep.equal({
'Foo FOO_BAR_BAZ': 3
});
});
it('should insert space after key starting with number', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
foo: {
'1key': 1
}
})).to.deep.equal({
'Foo 1 Key': 1
});
});
it('should not modify start case keys', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
Foo: {
'Start Case Key': 42
}
})).to.deep.equal({
'Foo Start Case Key': 42
});
});
it('should not modify arrays', function() {
m.chai.expect(utils.makeFlatStartCaseObject([ 1, 2, {
nested: 3
} ])).to.deep.equal([ 1, 2, {
Nested: 3
} ]);
});
it('should not modify nested arrays', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
values: [ 1, 2, {
nested: 3
} ]
})).to.deep.equal({
Values: [ 1, 2, {
Nested: 3
} ]
});
});
it('should leave nested arrays nested', function() {
m.chai.expect(utils.makeFlatStartCaseObject([ 1, 2, [ 3, 4 ] ])).to.deep.equal([ 1, 2, [ 3, 4 ] ]);
});
});
});