feat: Add front end metrics gathering.

* Use SpeedTrap to collect client side metrics
* Add screen views and errors to an event stream.
* Metrics are sent to the `/metrics` endpoint on document unload or after 10 minutes of inactivity.
* Data that is sent to the backend is filtered - only data we expect is sent.
* Data collection sample rate is set by server configuration.

Other logged events:

* users who cancel login from the browser.
* complete_reset_password:link_damaged
* complete_reset_password:link_expired
* complete_signn_up:link_damaged
* confirm:too_many_attempts
* confirm:resend
* confirm_reset_password_resend
* login:canceled

Others changes:
* AuthErrors.toMessage now accepts `forceMessage`
* Change AuthErrors.toCode to accept an error object.
* Give `Session expired` an error code of 1002.

issue #1119
This commit is contained in:
Shane Tomlinson 2014-05-19 12:42:46 +01:00
Родитель e6adb592eb
Коммит 084fce06ae
40 изменённых файлов: 1077 добавлений и 82 удалений

Просмотреть файл

@ -28,7 +28,9 @@ define([
'lib/url',
'lib/channels/web',
'lib/channels/fx-desktop',
'lib/config-loader'
'lib/config-loader',
'lib/metrics',
'lib/null-metrics'
],
function (
_,
@ -41,7 +43,9 @@ function (
Url,
WebChannel,
FxDesktopChannel,
ConfigLoader
ConfigLoader,
Metrics,
NullMetrics
) {
function getChannel() {
@ -75,11 +79,24 @@ function (
setSessionValueFromUrl('context');
}
function isMetricsCollectionEnabled (sampleRate) {
return Math.random() <= sampleRate;
}
function createMetrics(sampleRate) {
if (isMetricsCollectionEnabled(sampleRate)) {
return new Metrics();
}
return new NullMetrics();
}
function Start(options) {
options = options || {};
this._window = options.window || window;
this._window.router = this._router = options.router || new Router();
this._router = options.router;
this._history = options.history || Backbone.history;
this._configLoader = new ConfigLoader();
}
@ -101,11 +118,19 @@ function (
.then(_.bind(this.useConfig, this));
},
useConfig: function(config) {
useConfig: function (config) {
this._config = config;
this._configLoader.useConfig(config);
Session.set('config', config);
Session.set('language', config.language);
this._metrics = createMetrics(config.metricsSampleRate);
this._metrics.init();
if (! this._router) {
this._router = new Router({ metrics: this._metrics });
}
this._window.router = this._router;
},
initializeL10n: function () {

Просмотреть файл

@ -36,13 +36,16 @@ function () {
SERVICE_UNAVAILABLE: 998,
SERVER_BUSY: 201,
ENDPOINT_NOT_SUPPORTED: 116,
USER_CANCELED_LOGIN: 1001 // local only error code for when user cancels desktop login
// local only error codes
USER_CANCELED_LOGIN: 1001,
SESSION_EXPIRED: 1002
};
var CODE_TO_MESSAGES = {
// errors returned by the auth server
999: t('Unexpected error'),
110: t('Invalid authentication token in request signature'),
110: t('Invalid token'),
111: t('Invalid timestamp in request signature'),
115: t('Invalid nonce in request signature'),
101: t('Account already exists'),
@ -60,7 +63,10 @@ function () {
114: t('Attempt limit exceeded.'),
998: t('System unavailable, try again soon'),
201: t('Server busy, try again soon'),
116: t('This endpoint is no longer supported')
116: t('This endpoint is no longer supported'),
// local only error messages
1002: t('Session expired. Sign in to continue.')
};
return {
@ -72,6 +78,8 @@ function () {
if (typeof err === 'number') {
code = err;
} else if (err && err.forceMessage) {
return err.forceMessage;
// error from backend
} else if (err && typeof err.errno === 'number') {
code = err.errno;
@ -118,10 +126,10 @@ function () {
},
/**
* Convert a text type from ERROR_TO_CODE to a numeric code
* Convert an error or a text type from ERROR_TO_CODE to a numeric code
*/
toCode: function (type) {
return ERROR_TO_CODE[type];
return type.errno || ERROR_TO_CODE[type] || type;
},
/**

230
app/scripts/lib/metrics.js Normal file
Просмотреть файл

@ -0,0 +1,230 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/*
* A metrics module!
*
* An instantiated metrics object has two primary APIs:
*
* metrics.logEvent(<event_name>);
* metrics.startTimer(<timer_name>)/metrics.stopTimer(<timer_name);
*
* Metrics are automatically sent to the server on window.unload
* but can also be sent by calling metrics.flush();
*/
define([
'underscore',
'backbone',
'jquery',
'speedTrap',
'p-promise',
'lib/url'
], function (_, Backbone, $, speedTrap, p, Url) {
'use strict';
// Speed trap is a singleton, convert it
// to an instantiable function.
var SpeedTrap = function() {};
SpeedTrap.prototype = speedTrap;
var ALLOWED_FIELDS = [
'date',
'navigationTiming',
'referrer',
'duration',
'timers',
'events',
'context',
'service'
];
var TEN_MINS_MS = 10 * 60 * 1000;
function getDate() {
var roundedDate = new Date();
roundedDate.setHours(0, 0, 0, 0);
return roundedDate;
}
function Metrics (options) {
options = options || {};
// by default, send the metrics to the content server.
this._collector = options.collector || '';
this._ajax = options.ajax || $.ajax;
this._speedTrap = new SpeedTrap();
this._speedTrap.init();
// `timers` and `events` are part of the public API
this.timers = this._speedTrap.timers;
this.events = this._speedTrap.events;
this._date = getDate();
this._window = options.window || window;
var searchParams = this._window.location.search;
this._context = Url.searchParam('context', searchParams);
this._service = Url.searchParam('service', searchParams);
this._inactivityFlushMs = options.inactivityFlushMs || TEN_MINS_MS;
}
_.extend(Metrics.prototype, Backbone.Events, {
ALLOWED_FIELDS: ALLOWED_FIELDS,
init: function () {
this._flush = _.bind(this.flush, this);
$(this._window).on('unload', this._flush);
// Set the initial inactivity timeout to clear navigation timing data.
this._resetInactivityFlushTimeout();
},
destroy: function () {
$(this._window).off('unload', this._flush);
this._clearInactivityFlushTimeout();
},
/**
* Send the collected data to the backend.
*/
flush: function () {
// Inactivity timer is restarted when the next event/timer comes in.
// This avoids sending empty result sets if the tab is
// just sitting there open with no activity.
this._clearInactivityFlushTimeout();
var filteredData = this.getFilteredData();
this._speedTrap.events.clear();
this._speedTrap.timers.clear();
var url = this._collector + '/metrics';
// use a synchronous request to block the page from unloading
// until the request is complete.
return this._send(filteredData, url, false);
},
_clearInactivityFlushTimeout: function () {
clearTimeout(this._inactivityFlushTimeout);
},
_resetInactivityFlushTimeout: function () {
this._clearInactivityFlushTimeout();
var self = this;
this._inactivityFlushTimeout =
setTimeout(function () {
self.logEvent('inactivity:flush');
self.flush();
}, this._inactivityFlushMs);
},
/**
* Get all the data, whether it's allowed to be sent or not.
*/
getAllData: function () {
var loadData = this._speedTrap.getLoad();
var unloadData = this._speedTrap.getUnload();
var allData = _.extend({
date: this._date.toISOString(),
context: this._context,
service: this._service
}, loadData, unloadData);
return allData;
},
/**
* Get the filtered data.
* Filtered data is data that is allowed to be sent,
* that is defined and not an empty string.
*/
getFilteredData: function () {
var allData = this.getAllData();
var filteredData = {};
_.forEach(ALLOWED_FIELDS, function (itemName) {
if (typeof allData[itemName] !== 'undefined' &&
allData[itemName] !== '') {
filteredData[itemName] = allData[itemName];
}
});
return filteredData;
},
_send: function (data, url, async) {
var deferred = p.defer();
var self = this;
this._ajax({
async: async !== false,
type: 'POST',
url: url,
contentType: 'application/json',
data: JSON.stringify(data),
error: function (jqXHR, textStatus, errorThrown) {
self.trigger('flush:error');
deferred.reject(errorThrown);
},
success: function () {
self.trigger('flush:success', data);
deferred.resolve(data);
}
});
return deferred.promise;
},
/**
* Log an event
*/
logEvent: function (eventName) {
this._resetInactivityFlushTimeout();
this.events.capture(eventName);
},
/**
* Start a timer
*/
startTimer: function (timerName) {
this._resetInactivityFlushTimeout();
this.timers.start(timerName);
},
/**
* Stop a timer
*/
stopTimer: function (timerName) {
this._resetInactivityFlushTimeout();
this.timers.stop(timerName);
},
/**
* Convert an error to an identifier
*/
errorToId: function (err, errors) {
return 'error:' + errors.toCode(err);
},
/**
* Convert a pathname from a URL to an identifier
*/
pathToId: function (path) {
return 'screen:' + Url.pathToScreenName(path);
}
});
return Metrics;
});

Просмотреть файл

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/*
* A null metrics module. For use as a standin if metrics are disabled
* or for unit tests.
*/
define([
'underscore',
'p-promise',
'lib/metrics'
], function (_, p, Metrics) {
'use strict';
function NullMetrics () {
// do nothing
}
_.forEach(_.keys(Metrics.prototype), function(key) {
NullMetrics.prototype[key] = function () {
// do nothing
};
});
// Metrics.flush returns a promise.
NullMetrics.prototype.flush = function () {
return p();
};
return NullMetrics;
});

Просмотреть файл

@ -31,7 +31,17 @@ function (_) {
var terms = searchParams(str);
return terms[name];
},
pathToScreenName: function (path) {
// strip leading /
return path.replace(/^\//, '')
// strip trailing /
.replace(/\/$/, '')
// search params can contain sensitive info
.replace(/\?.*/, '');
}
};
});

Просмотреть файл

@ -13,7 +13,8 @@ require.config({
text: '../bower_components/requirejs-text/text',
mustache: '../bower_components/mustache/mustache',
stache: '../bower_components/requirejs-mustache/stache',
'p-promise': '../bower_components/p/p'
'p-promise': '../bower_components/p/p',
speedTrap: '../bower_components/speed-trap/dist/speed-trap'
},
shim: {
underscore: {

Просмотреть файл

@ -57,7 +57,15 @@ function (
function showView(View, options) {
return function () {
this.showView(new View(options || {}));
// passed in options block can override
// default options.
options = _.extend({
metrics: this.metrics,
window: this.window,
router: this
}, options || {});
this.showView(new View(options));
};
}
@ -92,6 +100,8 @@ function (
this.window = options.window || window;
this.metrics = options.metrics;
this.$stage = $('#stage');
this.watchAnchors();

Просмотреть файл

@ -14,9 +14,10 @@ define([
'lib/fxa-client',
'lib/url',
'lib/strings',
'lib/ephemeral-messages'
'lib/ephemeral-messages',
'lib/null-metrics'
],
function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, EphemeralMessages) {
function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, EphemeralMessages, NullMetrics) {
var ENTER_BUTTON_CODE = 13;
var DEFAULT_TITLE = window.document.title;
var EPHEMERAL_MESSAGE_ANIMATION_MS = 150;
@ -25,6 +26,11 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
// intialized with an ephemeralMessages for testing.
var ephemeralMessages = new EphemeralMessages();
// A null metrics instance is created for unit tests. In the app,
// when a view is initialized, an initialized Metrics instance
// is passed in to the contstructor.
var nullMetrics = new NullMetrics();
var BaseView = Backbone.View.extend({
constructor: function (options) {
options = options || {};
@ -34,6 +40,7 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
this.translator = options.translator || this.window.translator;
this.router = options.router || this.window.router;
this.ephemeralMessages = options.ephemeralMessages || ephemeralMessages;
this.metrics = options.metrics || nullMetrics;
this.fxaClient = new FxaClient();
@ -53,6 +60,9 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
*/
render: function () {
var self = this;
self.logScreen();
return p()
.then(function () {
return self.isUserAuthorized();
@ -60,8 +70,9 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
.then(function (isUserAuthorized) {
if (! isUserAuthorized) {
// user is not authorized, make them sign in.
var err = AuthErrors.toError('SESSION_EXPIRED');
self.navigate('signin', {
error: t('Session expired. Sign in to continue.')
error: err
});
return false;
}
@ -291,6 +302,7 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
this.hideSuccess();
this.$('.spinner').hide();
this.logError(err, errors);
var translated = this.translateError(err, errors);
if (translated) {
@ -305,6 +317,39 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
return translated;
},
/**
* Log an error to the event stream
*/
logError: function (err, errors) {
// The error could already be logged, if so, abort mission.
// This can occur when `navigate` redirects a user to a different
// screen and an error is passed. The error is logged before the screen
// transition, the new screen is rendered, then the original error is
// displayed. This avoids duplicate entries.
if (err.logged) {
return;
}
err.logged = true;
errors = errors || AuthErrors;
this.logEvent(this.metrics.errorToId(err, errors));
},
/**
* Log the current screen
*/
logScreen: function () {
var path = this.window.location.pathname;
this.logEvent(this.metrics.pathToId(path));
},
/**
* Log an event to the event stream
*/
logEvent: function (eventName) {
this.metrics.logEvent(eventName);
},
/**
* Display an error message that may contain HTML. Marked unsafe
* because msg could contain XSS. Use with caution and never
@ -322,6 +367,7 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
this.hideSuccess();
this.$('.spinner').hide();
this.logError(err, errors);
var translated = this.translateError(err, errors);
if (translated) {
@ -369,6 +415,9 @@ function (_, Backbone, $, p, Session, AuthErrors, FxaClient, Url, Strings, Ephem
}
if (options.error) {
// log the error entry before the new screen is rendered so events
// stay in the correct order.
this.logError(options.error);
this.ephemeralMessages.set('error', options.error);
}
this.router.navigate(page, { trigger: true });

Просмотреть файл

@ -60,8 +60,8 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, AuthErrors) {
});
}, function (err) {
if (AuthErrors.is(err, 'UNVERIFIED_ACCOUNT')) {
var msg = t('Unverified account. <a href="#" id="resend">Resend verification email</a>.');
return self.displayErrorUnsafe(msg);
err.forceMessage = t('Unverified account. <a href="#" id="resend">Resend verification email</a>.');
return self.displayErrorUnsafe(err);
}
throw err;
@ -76,7 +76,9 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, AuthErrors) {
self.navigate('confirm');
}, function (err) {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
return self.navigate('signup');
return self.navigate('signup', {
error: err
});
}
throw self.displayError(err);

Просмотреть файл

@ -36,12 +36,14 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, Validate, Aut
} catch(e) {
// This is an invalid link. Abort and show an error message
// before doing any more checks.
this.logEvent('complete_reset_password:link_damaged');
return true;
}
if (! this._doesLinkValidate()) {
// One or more parameters fails validation. Abort and show an
// error message before doing any more checks.
this.logEvent('complete_reset_password:link_damaged');
return true;
}
@ -49,6 +51,7 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, Validate, Aut
return this.fxaClient.isPasswordResetComplete(this.token)
.then(function (isComplete) {
self._isLinkExpired = isComplete;
self.logEvent('complete_reset_password:link_expired');
return true;
});
},
@ -95,6 +98,7 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, Validate, Aut
self.navigate('reset_password_complete');
}, function (err) {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
self.logError(err);
// The token has expired since the first check, re-render to
// show a screen that allows the user to receive a new link.
return self.render();

Просмотреть файл

@ -23,6 +23,7 @@ function (_, FormView, BaseView, CompleteSignUpTemplate, FxaClient, AuthErrors,
this.importSearchParam('uid');
this.importSearchParam('code');
} catch(e) {
this.logEvent('complete_sign_up:link_damaged');
// This is an invalid link. Abort and show an error message
// before doing any more checks.
return true;
@ -31,6 +32,7 @@ function (_, FormView, BaseView, CompleteSignUpTemplate, FxaClient, AuthErrors,
if (! this._doesLinkValidate()) {
// One or more parameters fails validation. Abort and show an
// error message before doing any more checks.
this.logEvent('complete_sign_up:link_damaged');
return true;
}
@ -46,6 +48,7 @@ function (_, FormView, BaseView, CompleteSignUpTemplate, FxaClient, AuthErrors,
AuthErrors.is(err, 'INVALID_PARAMETER')) {
// These errors show a link damaged screen
self._isLinkDamaged = true;
self.logEvent('complete_sign_up:link_damaged');
} else {
// all other errors show the standard error box.
self._error = self.translateError(err);

Просмотреть файл

@ -12,7 +12,6 @@ define([
'lib/auth-errors'
],
function (FormView, BaseView, Template, Session, AuthErrors) {
var t = BaseView.t;
var SHOW_RESEND_IN_MS = 5 * 60 * 1000; // 5 minutes.
var VERIFICATION_POLL_IN_MS = 4000; // 4 seconds
@ -110,6 +109,7 @@ function (FormView, BaseView, Template, Session, AuthErrors) {
var self = this;
// Hide the button after 4 attempts. Redisplay button after a delay.
if (self._attemptedSubmits === 4) {
self.logEvent('confirm:too_many_attempts');
self.$('#resend').hide();
self._displayResendTimeout = setTimeout(function () {
self._displayResendTimeout = null;
@ -122,13 +122,14 @@ function (FormView, BaseView, Template, Session, AuthErrors) {
submit: function () {
var self = this;
self.logEvent('confirm:resend');
return this.fxaClient.signUpResend()
.then(function () {
self.displaySuccess();
}, function (err) {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
return self.navigate('signup', {
error: t('Invalid token')
error: err
});
}

Просмотреть файл

@ -78,13 +78,14 @@ function (_, ConfirmView, BaseView, Template, Session, Constants, AuthErrors) {
submit: function () {
var self = this;
self.logEvent('confirm_reset_password:resend');
return this.fxaClient.passwordResetResend()
.then(function () {
self.displaySuccess();
}, function (err) {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
return self.navigate('reset_password', {
error: t('Invalid token')
error: err
});
}

Просмотреть файл

@ -66,9 +66,10 @@ function (_, BaseView, FormView, Template, Session, Url, AuthErrors) {
// email indicates the signed in email. Use prefillEmail
// to avoid collisions across sessions.
Session.set('prefillEmail', email);
var msg = t('Unknown account. <a href="/signup">Sign up</a>');
return self.displayErrorUnsafe(msg);
err.forceMessage = t('Unknown account. <a href="/signup">Sign up</a>');
return self.displayErrorUnsafe(err);
} else if (AuthErrors.is(err, 'USER_CANCELED_LOGIN')) {
self.logEvent('login:canceled');
// if user canceled login, just stop
return;
}

Просмотреть файл

@ -74,8 +74,9 @@ function (_, p, BaseView, FormView, SignInTemplate, Constants, Session, Password
})
.then(null, function (err) {
if (AuthErrors.is(err, 'UNKNOWN_ACCOUNT')) {
return self._suggestSignUp();
return self._suggestSignUp(err);
} else if (AuthErrors.is(err, 'USER_CANCELED_LOGIN')) {
self.logEvent('login:canceled');
// if user canceled login, just stop
return;
}
@ -94,9 +95,9 @@ function (_, p, BaseView, FormView, SignInTemplate, Constants, Session, Password
return true;
},
_suggestSignUp: function () {
var msg = t('Unknown account. <a href="/signup">Sign up</a>');
return this.displayErrorUnsafe(msg);
_suggestSignUp: function (err) {
err.forceMessage = t('Unknown account. <a href="/signup">Sign up</a>');
return this.displayErrorUnsafe(err);
},
_savePrefillInfo: function () {
@ -125,7 +126,7 @@ function (_, p, BaseView, FormView, SignInTemplate, Constants, Session, Password
self.navigate('confirm_reset_password');
}, function (err) {
if (AuthErrors.is(err, 'UNKNOWN_ACCOUNT')) {
return self._suggestSignUp();
return self._suggestSignUp(err);
}
// resetPassword is not called from `submit` and must

Просмотреть файл

@ -143,8 +143,9 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, AuthErrors) {
// user in directly, instead, point the user to the signin page
// where the entered email/password will be prefilled.
if (AuthErrors.is(err, 'ACCOUNT_ALREADY_EXISTS')) {
return self._suggestSignIn();
return self._suggestSignIn(err);
} else if (AuthErrors.is(err, 'USER_CANCELED_LOGIN')) {
self.logEvent('login:canceled');
// if user canceled login, just stop
return;
}
@ -162,9 +163,9 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, AuthErrors) {
}
},
_suggestSignIn: function () {
var msg = t('Account already exists. <a href="/signin">Sign in</a>');
return this.displayErrorUnsafe(msg);
_suggestSignIn: function (err) {
err.forceMessage = t('Account already exists. <a href="/signin">Sign in</a>');
return this.displayErrorUnsafe(err);
},
_savePrefillInfo: function () {

Просмотреть файл

@ -77,6 +77,19 @@ define([
return email.split('@')[0];
}
function isEventLogged(metrics, eventName) {
var events = metrics.getFilteredData().events;
for (var i = 0; i < events.length; ++i) {
var event = events[i];
if (event.type === eventName) {
return true;
}
}
return false;
}
return {
requiresFocus: requiresFocus,
addFxaClientSpy: addFxaClientSpy,
@ -84,6 +97,7 @@ define([
wrapAssertion: wrapAssertion,
createRandomHexString: createRandomHexString,
createEmail: createEmail,
emailToUser: emailToUser
emailToUser: emailToUser,
isEventLogged: isEventLogged
};
});

Просмотреть файл

@ -14,7 +14,8 @@ require.config({
stache: '/bower_components/requirejs-mustache/stache',
chai: '/bower_components/chai/chai',
'p-promise': '/bower_components/p/p',
sinon: '/bower_components/sinon/index'
sinon: '/bower_components/sinon/index',
speedTrap: '/bower_components/speed-trap/dist/speed-trap'
},
shim: {
underscore: {
@ -56,6 +57,8 @@ require([
'../tests/spec/lib/app-start',
'../tests/spec/lib/validate',
'../tests/spec/lib/service-name',
'../tests/spec/lib/metrics',
'../tests/spec/lib/null-metrics',
'../tests/spec/views/base',
'../tests/spec/views/tooltip',
'../tests/spec/views/form',

Просмотреть файл

@ -30,6 +30,13 @@ function (chai, AuthErrors) {
AuthErrors.toMessage('this is an error'), 'this is an error');
});
it('uses forceMessage as the message if it exists', function () {
assert.equal(AuthErrors.toMessage({
errno: 102,
forceMessage: 'this is my message'
}), 'this is my message');
});
it('converts an error from the backend containing an errno to a message', function () {
assert.equal(
AuthErrors.toMessage({
@ -88,9 +95,18 @@ function (chai, AuthErrors) {
});
describe('toCode', function () {
it('converts a string type to a numeric code', function () {
it('returns the errno from an error object', function () {
var err = AuthErrors.toError('INVALID_TOKEN', 'bad token, man');
assert.equal(AuthErrors.toCode(err), 110);
});
it('converts a string type to a numeric code, if valid code', function () {
assert.equal(AuthErrors.toCode('UNKNOWN_ACCOUNT'), 102);
});
it('returns the string if an invalid code', function () {
assert.equal(AuthErrors.toCode('this is an invalid code'), 'this is an invalid code');
});
});
describe('is', function () {

Просмотреть файл

@ -0,0 +1,182 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// test the metrics library
define([
'chai',
'lib/metrics',
'../../mocks/window'
],
function (chai, Metrics, WindowMock) {
'use strict';
/*global describe, it*/
var assert = chai.assert;
describe('lib/metrics', function () {
var metrics, windowMock;
beforeEach(function () {
windowMock = new WindowMock();
windowMock.location.search = '?service=sync&context=fxa_desktop_v1';
metrics = new Metrics({
window: windowMock
});
metrics.init();
});
afterEach(function () {
metrics.destroy();
metrics = null;
});
describe('getFilteredData', function () {
it('gets data that is allowed to be sent to the server', function () {
var filteredData = metrics.getFilteredData();
// ensure results are filtered and no unexpected data makes it through.
for (var key in filteredData) {
assert.isTrue(metrics.ALLOWED_FIELDS.indexOf(key) > -1);
}
});
it('gets non-optional fields', function () {
var filteredData = metrics.getFilteredData();
assert.isTrue(filteredData.hasOwnProperty('date'));
assert.isTrue(filteredData.hasOwnProperty('events'));
assert.isTrue(filteredData.hasOwnProperty('timers'));
assert.isTrue(filteredData.hasOwnProperty('navigationTiming'));
assert.isTrue(filteredData.hasOwnProperty('duration'));
assert.isTrue(filteredData.hasOwnProperty('context'));
assert.isTrue(filteredData.hasOwnProperty('service'));
});
});
describe('logEvent', function () {
it('adds events to output data', function () {
metrics.logEvent('event1');
metrics.logEvent('event2');
metrics.logEvent('event3');
var filteredData = metrics.getFilteredData();
assert.equal(filteredData.events.length, 3);
assert.equal(filteredData.events[0].type, 'event1');
assert.equal(filteredData.events[1].type, 'event2');
assert.equal(filteredData.events[2].type, 'event3');
});
});
describe('startTimer/stopTimer', function () {
it('adds a timer to output data', function () {
metrics.startTimer('timer1');
metrics.stopTimer('timer1');
var filteredData = metrics.getFilteredData();
assert.equal(filteredData.timers.timer1.length, 1);
var timerData = filteredData.timers.timer1[0];
assert.ok(timerData.hasOwnProperty('start'));
assert.ok(timerData.hasOwnProperty('stop'));
assert.ok(timerData.hasOwnProperty('elapsed'));
});
});
describe('flush', function () {
var sentData, serverError;
function ajaxMock(options) {
sentData = options.data;
if (serverError) {
options.error({}, 'bad jiji', serverError);
} else {
options.success();
}
}
beforeEach(function () {
metrics.destroy();
metrics = new Metrics({
ajax: ajaxMock,
window: windowMock,
inactivityFlushMs: 100
});
metrics.init();
sentData = serverError = null;
});
it('sends filtered data to the server and clears the event stream', function () {
metrics.logEvent('event1');
metrics.logEvent('event2');
return metrics.flush()
.then(function (data) {
var parsedSentData = JSON.parse(sentData);
assert.deepEqual(data, parsedSentData);
var events = metrics.getFilteredData().events;
assert.equal(events.length, 0);
});
});
it('sends filtered data to the server on window unload', function (done) {
metrics.logEvent('event10');
metrics.logEvent('event20');
var filteredData = metrics.getFilteredData();
metrics.on('flush:success', function () {
var parsedSentData = JSON.parse(sentData);
// `duration` fields are different if the above `getFilteredData`
// is called in a different millisecond than the one used to
// generate data that is sent to the server.
// Ensure `duration` is in the results, but do not compare the two.
assert.isTrue(parsedSentData.hasOwnProperty('duration'));
delete parsedSentData.duration;
delete filteredData.duration;
assert.deepEqual(filteredData, parsedSentData);
done();
});
$(windowMock).trigger('unload');
});
it('handles server errors', function () {
metrics.logEvent('event100');
metrics.logEvent('event200');
serverError = 'server down';
return metrics.flush()
.then(null, function (err) {
// to ensure the failure branch is called, pass the error
// on to the next success callback which is called on
// success or failure.
return err;
})
.then(function(err) {
assert.equal(err, 'server down');
});
});
it('automatically flushes after inactivityFlushMs', function (done) {
metrics.events.clear();
metrics.logEvent('event-is-autoflushed');
metrics.on('flush:success', function (sentData) {
assert.equal(sentData.events[0].type, 'event-is-autoflushed');
done();
});
});
});
});
});

Просмотреть файл

@ -0,0 +1,44 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// test the metrics library
define([
'chai',
'lib/null-metrics',
'lib/metrics'
],
function (chai, NullMetrics, Metrics) {
'use strict';
/*global describe, it*/
var assert = chai.assert;
describe('lib/null-metrics', function () {
var nullMetrics;
beforeEach(function () {
nullMetrics = new NullMetrics();
});
afterEach(function () {
nullMetrics = null;
});
it('has the same function signature as Metrics', function () {
for (var key in Metrics.prototype) {
if (typeof Metrics.prototype[key] === 'function') {
assert.isFunction(nullMetrics[key], key);
}
}
});
it('flush returns a promise', function () {
return nullMetrics.flush()
.then(function () {
assert.isTrue(true);
});
});
});
});

Просмотреть файл

@ -13,15 +13,17 @@ define([
'views/sign_in',
'views/sign_up',
'lib/session',
'lib/constants',
'lib/metrics',
'../../mocks/window',
'lib/constants'
'../../lib/helpers'
],
function (chai, _, Backbone, Router, SignInView, SignUpView, Session, WindowMock, Constants) {
function (chai, _, Backbone, Router, SignInView, SignUpView, Session, Constants, Metrics, WindowMock, TestHelpers) {
/*global describe, beforeEach, afterEach, it*/
var assert = chai.assert;
describe('lib/router', function () {
var router, windowMock, origNavigate, navigateUrl, navigateOptions;
var router, windowMock, origNavigate, navigateUrl, navigateOptions, metrics;
beforeEach(function () {
navigateUrl = navigateOptions = null;
@ -29,8 +31,11 @@ function (chai, _, Backbone, Router, SignInView, SignUpView, Session, WindowMock
$('#container').html('<div id="stage"></div>');
windowMock = new WindowMock();
metrics = new Metrics();
router = new Router({
window: windowMock
window: windowMock,
metrics: metrics
});
origNavigate = Backbone.Router.prototype.navigate;
@ -41,7 +46,8 @@ function (chai, _, Backbone, Router, SignInView, SignUpView, Session, WindowMock
});
afterEach(function () {
windowMock = router = navigateUrl = navigateOptions = null;
metrics.destroy();
windowMock = router = navigateUrl = navigateOptions = metrics = null;
Backbone.Router.prototype.navigate = origNavigate;
$('#container').empty();
});
@ -85,8 +91,14 @@ function (chai, _, Backbone, Router, SignInView, SignUpView, Session, WindowMock
var signInView, signUpView;
beforeEach(function () {
signInView = new SignInView({});
signUpView = new SignUpView({});
signInView = new SignInView({
metrics: metrics,
window: windowMock
});
signUpView = new SignUpView({
metrics: metrics,
window: windowMock
});
});
afterEach(function() {
@ -94,6 +106,7 @@ function (chai, _, Backbone, Router, SignInView, SignUpView, Session, WindowMock
});
it('shows a view, then shows the new view', function () {
windowMock.location.pathname = '/signin';
return router.showView(signInView)
.then(function () {
assert.ok($('#fxa-signin-header').length);
@ -101,12 +114,16 @@ function (chai, _, Backbone, Router, SignInView, SignUpView, Session, WindowMock
// session was cleared in beforeEach, simulating a user
// visiting their first page. The user cannot go back.
assert.equal(Session.canGoBack, false);
windowMock.location.pathname = '/signup';
return router.showView(signUpView);
})
.then(function () {
assert.ok($('#fxa-signup-header').length);
// if there is a back button, it can be shown now.
assert.equal(Session.canGoBack, true);
assert.isTrue(TestHelpers.isEventLogged(metrics, 'screen:signin'));
assert.isTrue(TestHelpers.isEventLogged(metrics, 'screen:signup'));
});
});
});

Просмотреть файл

@ -28,6 +28,24 @@ function (chai, _, Url) {
assert.isUndefined(Url.searchParam('animal'));
});
});
describe('pathToScreenName', function () {
it('strips leading /', function () {
assert.equal(Url.pathToScreenName('/signin'), 'signin');
});
it('strips trailing /', function () {
assert.equal(Url.pathToScreenName('signup/'), 'signup');
});
it('leaves middle / alone', function () {
assert.equal(Url.pathToScreenName('/legal/tos/'), 'legal/tos');
});
it('strips search parameters', function () {
assert.equal(Url.pathToScreenName('complete_sign_up?email=testuser@testuser.com'), 'complete_sign_up');
});
});
});
});

Просмотреть файл

@ -11,21 +11,24 @@ define([
'views/base',
'lib/translator',
'lib/ephemeral-messages',
'lib/metrics',
'lib/auth-errors',
'stache!templates/test_template',
'../../mocks/dom-event',
'../../mocks/router',
'../../mocks/window',
'../../lib/helpers'
],
function (chai, jQuery, BaseView, Translator, EphemeralMessages,
Template, DOMEventMock, RouterMock, WindowMock, TestHelpers) {
function (chai, jQuery, BaseView, Translator, EphemeralMessages, Metrics,
AuthErrors, Template, DOMEventMock, RouterMock, WindowMock,
TestHelpers) {
var requiresFocus = TestHelpers.requiresFocus;
var wrapAssertion = TestHelpers.wrapAssertion;
var assert = chai.assert;
describe('views/base', function () {
var view, router, windowMock, ephemeralMessages, translator;
var view, router, windowMock, ephemeralMessages, translator, metrics;
beforeEach(function () {
translator = new Translator('en-US', ['en-US']);
@ -36,8 +39,8 @@ function (chai, jQuery, BaseView, Translator, EphemeralMessages,
router = new RouterMock();
windowMock = new WindowMock();
ephemeralMessages = new EphemeralMessages();
metrics = new Metrics();
var View = BaseView.extend({
template: Template
@ -47,7 +50,8 @@ function (chai, jQuery, BaseView, Translator, EphemeralMessages,
translator: translator,
router: router,
window: windowMock,
ephemeralMessages: ephemeralMessages
ephemeralMessages: ephemeralMessages,
metrics: metrics
});
return view.render()
@ -57,11 +61,14 @@ function (chai, jQuery, BaseView, Translator, EphemeralMessages,
});
afterEach(function () {
metrics.destroy();
if (view) {
view.destroy();
jQuery(view.el).remove();
view = router = windowMock = null;
}
view = router = windowMock = metrics = null;
});
describe('render', function () {
@ -186,6 +193,14 @@ function (chai, jQuery, BaseView, Translator, EphemeralMessages,
view.displayError('an error message<div>with html</div>');
assert.equal(view.$('.error').html(), 'an error message&lt;div&gt;with html&lt;/div&gt;');
});
it('adds an entry into the event stream', function () {
var err = AuthErrors.toError('INVALID_TOKEN', 'bad token, man');
view.displayError(err);
assert.isTrue(TestHelpers.isEventLogged(metrics,
metrics.errorToId('INVALID_TOKEN', AuthErrors)));
});
});
describe('displayErrorUnsafe', function () {
@ -199,6 +214,14 @@ function (chai, jQuery, BaseView, Translator, EphemeralMessages,
view.hideError();
assert.isFalse(view.isErrorVisible());
});
it('adds an entry into the event stream', function () {
var err = AuthErrors.toError('INVALID_TOKEN', 'bad token, man');
view.displayError(err);
assert.isTrue(TestHelpers.isEventLogged(metrics,
metrics.errorToId('INVALID_TOKEN', AuthErrors)));
});
});
describe('displaySuccess', function () {
@ -224,6 +247,15 @@ function (chai, jQuery, BaseView, Translator, EphemeralMessages,
});
view.navigate('signin');
});
it('logs an error if an error is passed in the options', function () {
view.navigate('signin', {
error: AuthErrors.toError('SESSION_EXPIRED')
});
assert.isTrue(TestHelpers.isEventLogged(metrics,
metrics.errorToId('SESSION_EXPIRED', AuthErrors)));
});
});
describe('focus', function () {
@ -346,6 +378,32 @@ function (chai, jQuery, BaseView, Translator, EphemeralMessages,
assert.ok(err);
});
});
describe('logEvent', function () {
it('logs an event to the event stream', function () {
view.logEvent('event1');
assert.isTrue(TestHelpers.isEventLogged(metrics, 'event1'));
});
});
describe('logError', function () {
it('logs an error to the event stream', function () {
view.logError(AuthErrors.toError('INVALID_TOKEN'));
assert.isTrue(TestHelpers.isEventLogged(metrics,
metrics.errorToId('INVALID_TOKEN', AuthErrors)));
});
it('does not log already logged errors', function () {
view.metrics.events.clear();
var err = AuthErrors.toError('INVALID_TOKEN');
view.logError(err);
view.logError(err);
var events = view.metrics.getFilteredData().events;
assert.equal(events.length, 1);
});
});
});
});

Просмотреть файл

@ -9,26 +9,34 @@ define([
'chai',
'p-promise',
'lib/auth-errors',
'lib/metrics',
'views/complete_reset_password',
'../../mocks/router',
'../../mocks/window',
'../../lib/helpers'
],
function (chai, p, AuthErrors, View, RouterMock, WindowMock, TestHelpers) {
function (chai, p, AuthErrors, Metrics, View, RouterMock, WindowMock, TestHelpers) {
var assert = chai.assert;
var wrapAssertion = TestHelpers.wrapAssertion;
describe('views/complete_reset_password', function () {
var view, routerMock, windowMock, isPasswordResetComplete;
var view, routerMock, windowMock, isPasswordResetComplete, metrics;
function testEventLogged(eventName) {
assert.isTrue(TestHelpers.isEventLogged(metrics, eventName));
}
beforeEach(function () {
routerMock = new RouterMock();
windowMock = new WindowMock();
metrics = new Metrics();
windowMock.location.search = '?code=dea0fae1abc2fab3bed4dec5eec6ace7&email=testuser@testuser.com&token=feed';
view = new View({
router: routerMock,
window: windowMock
window: windowMock,
metrics: metrics
});
// mock in isPasswordResetComplete
@ -44,9 +52,12 @@ function (chai, p, AuthErrors, View, RouterMock, WindowMock, TestHelpers) {
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = windowMock = null;
view = windowMock = metrics = null;
});
describe('render', function () {
@ -57,6 +68,9 @@ function (chai, p, AuthErrors, View, RouterMock, WindowMock, TestHelpers) {
it('shows malformed screen if the token is missing', function () {
windowMock.location.search = '?code=faea&email=testuser@testuser.com';
return view.render()
.then(function () {
testEventLogged('complete_reset_password:link_damaged');
})
.then(function () {
assert.ok(view.$('#fxa-verification-link-damaged-header').length);
});
@ -65,6 +79,9 @@ function (chai, p, AuthErrors, View, RouterMock, WindowMock, TestHelpers) {
it('shows malformed screen if the code is missing', function () {
windowMock.location.search = '?token=feed&email=testuser@testuser.com';
return view.render()
.then(function () {
testEventLogged('complete_reset_password:link_damaged');
})
.then(function () {
assert.ok(view.$('#fxa-verification-link-damaged-header').length);
});
@ -73,6 +90,9 @@ function (chai, p, AuthErrors, View, RouterMock, WindowMock, TestHelpers) {
it('shows malformed screen if the email is missing', function () {
windowMock.location.search = '?token=feed&code=dea0fae1abc2fab3bed4dec5eec6ace7';
return view.render()
.then(function () {
testEventLogged('complete_reset_password:link_damaged');
})
.then(function () {
assert.ok(view.$('#fxa-verification-link-damaged-header').length);
});
@ -81,6 +101,9 @@ function (chai, p, AuthErrors, View, RouterMock, WindowMock, TestHelpers) {
it('shows the expired screen if the token has already been verified', function () {
isPasswordResetComplete = true;
return view.render()
.then(function () {
testEventLogged('complete_reset_password:link_expired');
})
.then(function () {
assert.ok(view.$('#fxa-verification-link-expired-header').length);
});

Просмотреть файл

@ -10,16 +10,17 @@ define([
'p-promise',
'views/complete_sign_up',
'lib/auth-errors',
'lib/metrics',
'lib/constants',
'../../mocks/router',
'../../mocks/window',
'../../lib/helpers'
],
function (chai, p, View, AuthErrors, Constants, RouterMock, WindowMock, TestHelpers) {
function (chai, p, View, AuthErrors, Metrics, Constants, RouterMock, WindowMock, TestHelpers) {
var assert = chai.assert;
describe('views/complete_sign_up', function () {
var view, routerMock, windowMock, verificationError;
var view, routerMock, windowMock, verificationError, metrics;
var validCode = TestHelpers.createRandomHexString(Constants.CODE_LENGTH);
var validUid = TestHelpers.createRandomHexString(Constants.UID_LENGTH);
@ -31,13 +32,19 @@ function (chai, p, View, AuthErrors, Constants, RouterMock, WindowMock, TestHelp
});
}
function testEventLogged(eventName) {
assert.isTrue(TestHelpers.isEventLogged(metrics, eventName));
}
beforeEach(function () {
routerMock = new RouterMock();
windowMock = new WindowMock();
metrics = new Metrics();
view = new View({
router: routerMock,
window: windowMock
window: windowMock,
metrics: metrics
});
verificationError = null;
@ -53,14 +60,20 @@ function (chai, p, View, AuthErrors, Constants, RouterMock, WindowMock, TestHelp
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = windowMock = null;
view = windowMock = metrics = null;
});
describe('render', function () {
it('shows an error if uid is not available on the URL', function () {
return testShowsDamagedScreen('?code=' + validCode)
.then(function () {
testEventLogged('complete_sign_up:link_damaged');
})
.then(function () {
assert.isFalse(view.fxaClient.verifyCode.called);
});
@ -68,6 +81,9 @@ function (chai, p, View, AuthErrors, Constants, RouterMock, WindowMock, TestHelp
it('shows an error if code is not available on the URL', function () {
return testShowsDamagedScreen('?uid=' + validUid)
.then(function () {
testEventLogged('complete_sign_up:link_damaged');
})
.then(function () {
assert.isFalse(view.fxaClient.verifyCode.called);
});
@ -76,6 +92,9 @@ function (chai, p, View, AuthErrors, Constants, RouterMock, WindowMock, TestHelp
it('INVALID_PARAMETER error displays the verification link damaged screen', function () {
verificationError = AuthErrors.toError('INVALID_PARAMETER', 'code');
return testShowsDamagedScreen()
.then(function () {
testEventLogged('complete_sign_up:link_damaged');
})
.then(function () {
assert.isTrue(view.fxaClient.verifyCode.called);
});

Просмотреть файл

@ -7,25 +7,30 @@ define([
'p-promise',
'lib/session',
'lib/auth-errors',
'lib/metrics',
'views/confirm',
'../../mocks/router',
'../../lib/helpers'
],
function (chai, p, Session, AuthErrors, View, RouterMock, TestHelpers) {
function (chai, p, Session, AuthErrors, Metrics, View, RouterMock, TestHelpers) {
'use strict';
var assert = chai.assert;
describe('views/confirm', function () {
var view, routerMock;
var view, routerMock, metrics;
beforeEach(function () {
Session.set('sessionToken', 'fake session token');
routerMock = new RouterMock();
metrics = new Metrics();
view = new View({
router: routerMock
router: routerMock,
metrics: metrics
});
return view.render()
.then(function () {
$('#container').html(view.el);
@ -33,8 +38,12 @@ function (chai, p, Session, AuthErrors, View, RouterMock, TestHelpers) {
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = metrics = null;
});
describe('constructor creates it', function () {
@ -52,7 +61,7 @@ function (chai, p, Session, AuthErrors, View, RouterMock, TestHelpers) {
});
describe('submit', function () {
it('resends the confirmation email, shows success message', function () {
it('resends the confirmation email, shows success message, logs the event', function () {
var email = TestHelpers.createEmail();
return view.fxaClient.signUp(email, 'password')
@ -61,6 +70,8 @@ function (chai, p, Session, AuthErrors, View, RouterMock, TestHelpers) {
})
.then(function () {
assert.isTrue(view.$('.success').is(':visible'));
assert.isTrue(TestHelpers.isEventLogged(metrics,
'confirm:resend'));
});
});
@ -75,6 +86,9 @@ function (chai, p, Session, AuthErrors, View, RouterMock, TestHelpers) {
return view.submit()
.then(function () {
assert.equal(routerMock.page, 'signup');
assert.isTrue(TestHelpers.isEventLogged(metrics,
'confirm:resend'));
});
});
@ -137,6 +151,11 @@ function (chai, p, Session, AuthErrors, View, RouterMock, TestHelpers) {
}).then(function () {
assert.equal(count, 2);
assert.equal(view.$('#resend:visible').length, 0);
assert.isTrue(TestHelpers.isEventLogged(metrics,
'confirm:resend'));
assert.isTrue(TestHelpers.isEventLogged(metrics,
'confirm:too_many_attempts'));
});
});
});

Просмотреть файл

@ -8,27 +8,31 @@ define([
'lib/auth-errors',
'views/confirm_reset_password',
'lib/session',
'lib/metrics',
'../../mocks/router',
'../../mocks/window'
'../../mocks/window',
'../../lib/helpers'
],
function (chai, p, AuthErrors, View, Session, RouterMock, WindowMock) {
function (chai, p, AuthErrors, View, Session, Metrics, RouterMock, WindowMock, TestHelpers) {
'use strict';
var assert = chai.assert;
describe('views/confirm_reset_password', function () {
var view, routerMock, windowMock;
var view, routerMock, windowMock, metrics;
beforeEach(function () {
routerMock = new RouterMock();
windowMock = new WindowMock();
metrics = new Metrics();
Session.set('passwordForgotToken', 'fake password reset token');
Session.set('email', 'testuser@testuser.com');
view = new View({
router: routerMock,
window: windowMock
window: windowMock,
metrics: metrics
});
return view.render()
.then(function () {
@ -37,8 +41,12 @@ function (chai, p, AuthErrors, View, Session, RouterMock, WindowMock) {
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = metrics = null;
});
describe('constructor', function () {
@ -130,6 +138,9 @@ function (chai, p, AuthErrors, View, Session, RouterMock, WindowMock) {
return view.submit()
.then(function () {
assert.equal(routerMock.page, 'reset_password');
assert.isTrue(TestHelpers.isEventLogged(metrics,
'confirm_reset_password:resend'));
});
});

Просмотреть файл

@ -7,23 +7,29 @@
define([
'chai',
'p-promise',
'lib/session',
'lib/auth-errors',
'lib/metrics',
'views/reset_password',
'../../mocks/window',
'../../mocks/router',
'../../lib/helpers'
],
function (chai, Session, View, WindowMock, RouterMock, TestHelpers) {
function (chai, p, Session, AuthErrors, Metrics, View, WindowMock, RouterMock, TestHelpers) {
var assert = chai.assert;
var wrapAssertion = TestHelpers.wrapAssertion;
describe('views/reset_password', function () {
var view, router;
var view, router, metrics;
beforeEach(function () {
router = new RouterMock();
metrics = new Metrics();
view = new View({
router: router
router: router,
metrics: metrics
});
return view.render()
.then(function () {
@ -32,10 +38,13 @@ function (chai, Session, View, WindowMock, RouterMock, TestHelpers) {
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = router = null;
$('#container').empty();
view = router = metrics = null;
});
describe('render', function () {
@ -98,7 +107,7 @@ function (chai, Session, View, WindowMock, RouterMock, TestHelpers) {
});
describe('submit with unknown email address', function () {
it('rejects the promise', function () {
it('shows an error message', function () {
var email = 'unknown' + Math.random() + '@testuser.com';
view.$('input[type=email]').val(email);
@ -109,6 +118,51 @@ function (chai, Session, View, WindowMock, RouterMock, TestHelpers) {
});
});
describe('submit when user cancelled login', function () {
it('logs an error', function () {
view.fxaClient.passwordReset = function (email) {
return p()
.then(function () {
throw AuthErrors.toError('USER_CANCELED_LOGIN');
});
};
return view.submit()
.then(null, function(err) {
assert.isTrue(false, 'unexpected failure');
})
.then(function (err) {
assert.isFalse(view.isErrorVisible());
assert.isTrue(TestHelpers.isEventLogged(metrics,
'login:canceled'));
});
});
});
describe('submit with other error', function () {
it('passes other errors along', function () {
view.fxaClient.passwordReset = function (email) {
return p()
.then(function () {
throw AuthErrors.toError('INVALID_JSON');
});
};
return view.submit()
.then(null, function(err) {
// The errorback will not be called if the submit
// succeeds, but the following callback always will
// be. To ensure the errorback was called, pass
// the error along and check its type.
return err;
})
.then(function (err) {
assert.isTrue(AuthErrors.is(err, 'INVALID_JSON'));
});
});
});
});
describe('views/reset_password with email specified as query param', function () {

Просмотреть файл

@ -12,23 +12,29 @@ define([
'views/sign_in',
'lib/session',
'lib/auth-errors',
'lib/metrics',
'../../mocks/window',
'../../mocks/router',
'../../lib/helpers'
],
function (chai, $, p, View, Session, AuthErrors, WindowMock, RouterMock, TestHelpers) {
function (chai, $, p, View, Session, AuthErrors, Metrics, WindowMock, RouterMock, TestHelpers) {
var assert = chai.assert;
var wrapAssertion = TestHelpers.wrapAssertion;
describe('views/sign_in', function () {
var view, email, routerMock;
var view, email, routerMock, metrics;
beforeEach(function () {
email = 'testuser.' + Math.random() + '@testuser.com';
routerMock = new RouterMock();
metrics = new Metrics();
view = new View({
router: routerMock
router: routerMock,
metrics: metrics
});
return view.render()
.then(function () {
$('#container').html(view.el);
@ -36,8 +42,12 @@ function (chai, $, p, View, Session, AuthErrors, WindowMock, RouterMock, TestHel
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = metrics = null;
});
describe('render', function () {
@ -113,7 +123,7 @@ function (chai, $, p, View, Session, AuthErrors, WindowMock, RouterMock, TestHel
});
});
it('does nothing if user cancels login', function () {
it('logs an error if user cancels login', function () {
view.fxaClient.signIn = function () {
return p()
.then(function () {
@ -125,6 +135,9 @@ function (chai, $, p, View, Session, AuthErrors, WindowMock, RouterMock, TestHel
return view.submit()
.then(function () {
assert.isFalse(view.isErrorVisible());
assert.isTrue(TestHelpers.isEventLogged(metrics,
'login:canceled'));
});
});
@ -151,6 +164,27 @@ function (chai, $, p, View, Session, AuthErrors, WindowMock, RouterMock, TestHel
assert.ok(msg.indexOf('/signup') > -1);
});
});
it('passes other errors along', function () {
view.fxaClient.signIn = function (email) {
return p()
.then(function () {
throw AuthErrors.toError('INVALID_JSON');
});
};
return view.submit()
.then(null, function(err) {
// The errorback will not be called if the submit
// succeeds, but the following callback always will
// be. To ensure the errorback was called, pass
// the error along and check its type.
return err;
})
.then(function (err) {
assert.isTrue(AuthErrors.is(err, 'INVALID_JSON'));
});
});
});
describe('showValidationErrors', function () {

Просмотреть файл

@ -13,10 +13,11 @@ define([
'views/sign_up',
'lib/session',
'lib/auth-errors',
'lib/metrics',
'../../mocks/router',
'../../lib/helpers'
],
function (chai, _, $, p, View, Session, AuthErrors, RouterMock, TestHelpers) {
function (chai, _, $, p, View, Session, AuthErrors, Metrics, RouterMock, TestHelpers) {
var assert = chai.assert;
var wrapAssertion = TestHelpers.wrapAssertion;
@ -34,14 +35,17 @@ function (chai, _, $, p, View, Session, AuthErrors, RouterMock, TestHelpers) {
}
describe('views/sign_up', function () {
var view, router, email;
var view, router, email, metrics;
beforeEach(function () {
email = 'testuser.' + Math.random() + '@testuser.com';
document.cookie = 'tooyoung=1; expires=Thu, 01-Jan-1970 00:00:01 GMT';
router = new RouterMock();
metrics = new Metrics();
view = new View({
router: router
router: router,
metrics: metrics
});
return view.render()
.then(function () {
@ -50,11 +54,13 @@ function (chai, _, $, p, View, Session, AuthErrors, RouterMock, TestHelpers) {
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = null;
router = null;
document.cookie = 'tooyoung=1; expires=Thu, 01-Jan-1970 00:00:01 GMT';
view = router = metrics = null;
});
describe('render', function () {
@ -321,7 +327,7 @@ function (chai, _, $, p, View, Session, AuthErrors, RouterMock, TestHelpers) {
});
});
it('does nothing if user cancels signup', function () {
it('logs an error if user cancels signup', function () {
view.fxaClient.signUp = function () {
return p()
.then(function () {
@ -335,6 +341,9 @@ function (chai, _, $, p, View, Session, AuthErrors, RouterMock, TestHelpers) {
return view.submit()
.then(function () {
assert.isFalse(view.isErrorVisible());
assert.isTrue(TestHelpers.isEventLogged(metrics,
'login:canceled'));
});
});
@ -351,6 +360,13 @@ function (chai, _, $, p, View, Session, AuthErrors, RouterMock, TestHelpers) {
return view.submit()
.then(null, function (err) {
// The errorback will not be called if the submit
// succeeds, but the following callback always will
// be. To ensure the errorback was called, pass
// the error along and check its type.
return err;
})
.then(function(err) {
assert.isTrue(AuthErrors.is(err, 'SERVER_BUSY'));
});
});

Просмотреть файл

@ -19,6 +19,7 @@
"requirejs-mustache": "https://github.com/jfparadis/requirejs-mustache.git#5fb8c0a3e8560526e8f9617a689e2924a8532d0a",
"requirejs-text": "2.0.10",
"sinon": "http://sinonjs.org/releases/sinon-1.7.1.js",
"speed-trap": "0.0.2",
"tos-pp": "https://github.com/mozilla/legal-docs.git",
"underscore": "1.5.2"
},

Просмотреть файл

@ -5,5 +5,8 @@
"static_max_age" : 0,
"i18n": {
"supportedLanguages": ["af", "an", "ar", "as", "ast", "be", "bg", "bn-BD", "bn-IN", "br", "bs", "ca", "cs", "cy", "da", "de", "el", "en-GB", "en-US", "en-ZA", "eo", "es", "es-AR", "es-CL", "es-MX", "et", "eu", "fa", "ff", "fi", "fr", "fy", "fy-NL", "ga", "ga-IE", "gd", "gl", "gu", "gu-IN", "he", "hi-IN", "hr", "ht", "hu", "hy-AM", "id", "is", "it", "it-CH", "ja", "kk", "km", "kn", "ko", "ku", "lij", "lt", "lv", "mai", "mk", "ml", "mr", "ms", "nb-NO", "ne-NP", "nl", "nn-NO", "or", "pa", "pa-IN", "pl", "pt", "pt-BR", "pt-PT", "rm", "ro", "ru", "si", "sk", "sl", "son", "sq", "sr", "sr-LATN", "sv", "sv-SE", "ta", "te", "th", "tr", "uk", "ur", "vi", "xh", "zh-CN", "zh-TW", "zu"]
},
"metrics": {
"sample_rate": 1
}
}

Просмотреть файл

@ -13,5 +13,8 @@
"supportedLanguages": ["af", "an", "ar", "as", "ast", "be", "bg", "bn-BD", "bn-IN", "br", "bs", "ca", "cs", "cy", "da", "de", "el", "en-GB", "en-US", "en-ZA", "eo", "es", "es-AR", "es-CL", "es-MX", "et", "eu", "fa", "ff", "fi", "fr", "fy", "fy-NL", "ga", "ga-IE", "gd", "gl", "gu", "gu-IN", "he", "hi-IN", "hr", "ht", "hu", "hy-AM", "id", "is", "it", "it-CH", "ja", "kk", "km", "kn", "ko", "ku", "lij", "lt", "lv", "mai", "mk", "ml", "mr", "ms", "nb-NO", "ne-NP", "nl", "nn-NO", "or", "pa", "pa-IN", "pl", "pt", "pt-BR", "pt-PT", "rm", "ro", "ru", "si", "sk", "sl", "son", "sq", "sr", "sr-LATN", "sv", "sv-SE", "ta", "te", "th", "tr", "uk", "ur", "vi", "xh", "zh-CN", "zh-TW", "zu"]
},
"route_log_format": "dev_fxa",
"static_directory": "app"
"static_directory": "app",
"metrics": {
"sample_rate": 1
}
}

Просмотреть файл

@ -159,6 +159,14 @@ var conf = module.exports = convict({
default: 'key-value-json',
env: 'I18N_TRANSLATION_TYPE'
}
},
metrics: {
sample_rate: {
doc: 'Front end metrics sample rate - must be between 0 and 1',
format: Number,
default: 0,
env: 'METRICS_SAMPLE_RATE'
}
}
});

Просмотреть файл

@ -28,7 +28,8 @@ module.exports = function (config, templates, i18n) {
require('./routes/get-ver.json'),
require('./routes/get-terms-privacy')(i18n),
require('./routes/get-config')(i18n),
require('./routes/get-client.json')(i18n)
require('./routes/get-client.json')(i18n),
require('./routes/post-metrics')()
];
var authServerHost = url.parse(config.get('fxaccount_url')).hostname;

Просмотреть файл

@ -32,7 +32,8 @@ module.exports = function(i18n) {
fxaccountUrl: config.get('fxaccount_url'),
oauthUrl: config.get('oauth_url'),
// req.lang is set by abide in a previous middleware.
language: req.lang
language: req.lang,
metricsSampleRate: config.get('metrics.sample_rate')
});
};

Просмотреть файл

@ -0,0 +1,23 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
var logger = require('intel').getLogger('server.metrics');
module.exports = function() {
var route = {};
route.method = 'post';
route.path = '/metrics';
route.process = function(req, res) {
logger.info('metrics:\n%s', JSON.stringify(req.body, null, 2));
res.json({ success: true });
};
return route;
};

Просмотреть файл

@ -16,7 +16,8 @@ define([
'tests/server/routes',
'tests/server/ver.json.js',
'tests/server/cookies_disabled',
'tests/server/l10n'
'tests/server/l10n',
'tests/server/metrics'
];
return intern;

44
tests/server/metrics.js Normal file
Просмотреть файл

@ -0,0 +1,44 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
define([
'intern!object',
'intern/chai!assert',
'intern/dojo/node!../../server/lib/configuration',
'intern/dojo/node!request'
], function (registerSuite, assert, config, request) {
'use strict';
var serverUrl = config.get('public_url');
var suite = {
name: 'metrics'
};
suite['#get /config returns a `metricsSampleRate`'] = function () {
var dfd = this.async(1000);
request(serverUrl + '/config',
dfd.callback(function (err, res) {
var results = JSON.parse(res.body);
assert.equal(results.metricsSampleRate, config.get('metrics.sample_rate'));
}, dfd.reject.bind(dfd)));
};
suite['#post /metrics - does nothing yet'] = function () {
var dfd = this.async(1000);
request.post(serverUrl + '/metrics', {
data: {
events: [ { type: 'event1', offset: 1 } ]
}
},
dfd.callback(function (err, res) {
assert.equal(res.statusCode, 200);
}, dfd.reject.bind(dfd)));
};
registerSuite(suite);
});