diff --git a/.circleci/config.yml b/.circleci/config.yml index 6507cf06c..1f1f608d1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,6 +6,7 @@ jobs: - image: circleci/node:8 environment: - DISPLAY=:99 + - image: redis steps: - checkout @@ -37,6 +38,7 @@ jobs: working_directory: ~/fxa docker: - image: circleci/node:8-stretch-browsers + - image: redis steps: - attach_workspace: at: ~/ @@ -67,6 +69,7 @@ jobs: working_directory: ~/fxa docker: - image: circleci/node:8-stretch-browsers + - image: redis steps: - attach_workspace: at: ~/ @@ -97,6 +100,7 @@ jobs: working_directory: ~/fxa docker: - image: circleci/node:8-stretch-browsers + - image: redis steps: - attach_workspace: at: ~/ @@ -127,6 +131,7 @@ jobs: working_directory: ~/fxa docker: - image: circleci/node:8-stretch-browsers + - image: redis steps: - attach_workspace: at: ~/ @@ -153,6 +158,7 @@ jobs: working_directory: ~/fxa docker: - image: circleci/node:8-stretch-browsers + - image: redis steps: - attach_workspace: at: ~/ @@ -185,6 +191,7 @@ jobs: dockerpush: docker: - image: circleci/node:8-stretch-browsers + - image: redis steps: - checkout diff --git a/app/scripts/lib/app-start.js b/app/scripts/lib/app-start.js index ed57b1bdc..82c0fc39e 100644 --- a/app/scripts/lib/app-start.js +++ b/app/scripts/lib/app-start.js @@ -95,7 +95,8 @@ Start.prototype = { initializeExperimentGroupingRules () { this._experimentGroupingRules = new ExperimentGroupingRules({ - env: this._config.env + env: this._config.env, + featureFlags: this._config.featureFlags }); }, diff --git a/app/scripts/lib/config-loader.js b/app/scripts/lib/config-loader.js index dd83672f2..980391677 100644 --- a/app/scripts/lib/config-loader.js +++ b/app/scripts/lib/config-loader.js @@ -53,6 +53,11 @@ ConfigLoader.prototype = { try { const serializedJSONConfig = decodeURIComponent(configFromHTML); config = JSON.parse(serializedJSONConfig); + + const serializedFeatureFlags = decodeURIComponent( + $('meta[name="fxa-feature-flags"]').attr('content') + ); + config.featureFlags = JSON.parse(serializedFeatureFlags); } catch (e) { return Promise.reject(ConfigLoader.Errors.toError('INVALID_CONFIG')); } diff --git a/app/scripts/lib/experiments/grouping-rules/communication-prefs.js b/app/scripts/lib/experiments/grouping-rules/communication-prefs.js index a1a0788d6..f8d477b00 100644 --- a/app/scripts/lib/experiments/grouping-rules/communication-prefs.js +++ b/app/scripts/lib/experiments/grouping-rules/communication-prefs.js @@ -25,30 +25,39 @@ const AVAILABLE_LANGUAGES = [ 'zh-tw' ]; -const availableLocalesRegExpStr = `^(${AVAILABLE_LANGUAGES.join('|')})$`; -const availableLocalesRegExp = new RegExp(availableLocalesRegExpStr); +const AVAILABLE_LANGUAGES_REGEX = arrayToRegex(AVAILABLE_LANGUAGES); function normalizeLanguage(lang) { return lang.toLowerCase().replace(/_/g, '-'); } -function areCommunicationPrefsAvailable(lang) { +function areCommunicationPrefsAvailable(lang, availableLanguages) { const normalizedLanguage = normalizeLanguage(lang); - return availableLocalesRegExp.test(normalizedLanguage); + return availableLanguages.test(normalizedLanguage); +} + +function arrayToRegex (array) { + return new RegExp(`^(?:${array.join('|')})$`); } module.exports = class CommunicationPrefsGroupingRule extends BaseGroupingRule { constructor () { super(); this.name = 'communicationPrefsVisible'; - this.availableLanguages = AVAILABLE_LANGUAGES; } choose (subject = {}) { - if (! subject.lang) { + const { featureFlags, lang } = subject; + let availableLanguages = AVAILABLE_LANGUAGES_REGEX; + + if (featureFlags && Array.isArray(featureFlags.communicationPrefLanguages)) { + availableLanguages = arrayToRegex(featureFlags.communicationPrefLanguages); + } + + if (! lang) { return false; } - return areCommunicationPrefsAvailable(subject.lang); + return areCommunicationPrefsAvailable(lang, availableLanguages); } }; diff --git a/app/scripts/lib/experiments/grouping-rules/index.js b/app/scripts/lib/experiments/grouping-rules/index.js index 09e8222f5..408d66c1e 100644 --- a/app/scripts/lib/experiments/grouping-rules/index.js +++ b/app/scripts/lib/experiments/grouping-rules/index.js @@ -24,6 +24,7 @@ class ExperimentChoiceIndex { constructor (options = {}) { this._env = options.env; this._experimentGroupingRules = options.experimentGroupingRules || experimentGroupingRules; + this._featureFlags = options.featureFlags; } /** @@ -51,6 +52,7 @@ class ExperimentChoiceIndex { const subjectCopy = Object.create(subject); subjectCopy.env = subject.env || this._env; subjectCopy.experimentGroupingRules = this; + subjectCopy.featureFlags = subject.featureFlags || this._featureFlags; return experiment.choose(subjectCopy); } } diff --git a/app/scripts/lib/experiments/grouping-rules/is-sampled-user.js b/app/scripts/lib/experiments/grouping-rules/is-sampled-user.js index f6e2cab5c..4115deb61 100644 --- a/app/scripts/lib/experiments/grouping-rules/is-sampled-user.js +++ b/app/scripts/lib/experiments/grouping-rules/is-sampled-user.js @@ -16,18 +16,24 @@ module.exports = class IsSampledUserGroupingRule extends BaseGroupingRule { } choose (subject = {}) { - const sampleRate = IsSampledUserGroupingRule.sampleRate(subject.env); + const sampleRate = IsSampledUserGroupingRule.sampleRate(subject); return !! (subject.env && subject.uniqueUserId && this.bernoulliTrial(sampleRate, subject.uniqueUserId)); } /** - * Return the sample rate for `env` + * Return the sample rate from `featureFlags` or `env` * * @static - * @param {String} env + * @param {Object} options + * @param {String} [options.env] + * @param {Object} [options.featureFlags] * @returns {Number} */ - static sampleRate (env) { + static sampleRate ({ env, featureFlags }) { + if (featureFlags && featureFlags.metricsSampleRate >= 0) { + return featureFlags.metricsSampleRate; + } + return env === 'development' ? 1.0 : 0.1; } }; diff --git a/app/scripts/lib/experiments/grouping-rules/send-sms-install-link.js b/app/scripts/lib/experiments/grouping-rules/send-sms-install-link.js index 940f326fd..79fb5d86f 100644 --- a/app/scripts/lib/experiments/grouping-rules/send-sms-install-link.js +++ b/app/scripts/lib/experiments/grouping-rules/send-sms-install-link.js @@ -24,13 +24,23 @@ module.exports = class SmsGroupingRule extends BaseGroupingRule { } choose (subject = {}) { - if (! subject.account || ! subject.uniqueUserId || ! subject.country || ! CountryTelephoneInfo[subject.country]) { + if (! subject.account || ! subject.uniqueUserId || ! subject.country) { + return false; + } + + let telephoneInfo = CountryTelephoneInfo[subject.country]; + const { featureFlags } = subject; + if (featureFlags && featureFlags.smsCountries) { + telephoneInfo = featureFlags.smsCountries[subject.country]; + } + + if (! telephoneInfo) { return false; } let choice = false; // If rolloutRate is not specified, assume 0. - const { rolloutRate } = CountryTelephoneInfo[subject.country] || 0; + const rolloutRate = telephoneInfo.rolloutRate || 0; if (this.isTestEmail(subject.account.get('email'))) { choice = 'signinCodes'; diff --git a/app/scripts/lib/experiments/grouping-rules/sentry.js b/app/scripts/lib/experiments/grouping-rules/sentry.js index 4a112ddc6..46b8157ec 100644 --- a/app/scripts/lib/experiments/grouping-rules/sentry.js +++ b/app/scripts/lib/experiments/grouping-rules/sentry.js @@ -16,7 +16,7 @@ module.exports = class SentryGroupingRule extends BaseGroupingRule { } choose (subject = {}) { - const sampleRate = SentryGroupingRule.sampleRate(subject.env); + const sampleRate = SentryGroupingRule.sampleRate(subject); return !! (subject.env && subject.uniqueUserId && this.bernoulliTrial(sampleRate, subject.uniqueUserId)); } @@ -28,7 +28,11 @@ module.exports = class SentryGroupingRule extends BaseGroupingRule { * @param {String} env * @returns {Number} */ - static sampleRate (env) { + static sampleRate ({ env, featureFlags }) { + if (featureFlags && featureFlags.sentrySampleRate >= 0) { + return featureFlags.sentrySampleRate; + } + return env === 'development' ? 1.0 : 0.3; } }; diff --git a/app/scripts/lib/experiments/grouping-rules/token-code.js b/app/scripts/lib/experiments/grouping-rules/token-code.js index 890c057c8..06445cd20 100644 --- a/app/scripts/lib/experiments/grouping-rules/token-code.js +++ b/app/scripts/lib/experiments/grouping-rules/token-code.js @@ -48,8 +48,13 @@ module.exports = class TokenCodeGroupingRule extends BaseGroupingRule { return false; } + const { featureFlags } = subject; + if (subject.clientId) { - const client = this.ROLLOUT_CLIENTS[subject.clientId]; + let client = this.ROLLOUT_CLIENTS[subject.clientId]; + if (featureFlags && featureFlags.tokenCodeClients) { + client = featureFlags.tokenCodeClients[subject.clientId]; + } if (client) { const groups = client.groups || GROUPS_DEFAULT; @@ -70,7 +75,12 @@ module.exports = class TokenCodeGroupingRule extends BaseGroupingRule { } if (subject.service && subject.service === Constants.SYNC_SERVICE) { - if (this.bernoulliTrial(this.SYNC_ROLLOUT_RATE, subject.uniqueUserId)) { + let syncRolloutRate = this.SYNC_ROLLOUT_RATE; + if (featureFlags && featureFlags.tokenCodeClients) { + syncRolloutRate = featureFlags.tokenCodeClients.sync.rolloutRate; + } + + if (this.bernoulliTrial(syncRolloutRate, subject.uniqueUserId)) { return this.uniformChoice(GROUPS_DEFAULT, subject.uniqueUserId); } } diff --git a/app/tests/spec/lib/app-start.js b/app/tests/spec/lib/app-start.js index 2924ff058..aa489e7ed 100644 --- a/app/tests/spec/lib/app-start.js +++ b/app/tests/spec/lib/app-start.js @@ -10,6 +10,7 @@ define(function (require, exports, module) { const BaseBroker = require('models/auth_brokers/base'); const Constants = require('lib/constants'); const ErrorUtils = require('lib/error-utils'); + const ExperimentGroupingRules = require('lib/experiments/grouping-rules'); const FxFennecV1Broker = require('models/auth_brokers/fx-fennec-v1'); const FxFirstrunV1Broker = require('models/auth_brokers/fx-firstrun-v1'); const FxFirstrunV2Broker = require('models/auth_brokers/fx-firstrun-v2'); @@ -38,6 +39,7 @@ define(function (require, exports, module) { let appStart; let backboneHistoryMock; let brokerMock; + let config; let notifier; let routerMock; let translator; @@ -47,6 +49,12 @@ define(function (require, exports, module) { beforeEach(() => { brokerMock = new BaseBroker(); backboneHistoryMock = new HistoryMock(); + config = { + env: 'production', + featureFlags: { + foo: 'bar' + } + }; notifier = new Notifier(); routerMock = { navigate: sinon.spy() }; translator = { @@ -60,6 +68,7 @@ define(function (require, exports, module) { appStart = new AppStart({ broker: brokerMock, + config, history: backboneHistoryMock, notifier, router: routerMock, @@ -113,34 +122,17 @@ define(function (require, exports, module) { }); }); - it('initializeErrorMetrics skips error metrics on empty config', () => { - appStart.initializeExperimentGroupingRules(); - const ableChoose = sinon.stub(appStart._experimentGroupingRules, 'choose').callsFake(() => { - return true; - }); + it('initializeExperimentGroupingRules propagates env and featureFlags', () => { + assert.isUndefined(appStart._experimentGroupingRules); - appStart.initializeErrorMetrics(); - assert.isUndefined(appStart._sentryMetrics); - ableChoose.restore(); - }); - - it('initializeErrorMetrics skips error metrics if env is not defined', () => { appStart.initializeExperimentGroupingRules(); - appStart.initializeErrorMetrics(); - assert.isUndefined(appStart._sentryMetrics); + assert.instanceOf(appStart._experimentGroupingRules, ExperimentGroupingRules); + assert.equal(appStart._experimentGroupingRules._env, 'production'); + assert.deepEqual(appStart._experimentGroupingRules._featureFlags, { foo: 'bar' }); }); it('initializeErrorMetrics creates error metrics', () => { - const appStart = new AppStart({ - broker: brokerMock, - config: { - env: 'development' - }, - history: backboneHistoryMock, - router: routerMock, - window: windowMock - }); appStart.initializeExperimentGroupingRules(); const ableChoose = sinon.stub(appStart._experimentGroupingRules, 'choose').callsFake(() => { @@ -184,6 +176,35 @@ define(function (require, exports, module) { assert.instanceOf(appStart._refreshObserver, RefreshObserver); }); + describe('without config', () => { + beforeEach(() => { + appStart = new AppStart({ + broker: brokerMock, + history: backboneHistoryMock, + router: routerMock, + window: windowMock + }); + }); + + it('initializeErrorMetrics skips error metrics on empty config', () => { + appStart.initializeExperimentGroupingRules(); + const ableChoose = sinon.stub(appStart._experimentGroupingRules, 'choose').callsFake(() => { + return true; + }); + + appStart.initializeErrorMetrics(); + assert.isUndefined(appStart._sentryMetrics); + ableChoose.restore(); + }); + + it('initializeErrorMetrics skips error metrics if env is not defined', () => { + appStart.initializeExperimentGroupingRules(); + + appStart.initializeErrorMetrics(); + assert.isUndefined(appStart._sentryMetrics); + }); + }); + describe('fatalError', () => { var err; var sandbox; diff --git a/app/tests/spec/lib/config-loader.js b/app/tests/spec/lib/config-loader.js index da597df63..33622e5f1 100644 --- a/app/tests/spec/lib/config-loader.js +++ b/app/tests/spec/lib/config-loader.js @@ -18,6 +18,28 @@ define(function (require, exports, module) { const VALID_HTML_CONFIG = encodeURIComponent(JSON.stringify({ env: 'dev' })); + const FEATURE_FLAGS = { + communicationPrefLanguages: [ 'en', 'fr' ], + metricsSampleRate: 0.1, + sentrySampleRate: 1, + smsCountries: { + FR: { + rolloutRate: 0.5, + }, + GB: { + rolloutRate: 1, + } + }, + tokenCodeClients: { + deadbeefbaadf00d: { + rolloutRate: 0 + }, + sync: { + rolloutRate: 1 + } + } + }; + const SERIALISED_FEATURE_FLAGS = encodeURIComponent(JSON.stringify(FEATURE_FLAGS)); describe('lib/config-loader', () => { let configLoader; @@ -26,72 +48,66 @@ define(function (require, exports, module) { configLoader = new ConfigLoader(); }); - describe('_readConfigFromHTML', () => { - describe('config missing', () => { - it('returns a `MISSING_CONFIG` error', () => { - return configLoader._readConfigFromHTML() - .then(assert.fail, (err) => { - assert.isTrue(ConfigLoaderErrors.is(err, 'MISSING_CONFIG')); - }); + it('_readConfigFromHTML returns a `MISSING_CONFIG` error', () => { + return configLoader._readConfigFromHTML() + .then(assert.fail, (err) => { + assert.isTrue(ConfigLoaderErrors.is(err, 'MISSING_CONFIG')); }); - }); - - describe('config available', () => { - beforeEach(() => { - $('head').append(``); - }); - - afterEach(() => { - $('meta[name="fxa-content-server/config"]').remove(); - }); - - it('returns the expected config', () => { - return configLoader._readConfigFromHTML() - .then((serializedHTMLConfig) => { - assert.equal(serializedHTMLConfig, VALID_HTML_CONFIG); - }); - }); - }); }); - describe('_parseHTMLConfig', () => { - describe('with an invalid URI Component', () => { - it('throws an `INVALID_CONFIG` error', () => { - return configLoader._parseHTMLConfig(INVALID_URI_COMPONENT_HTML_CONFIG) - .then(assert.fail, (err) => { - assert.isTrue(ConfigLoaderErrors.is(err, 'INVALID_CONFIG')); - }); + it('_parseHTMLConfig rejects with invalid encoding', () => { + return configLoader._parseHTMLConfig(INVALID_URI_COMPONENT_HTML_CONFIG) + .then(assert.fail, (err) => { + assert.isTrue(ConfigLoaderErrors.is(err, 'INVALID_CONFIG')); }); - }); - - describe('with invalid JSON', () => { - it('throws an `INVALID_CONFIG` error', () => { - return configLoader._parseHTMLConfig(INVALID_JSON_HTML_CONFIG) - .then(assert.fail, (err) => { - assert.isTrue(ConfigLoaderErrors.is(err, 'INVALID_CONFIG')); - }); - }); - }); - - describe('with valid config', () => { - it('parses the config', () => { - return configLoader._parseHTMLConfig(VALID_HTML_CONFIG) - .then((config) => { - assert.equal(config.env, 'dev'); - }); - }); - }); }); - describe('fetch', () => { - describe('with valid config', () => { + it('_parseHTMLConfig rejects with invalid JSON', () => { + return configLoader._parseHTMLConfig(INVALID_JSON_HTML_CONFIG) + .then(assert.fail, (err) => { + assert.isTrue(ConfigLoaderErrors.is(err, 'INVALID_CONFIG')); + }); + }); + + describe('insert config markup in to the DOM', () => { + before(() => { + $('head').append(``); + $('head').append(``); + }); + + after(() => { + $('meta[name="fxa-content-server/config"]').remove(); + $('meta[name="fxa-feature-flags"]').remove(); + }); + + it('_readConfigFromHTML returns the expected config', () => { + return configLoader._readConfigFromHTML() + .then((serializedHTMLConfig) => { + assert.equal(serializedHTMLConfig, VALID_HTML_CONFIG); + }); + }); + + it('_parseHTMLConfig parses the config', () => { + return configLoader._parseHTMLConfig(VALID_HTML_CONFIG) + .then((config) => { + assert.equal(config.env, 'dev'); + assert.deepEqual(config.featureFlags, FEATURE_FLAGS); + }); + }); + + it('_setWebpackPublicPath sets the bundle path', () => { + configLoader._setWebpackPublicPath('somepath'); + assert.equal(__webpack_public_path__, 'somepath'); //eslint-disable-line no-undef + configLoader._setWebpackPublicPath(); + assert.equal(__webpack_public_path__, Constants.DEFAULT_BUNDLE_PATH); //eslint-disable-line no-undef + }); + + describe('mock internal methods', () => { let $html; let origLang; let sandbox; beforeEach(() => { - $('head').append(``); - $html = $('html'); origLang = $html.attr('lang'); $html.attr('lang', 'db_LB'); @@ -102,17 +118,17 @@ define(function (require, exports, module) { }); afterEach(() => { - $('meta[name="fxa-content-server/config"]').remove(); $html.attr('lang', origLang); sandbox.restore(); }); - it('returns the config', () => { + it('fetch returns the config', () => { return configLoader.fetch() .then((config) => { assert.equal(config.env, 'dev'); assert.equal(config.lang, 'db_LB'); + assert.deepEqual(config.featureFlags, FEATURE_FLAGS); assert.isTrue(configLoader._readConfigFromHTML.called); assert.isTrue(configLoader._parseHTMLConfig.called); @@ -120,19 +136,5 @@ define(function (require, exports, module) { }); }); }); - - - describe('_setWebpackPublicPath', () => { - it('sets the bundle path', () => { - /*eslint-disable camelcase*/ - configLoader._setWebpackPublicPath('somepath'); - assert.equal(__webpack_public_path__, 'somepath'); //eslint-disable-line no-undef - configLoader._setWebpackPublicPath(); - assert.equal(__webpack_public_path__, Constants.DEFAULT_BUNDLE_PATH); //eslint-disable-line no-undef - }); - }); - }); }); - - diff --git a/app/tests/spec/lib/experiments/grouping-rules/communication-prefs.js b/app/tests/spec/lib/experiments/grouping-rules/communication-prefs.js index ba3d83f5d..7079ba127 100644 --- a/app/tests/spec/lib/experiments/grouping-rules/communication-prefs.js +++ b/app/tests/spec/lib/experiments/grouping-rules/communication-prefs.js @@ -15,45 +15,53 @@ define(function (require, exports, module) { experiment = new Experiment(); }); - - it('has the expected number of available languages', () => { - assert.lengthOf(experiment.availableLanguages, 12); + it('choose returns false without subject.lang', () => { + assert.isFalse(experiment.choose({})); }); - describe('choose', () => { - it('returns false without subject.lang', () => { - assert.isFalse(experiment.choose({})); + [ + 'de', + 'en', + 'en-US', + 'en-GB', + 'es', + 'es-ES', + 'es-MX', + 'fr', + 'hu', + 'id', + 'pl', + 'pt-br', + 'ru', + 'zh-TW', + ].forEach((lang) => { + it(`choose returns true for ${lang}`, () => { + assert.isTrue(experiment.choose({ lang })); }); + }); - it('returns true for available languages', () => { - [ - 'de', - 'en', - 'en-US', - 'en-GB', - 'es', - 'es-ES', - 'es-MX', - 'fr', - 'hu', - 'id', - 'pl', - 'pt-br', - 'ru', - 'zh-TW', - ].forEach((lang) => { - assert.isTrue(experiment.choose({ lang })); - }); + [ + 'de-DE', + 'pt', + ].forEach((lang) => { + it(`choose returns false for ${lang}`, () => { + assert.isFalse(experiment.choose({ lang })); }); + }); - it('returns false for unsupported languages', () => { - [ - 'de-DE', - 'pt', - ].forEach((lang) => { - assert.isFalse(experiment.choose({ lang })); - }); - }); + it('choose gives precedence to featureFlags', () => { + assert.isFalse(experiment.choose({ + featureFlags: { + communicationPrefLanguages: [ 'en', 'fr' ] + }, + lang: 'de' + })); + assert.isTrue(experiment.choose({ + featureFlags: { + communicationPrefLanguages: [ 'en', 'pt' ] + }, + lang: 'pt' + })); }); }); }); diff --git a/app/tests/spec/lib/experiments/grouping-rules/index.js b/app/tests/spec/lib/experiments/grouping-rules/index.js index 71d1984fd..cea13b6fc 100644 --- a/app/tests/spec/lib/experiments/grouping-rules/index.js +++ b/app/tests/spec/lib/experiments/grouping-rules/index.js @@ -42,7 +42,10 @@ define(function (require, exports, module) { rule1, rule2, rule3 - ] + ], + featureFlags: { + foo: 'bar' + } }); }); @@ -64,11 +67,17 @@ define(function (require, exports, module) { assert.isTrue(experimentGroupingRules.choose('rule1', subject)); assert.isTrue(rule1.choose.calledOnce); - assert.isTrue(rule1.choose.calledWith(subject)); + assert.deepEqual(rule1.choose.args[0][0], { + experimentGroupingRules, + featureFlags: { + foo: 'bar' + }, + ...subject + }); assert.equal(experimentGroupingRules.choose('rule2', subject), 'treatment'); assert.isTrue(rule2.choose.calledOnce); - assert.isTrue(rule2.choose.calledWith(subject)); + assert.deepEqual(rule2.choose.args[0][0], rule1.choose.args[0][0]); // rule3 is allowed even if rule2 is forced. subject.forceExperiment = 'rule2'; diff --git a/app/tests/spec/lib/experiments/grouping-rules/is-sampled-user.js b/app/tests/spec/lib/experiments/grouping-rules/is-sampled-user.js index 04a613707..aa27eb116 100644 --- a/app/tests/spec/lib/experiments/grouping-rules/is-sampled-user.js +++ b/app/tests/spec/lib/experiments/grouping-rules/is-sampled-user.js @@ -18,22 +18,46 @@ define(function (require, exports, module) { describe('sampleRate', () => { it('returns 1 for development', () => { - assert.equal(Experiment.sampleRate('development'), 1); + assert.equal(Experiment.sampleRate({ env: 'development' }), 1); }); it('returns 0.1 for everyone else', () => { - assert.equal(Experiment.sampleRate('production'), 0.1); + assert.equal(Experiment.sampleRate({ env: 'production' }), 0.1); }); }); describe('choose', () => { - it('delegates to bernoulliTrial', () => { + beforeEach(() => { sinon.stub(experiment, 'bernoulliTrial').callsFake(() => true); + }); + afterEach(() => { + experiment.bernoulliTrial.restore(); + }); + + it('delegates to bernoulliTrial', () => { assert.isTrue(experiment.choose({ env: 'production', uniqueUserId: 'user-id' })); assert.isTrue(experiment.bernoulliTrial.calledOnce); assert.isTrue(experiment.bernoulliTrial.calledWith(0.1, 'user-id')); }); + + it('passes sampleRate as 1 if env is development', () => { + experiment.choose({ env: 'development', uniqueUserId: 'wibble' }); + assert.equal(experiment.bernoulliTrial.callCount, 1); + assert.equal(experiment.bernoulliTrial.args[0][0], 1); + }); + + it('gives precedence to featureFlags', () => { + experiment.choose({ + env: 'production', + featureFlags: { + metricsSampleRate: 0 + }, + uniqueUserId: 'wibble' + }); + assert.equal(experiment.bernoulliTrial.callCount, 1); + assert.equal(experiment.bernoulliTrial.args[0][0], 0); + }); }); }); }); diff --git a/app/tests/spec/lib/experiments/grouping-rules/send-sms-install-link.js b/app/tests/spec/lib/experiments/grouping-rules/send-sms-install-link.js index ac9e2c48f..86c33ce5c 100644 --- a/app/tests/spec/lib/experiments/grouping-rules/send-sms-install-link.js +++ b/app/tests/spec/lib/experiments/grouping-rules/send-sms-install-link.js @@ -40,10 +40,28 @@ define(function (require, exports, module) { }); describe('country does not have a `rolloutRate`', () => { - it('returns `false', () => { + beforeEach(() => { delete CountryTelephoneInfo.GB.rolloutRate; + }); + + it('returns `false', () => { assert.isFalse(experiment.choose({ account, country, uniqueUserId: 'user-id' })); }); + + it('featureFlags take precedence', () => { + assert.isTrue(experiment.choose({ + account, + country, + featureFlags: { + smsCountries: { + GB: { + rolloutRate: 1 + } + } + }, + uniqueUserId: 'wibble' + })); + }); }); describe('country has a `rolloutRate`', () => { @@ -79,6 +97,18 @@ define(function (require, exports, module) { CountryTelephoneInfo.GB.rolloutRate = 1.0; assert.isTrue(experiment.choose({ account, country, uniqueUserId: 'user-id' })); }); + + it('featureFlags take precedence', () => { + CountryTelephoneInfo.GB.rolloutRate = 1.0; + assert.isFalse(experiment.choose({ + account, + country, + featureFlags: { + smsCountries: {} + }, + uniqueUserId: 'wibble' + })); + }); }); }); }); diff --git a/app/tests/spec/lib/experiments/grouping-rules/sentry.js b/app/tests/spec/lib/experiments/grouping-rules/sentry.js index 168e1c454..520b6b8c1 100644 --- a/app/tests/spec/lib/experiments/grouping-rules/sentry.js +++ b/app/tests/spec/lib/experiments/grouping-rules/sentry.js @@ -18,22 +18,46 @@ define(function (require, exports, module) { describe('sampleRate', () => { it('returns 1 for development', () => { - assert.equal(Experiment.sampleRate('development'), 1); + assert.equal(Experiment.sampleRate({ env: 'development' }), 1); }); it('returns 0.3 for everyone else', () => { - assert.equal(Experiment.sampleRate('production'), 0.3); + assert.equal(Experiment.sampleRate({ env: 'production' }), 0.3); }); }); describe('choose', () => { - it('delegates to bernoulliTrial', () => { + beforeEach(() => { sinon.stub(experiment, 'bernoulliTrial').callsFake(() => true); + }); + afterEach(() => { + experiment.bernoulliTrial.restore(); + }); + + it('delegates to bernoulliTrial', () => { assert.isTrue(experiment.choose({ env: 'production', uniqueUserId: 'user-id' })); assert.isTrue(experiment.bernoulliTrial.calledOnce); assert.isTrue(experiment.bernoulliTrial.calledWith(0.3, 'user-id')); }); + + it('passes sampleRate as 1 if env is development', () => { + experiment.choose({ env: 'development', uniqueUserId: 'wibble' }); + assert.equal(experiment.bernoulliTrial.callCount, 1); + assert.equal(experiment.bernoulliTrial.args[0][0], 1); + }); + + it('gives precedence to featureFlags', () => { + experiment.choose({ + env: 'production', + featureFlags: { + sentrySampleRate: 0 + }, + uniqueUserId: 'wibble' + }); + assert.equal(experiment.bernoulliTrial.callCount, 1); + assert.equal(experiment.bernoulliTrial.args[0][0], 0); + }); }); }); }); diff --git a/app/tests/spec/lib/experiments/grouping-rules/token-code.js b/app/tests/spec/lib/experiments/grouping-rules/token-code.js index 4ba567643..0175eacc6 100644 --- a/app/tests/spec/lib/experiments/grouping-rules/token-code.js +++ b/app/tests/spec/lib/experiments/grouping-rules/token-code.js @@ -76,6 +76,21 @@ describe('lib/experiments/grouping-rules/token-code', () => { assert.isTrue(experiment.uniformChoice.calledOnce); assert.isTrue(experiment.uniformChoice.calledWith(['treatment-code'], 'user-id')); }); + + it('featureFlags take precedence', () => { + subject.clientId = 'invalidClientId'; + assert.equal(experiment.choose({ + ...subject, + featureFlags: { + tokenCodeClients: { + invalidClientId: { + groups: [ 'treatment-code' ], + rolloutRate: 1 + } + } + } + }), 'treatment-code'); + }); }); describe('with sync', () => { @@ -99,6 +114,21 @@ describe('lib/experiments/grouping-rules/token-code', () => { assert.isTrue(experiment.uniformChoice.calledOnce, 'called once'); assert.isTrue(experiment.uniformChoice.calledWith(['treatment-code'], 'user-id')); }); + + it('featureFlags take precedence', () => { + subject.service = 'sync'; + assert.isFalse(experiment.choose({ + ...subject, + featureFlags: { + tokenCodeClients: { + sync: { + groups: [ 'treatment-code' ], + rolloutRate: 0 + } + } + } + })); + }); }); }); }); diff --git a/grunttasks/l10n-generate-pages.js b/grunttasks/l10n-generate-pages.js index f4e9b05a0..4cf156264 100644 --- a/grunttasks/l10n-generate-pages.js +++ b/grunttasks/l10n-generate-pages.js @@ -25,6 +25,7 @@ module.exports = function (grunt) { var PROPAGATED_ESCAPED_TEMPLATE_FIELDS = [ 'config', + 'featureFlags', 'flowId', 'flowBeginTime', 'message' diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4ad068af1..5b3d55f2e 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "fxa-content-server", - "version": "1.132.0", + "version": "1.132.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -491,9 +491,9 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" }, "@sinonjs/commons": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.1.tgz", - "integrity": "sha512-rgmZk5CrBGAMATk0HlHOFvo8V44/r+On6cKS80tqid0Eljd+fFBWBOXZp9H2/EB3faxdNdzXTx6QZIKLkbJ7mA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.4.0.tgz", + "integrity": "sha512-9jHK3YF/8HtJ9wCAbG+j8cD0i0+ATS9A7gXFqS36TblLPNy6rEEc+SB0imo91eCboGaBYGV/MT1/br/J+EE7Tw==", "dev": true, "requires": { "type-detect": "4.0.8" @@ -517,9 +517,9 @@ } }, "@sinonjs/samsam": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.2.0.tgz", - "integrity": "sha512-j5F1rScewLtx6pbTK0UAjA3jJj4RYiSKOix53YWv+Jzy/AZ69qHxUpU8fwVLjyKbEEud9QrLpv6Ggs7WqTimYw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.0.tgz", + "integrity": "sha512-beHeJM/RRAaLLsMJhsCvHK31rIqZuobfPLa/80yGH5hnD8PV1hyh9xJBJNFfNmO7yWqm+zomijHsXpI6iTQJfQ==", "dev": true, "requires": { "@sinonjs/commons": "^1.0.2", @@ -713,9 +713,9 @@ } }, "@types/lodash": { - "version": "4.14.122", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.122.tgz", - "integrity": "sha512-9IdED8wU93ty8gP06ninox+42SBSJHp2IAamsSYMUY76mshRTeUsid/gtbl8ovnOwy8im41ib4cxTiIYMXGKew==", + "version": "4.14.123", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.123.tgz", + "integrity": "sha512-pQvPkc4Nltyx7G1Ww45OjVqUsJP4UsZm+GWJpigXgkikZqJgRm4c48g027o6tdgubWHwFRF15iFd+Y4Pmqv6+Q==", "dev": true }, "@types/mime": { @@ -731,9 +731,9 @@ "dev": true }, "@types/node": { - "version": "11.10.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-11.10.4.tgz", - "integrity": "sha512-wa09itaLE8L705aXd8F80jnFpxz3Y1/KRHfKsYL2bPc0XF+wEWu8sR9n5bmeu8Ba1N9z2GRNzm/YdHcghLkLKg==", + "version": "11.11.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.3.tgz", + "integrity": "sha512-wp6IOGu1lxsfnrD+5mX6qwSwWuqsdkKKxTN4aQc4wByHAKZJf9/D4KXPQ1POUjEbnCP5LMggB0OEFNY9OTsMqg==", "dev": true }, "@types/platform": { @@ -1440,9 +1440,9 @@ "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" }, "async-each": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", - "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.2.tgz", + "integrity": "sha512-6xrbvN0MOBKSJDdonmSSz2OwFSgxRaVtBDes26mj9KIGtDo+g9xosFRSC+i1gQh2oAN/tQ62AI/pGZGQjVOiRg==" }, "async-foreach": { "version": "0.1.3", @@ -1641,13 +1641,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", - "dev": true + "dev": true, + "optional": true }, "is-glob": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "dev": true, + "optional": true, "requires": { "is-extglob": "^1.0.0" } @@ -1689,6 +1691,7 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", "dev": true, + "optional": true, "requires": { "remove-trailing-separator": "^1.0.1" } @@ -2832,9 +2835,9 @@ "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" }, "caniuse-lite": { - "version": "1.0.30000941", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000941.tgz", - "integrity": "sha512-4vzGb2MfZcO20VMPj1j6nRAixhmtlhkypM4fL4zhgzEucQIYiRzSqPcWIu1OF8i0FETD93FMIPWfUJCAcFvrqA==" + "version": "1.0.30000947", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000947.tgz", + "integrity": "sha512-ubgBUfufe5Oi3W1+EHyh2C3lfBIEcZ6bTuvl5wNOpIuRB978GF/Z+pQ7pGGUpeYRB0P+8C7i/3lt6xkeu2hwnA==" }, "capture-stack-trace": { "version": "1.0.1", @@ -4520,6 +4523,11 @@ "is-obj": "^1.0.0" } }, + "double-ended-queue": { + "version": "2.1.0-0", + "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz", + "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw=" + }, "duplexer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", @@ -4561,9 +4569,9 @@ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "electron-to-chromium": { - "version": "1.3.113", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.113.tgz", - "integrity": "sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g==" + "version": "1.3.116", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.116.tgz", + "integrity": "sha512-NKwKAXzur5vFCZYBHpdWjTMO8QptNLNP80nItkSIgUOapPAo9Uia+RvkCaZJtO7fhQaVElSvBPWEc2ku6cKsPA==" }, "elliptic": { "version": "6.4.1", @@ -4661,14 +4669,14 @@ } }, "es5-ext": { - "version": "0.10.48", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.48.tgz", - "integrity": "sha512-CdRvPlX/24Mj5L4NVxTs4804sxiS2CjVprgCmrgoDkdmjdY4D+ySHa7K3jJf8R40dFg0tIm3z/dk326LrnuSGw==", + "version": "0.10.49", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.49.tgz", + "integrity": "sha512-3NMEhi57E31qdzmYp2jwRArIUsj1HI/RxbQ4bgnSB+AIKIxsAmTiK83bYMifIcpWvEc3P1X30DhUKOqEtF/kvg==", "dev": true, "requires": { "es6-iterator": "~2.0.3", "es6-symbol": "~3.1.1", - "next-tick": "1" + "next-tick": "^1.0.0" } }, "es6-iterator": { @@ -4997,9 +5005,9 @@ } }, "eslint-plugin-sorting": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-sorting/-/eslint-plugin-sorting-0.4.0.tgz", - "integrity": "sha512-2edN1MfCdHN3XBr/oMNRkg9TkHvUkzivMjfs9glyPEyqVsQrSJuh/iDidUjg7LXVATJuDQsD8bVELldBj4bFZQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-sorting/-/eslint-plugin-sorting-0.4.1.tgz", + "integrity": "sha512-MYMdZnRN9+YsypSP6clX37AgVh3dt1Esgo+DynoubjCMm+B8Gf2t80xdqjWt7poyDUzkwSfzu5pOVF2HvVfYug==", "dev": true }, "eslint-scope": { @@ -5861,7 +5869,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -5879,11 +5888,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5896,15 +5907,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6007,7 +6021,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6017,6 +6032,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6029,17 +6045,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6056,6 +6075,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6128,7 +6148,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6138,6 +6159,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6213,7 +6235,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6243,6 +6266,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6260,6 +6284,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6298,11 +6323,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -6391,7 +6418,7 @@ "integrity": "sha512-1Nq8FtlF0Xb5V/4oKmJuKLKD2nGnM4DuhWCdY5obZEIDtBUHgPb0CUf9FVLY54rHJGzfoItFqrVI+SNpY1GAdw==", "requires": { "es6-promise": "4.1.1", - "sjcl": "git://github.com/bitwiseshiftleft/sjcl.git#a03ea8ef32329bc8d7bc28a438372b5acb46616b", + "sjcl": "git://github.com/bitwiseshiftleft/sjcl.git#a03ea8e", "xhr2": "0.0.7" }, "dependencies": { @@ -6421,12 +6448,16 @@ } }, "fxa-shared": { - "version": "1.0.18", - "resolved": "https://registry.npmjs.org/fxa-shared/-/fxa-shared-1.0.18.tgz", - "integrity": "sha512-5un2xXGpIfjPramIx/fJtuxojw/zuJ884VYb/N4xhkW4qXKGOahAJIzcgERAUxCgAqQpcv3K7RA+Z33P+MH5ug==", + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/fxa-shared/-/fxa-shared-1.0.20.tgz", + "integrity": "sha512-olnR3xMo0m2+eL11yAj2A7Kf9l1kbzk1hHieYu3BmQDN8Otf6o/erPlGWOt2/G8AJdWAtxOBZ//W4SR5K+hB1w==", "requires": { "accept-language": "2.0.17", - "moment": "2.20.1" + "ajv": "6.10.0", + "bluebird": "3.5.3", + "generic-pool": "3.6.1", + "moment": "2.20.1", + "redis": "2.8.0" }, "dependencies": { "moment": { @@ -6477,6 +6508,11 @@ "is-property": "^1.0.0" } }, + "generic-pool": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.6.1.tgz", + "integrity": "sha512-iMmD/pY4q0+V+f8o4twE9JPeqfNuX+gJAaIPB3B0W1lFkBOtTxBo6B0HxHPgGhzQA8jego7EWopcYq/UDJO2KA==" + }, "get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -6898,13 +6934,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", - "dev": true + "dev": true, + "optional": true }, "is-glob": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "dev": true, + "optional": true, "requires": { "is-extglob": "^1.0.0" } @@ -8139,17 +8177,17 @@ } }, "strip-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.0.0.tgz", - "integrity": "sha512-Uu7gQyZI7J7gn5qLn1Np3G9vcYGTVqB+lFTytnDJv83dd8T22aGH451P3jueT2/QemInJDfxHB5Tde5OzgG1Ow==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.1.0.tgz", + "integrity": "sha512-TjxrkPONqO2Z8QDCpeE2j6n0M6EwxzyDgzEeGp+FbdvaJAt//ClYi6W5my+3ROlC/hZX2KACUwDfK49Ka5eDvg==", "requires": { - "ansi-regex": "^4.0.0" + "ansi-regex": "^4.1.0" }, "dependencies": { "ansi-regex": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.0.0.tgz", - "integrity": "sha512-iB5Dda8t/UqpPI/IjsejXu5jOGDrzn41wJyljwPH65VCIbk6+1BzFIMJGFwTNrYXT1CrD+B4l19U7awiQ8rk7w==" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" } } } @@ -9303,7 +9341,7 @@ } }, "legal-docs": { - "version": "git://github.com/mozilla/legal-docs.git#6827032416da57e4b77920046dc6abbfe582fbcf", + "version": "git://github.com/mozilla/legal-docs.git#30c5c916dc3f153df9cf2a55a263329ae403febb", "from": "git://github.com/mozilla/legal-docs.git#master" }, "levn": { @@ -9820,12 +9858,12 @@ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "mem": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mem/-/mem-4.1.0.tgz", - "integrity": "sha512-I5u6Q1x7wxO0kdOpYBB28xueHADYps5uty/zg936CiG8NTe5sJL8EjrCuLneuDW3PlMdZBGDIn8BirEVdovZvg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.2.0.tgz", + "integrity": "sha512-5fJxa68urlY0Ir8ijatKa3eRz5lwXnRCTvo9+TbTGAuTFJOwpGcY0X05moBd0nW45965Njt4CDI2GFQoG8DvqA==", "requires": { "map-age-cleaner": "^0.1.1", - "mimic-fn": "^1.0.0", + "mimic-fn": "^2.0.0", "p-is-promise": "^2.0.0" } }, @@ -9930,9 +9968,9 @@ } }, "mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.0.0.tgz", + "integrity": "sha512-jbex9Yd/3lmICXwYT6gA/j2mNQGU48wCh/VzRd+/Y/PjYQtlg1gLMdZqvu9s/xH7qKvngxRObl56XZR609IMbA==" }, "min-document": { "version": "2.19.0", @@ -10196,9 +10234,9 @@ "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=" }, "nan": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.12.1.tgz", - "integrity": "sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==" + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.13.0.tgz", + "integrity": "sha512-5DDQvN0luhXdut8SCwzm/ZuAX2W+fwhqNzfq7CZ+OJzQ6NwpcqmIGyLD1R8MEt7BeErzcsI0JLr4pND2pNp2Cw==" }, "nanomatch": { "version": "1.2.13", @@ -10401,9 +10439,9 @@ } }, "node-releases": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.9.tgz", - "integrity": "sha512-oic3GT4OtbWWKfRolz5Syw0Xus0KRFxeorLNj0s93ofX6PWyuzKjsiGxsCtWktBwwmTF6DdRRf2KreGqeOk5KA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.10.tgz", + "integrity": "sha512-KbUPCpfoBvb3oBkej9+nrU0/7xPlVhmhhUJ1PZqwIP5/1dJkRWKWD3OONjo6M2J7tSCBtDCumLwwqeI+DWWaLQ==", "requires": { "semver": "^5.3.0" } @@ -10543,7 +10581,7 @@ "from": "git://github.com/vladikoff/node-uap.git#9cdd16247", "requires": { "array.prototype.find": "2.0.0", - "uap-core": "git://github.com/ua-parser/uap-core.git#add7bafbb3ba57256d1b919103add1b2cab97aa7", + "uap-core": "git://github.com/ua-parser/uap-core.git", "uap-ref-impl": "0.2.0", "yamlparser": "0.0.2" } @@ -10790,6 +10828,13 @@ "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", "requires": { "mimic-fn": "^1.0.0" + }, + "dependencies": { + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" + } } }, "optimist": { @@ -10992,7 +11037,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", - "dev": true + "dev": true, + "optional": true }, "is-glob": { "version": "2.0.1", @@ -11226,9 +11272,9 @@ "dev": true }, "pngjs": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.3.3.tgz", - "integrity": "sha512-1n3Z4p3IOxArEs1VRXnZ/RXdfEniAUS9jb68g58FIXMNkPJeZd+Qh4Uq7/e0LVxAQGos1eIUrqrt4FpjdnEd+Q==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", "dev": true }, "po2json": { @@ -12036,6 +12082,26 @@ "strip-indent": "^1.0.1" } }, + "redis": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-2.8.0.tgz", + "integrity": "sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==", + "requires": { + "double-ended-queue": "^2.1.0-0", + "redis-commands": "^1.2.0", + "redis-parser": "^2.6.0" + } + }, + "redis-commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.4.0.tgz", + "integrity": "sha512-cu8EF+MtkwI4DLIT0x9P8qNTLFhQD4jLfxLR0cCNkeGzs87FN6879JOJwNQR/1zD7aSYNbU0hgsV9zGY71Itvw==" + }, + "redis-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz", + "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=" + }, "referrer-policy": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.1.0.tgz", @@ -13804,7 +13870,7 @@ "from": "git://github.com/vladikoff/ua-parser-js.git#fxa-version" }, "uap-core": { - "version": "git://github.com/ua-parser/uap-core.git#add7bafbb3ba57256d1b919103add1b2cab97aa7", + "version": "git://github.com/ua-parser/uap-core.git#b4a50d040ad03b163b675d468d7ee011e9ad5436", "from": "git://github.com/ua-parser/uap-core.git" }, "uap-ref-impl": { @@ -13991,9 +14057,9 @@ "integrity": "sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=" }, "upath": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz", - "integrity": "sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.2.tgz", + "integrity": "sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q==" }, "upper-case": { "version": "1.1.3", @@ -14635,6 +14701,12 @@ "mimic-fn": "^1.0.0" } }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, "os-locale": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", diff --git a/package.json b/package.json index 5597e8828..ca3f35e51 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "fxa-js-client": "1.0.8", "fxa-mustache-loader": "0.0.2", "fxa-pairing-channel": "1.0.1", - "fxa-shared": "1.0.18", + "fxa-shared": "1.0.20", "got": "6.7.1", "grunt": "1.0.3", "grunt-babel": "6.0.0", @@ -133,7 +133,7 @@ "css": "2.2.3", "eslint": "4.16.0", "eslint-plugin-fxa": "git://github.com/mozilla/eslint-plugin-fxa.git#1153ff4bbf7e2c074363253c555fb7f71bac09a1", - "eslint-plugin-sorting": "0.4.0", + "eslint-plugin-sorting": "0.4.1", "firefox-profile": "1.2.0", "fxa-conventional-changelog": "1.1.0", "grunt-ban-word": "0.1.1", diff --git a/server/lib/configuration.js b/server/lib/configuration.js index ec3997c65..bc3f0832c 100644 --- a/server/lib/configuration.js +++ b/server/lib/configuration.js @@ -147,6 +147,52 @@ const conf = module.exports = convict({ 'development' ] }, + featureFlags: { + enabled: { + default: true, + doc: 'Enable feature flagging', + env: 'FEATURE_FLAGS_ENABLED', + format: Boolean + }, + interval: { + default: '30 seconds', + doc: 'The refresh interval for feature-flagging', + env: 'FEATURE_FLAGS_INTERVAL', + format: 'duration' + }, + redis: { + host: { + default: '127.0.0.1', + doc: 'Redis host name or IP address', + env: 'FEATURE_FLAGS_REDIS_HOST', + format: String + }, + maxConnections: { + default: 1, + doc: 'Maximum connection count for feature-flagging Redis pool', + env: 'FEATURE_FLAGS_REDIS_MAX_CONNECTIONS', + format: 'nat' + }, + maxPending: { + default: 1, + doc: 'Maximum waiting client count for feature-flagging Redis pool', + env: 'FEATURE_FLAGS_REDIS_MAX_PENDING', + format: 'nat' + }, + minConnections: { + default: 1, + doc: 'Minimum connection count for feature-flagging Redis pool', + env: 'FEATURE_FLAGS_REDIS_MIN_CONNECTIONS', + format: 'nat' + }, + port: { + default: 6379, + doc: 'Redis port', + env: 'FEATURE_FLAGS_REDIS_PORT', + format: 'port' + } + } + }, flow_id_expiry: { default: '2 hours', doc: 'Time after which flow ids are considered stale', diff --git a/server/lib/routes/get-index.js b/server/lib/routes/get-index.js index 9214931c2..d26b29eab 100644 --- a/server/lib/routes/get-index.js +++ b/server/lib/routes/get-index.js @@ -8,6 +8,14 @@ const flowMetrics = require('../flow-metrics'); const logger = require('../logging/log')('routes.index'); module.exports = function (config) { + let featureFlags; + const featureFlagConfig = config.get('featureFlags'); + if (featureFlagConfig.enabled) { + featureFlags = require('fxa-shared/feature-flags')(featureFlagConfig, logger); + } else { + featureFlags = { get: () => ({}) }; + } + const AUTH_SERVER_URL = config.get('fxaccount_url'); const CLIENT_ID = config.get('oauth_client_id'); const COPPA_ENABLED = config.get('coppa.enabled'); @@ -54,17 +62,25 @@ module.exports = function (config) { return { method: 'get', path: '/', - process: function (req, res) { + process: async function (req, res) { const flowEventData = flowMetrics.create(FLOW_ID_KEY, req.headers['user-agent']); if (NO_LONGER_SUPPORTED_CONTEXTS.has(req.query.context)) { return res.redirect(`/update_firefox?${req.originalUrl.split('?')[1]}`); } + let flags; + try { + flags = await featureFlags.get(); + } catch (err) { + logger.error('featureFlags.error', err); + flags = {}; + } res.render('index', { // Note that bundlePath is added to templates as a build step bundlePath: '/bundle', config: serializedConfig, + featureFlags: encodeURIComponent(JSON.stringify(flags)), flowBeginTime: flowEventData.flowBeginTime, flowId: flowEventData.flowId, // Note that staticResourceUrl is added to templates as a build step @@ -74,6 +90,7 @@ module.exports = function (config) { if (req.headers.dnt === '1') { logger.info('request.headers.dnt'); } - } + }, + terminate: featureFlags.terminate }; }; diff --git a/server/templates/pages/src/index.html b/server/templates/pages/src/index.html index 5b8fcb0ed..48a8a30a3 100644 --- a/server/templates/pages/src/index.html +++ b/server/templates/pages/src/index.html @@ -8,6 +8,7 @@ + diff --git a/tests/server/routes/get-index.js b/tests/server/routes/get-index.js index bff93a072..ecee8f0d0 100644 --- a/tests/server/routes/get-index.js +++ b/tests/server/routes/get-index.js @@ -20,10 +20,14 @@ registerSuite('routes/get-index', { instance = route(config); }, + after: () => { + instance.terminate(); + }, + tests: { 'instance interface is correct': function () { assert.isObject(instance); - assert.lengthOf(Object.keys(instance), 3); + assert.lengthOf(Object.keys(instance), 4); assert.equal(instance.method, 'get'); assert.equal(instance.path, '/'); assert.isFunction(instance.process); @@ -37,7 +41,7 @@ registerSuite('routes/get-index', { query: {} }; response = {render: sinon.spy()}; - instance.process(request, response); + return instance.process(request, response); }, tests: { @@ -51,10 +55,11 @@ registerSuite('routes/get-index', { var renderParams = args[1]; assert.isObject(renderParams); - assert.lengthOf(Object.keys(renderParams), 5); + assert.lengthOf(Object.keys(renderParams), 6); assert.ok(/[0-9a-f]{64}/.exec(renderParams.flowId)); assert.isAbove(renderParams.flowBeginTime, 0); assert.equal(renderParams.bundlePath, '/bundle'); + assert.isObject(JSON.parse(decodeURIComponent(renderParams.featureFlags))); assert.equal(renderParams.staticResourceUrl, config.get('static_resource_url')); assert.isString(renderParams.config); @@ -88,7 +93,7 @@ registerSuite('routes/get-index', { }, }; response = {redirect: sinon.spy()}; - instance.process(request, response); + return instance.process(request, response); }, tests: { @@ -109,7 +114,7 @@ registerSuite('routes/get-index', { }, }; response = {redirect: sinon.spy()}; - instance.process(request, response); + return instance.process(request, response); }, tests: { @@ -130,7 +135,7 @@ registerSuite('routes/get-index', { }, }; response = {render: sinon.spy()}; - instance.process(request, response); + return instance.process(request, response); }, tests: {