Bug 1620021 - Integrate aboutwelcome with ExperimentManager r=andreio

Differential Revision: https://phabricator.services.mozilla.com/D65367
This commit is contained in:
Kate Hudson 2020-04-18 05:23:02 +00:00
parent 46d24ea87e
commit b1d26ed383
20 changed files with 385 additions and 357 deletions

View File

@ -1313,9 +1313,6 @@ pref("trailhead.firstrun.branches", "join-dynamic");
// Separate about welcome
pref("browser.aboutwelcome.enabled", true);
// Temporary utility to unblock testing on about:welcome experiment variations
pref("browser.aboutwelcome.temp.testExperiment.slug", "");
pref("browser.aboutwelcome.temp.testExperiment.branch", "control");
// See Console.jsm LOG_LEVELS for all possible values
pref("browser.aboutwelcome.log", "warn");
@ -1325,6 +1322,10 @@ pref("browser.messaging-system.whatsNewPanel.enabled", true);
pref("browser.messaging-system.personalized-cfr.scores", "{}");
pref("browser.messaging-system.personalized-cfr.score-threshold", 5000);
// Experiment Manager
pref("messaging-system.log", "warn");
pref("messaging-system.rsexperimentloader.enabled", true);
// Enable the DOM fullscreen API.
pref("full-screen-api.enabled", true);

View File

@ -11,8 +11,7 @@ const { XPCOMUtils } = ChromeUtils.import(
);
XPCOMUtils.defineLazyModuleGetters(this, {
ExperimentAPI:
"resource://activity-stream/aboutwelcome/lib/AboutWelcomeExperimentAPI.jsm",
ExperimentAPI: "resource://messaging-system/experiments/ExperimentAPI.jsm",
});
XPCOMUtils.defineLazyGetter(this, "log", () => {
@ -97,11 +96,21 @@ class AboutWelcomeChild extends JSWindowActorChild {
* Send initial data to page including experiment information
*/
AWGetStartupData() {
// TODO: Fetch this from Experiments
const experimentData = ExperimentAPI.getExperiment({
group: "aboutwelcome",
});
return Cu.cloneInto(experimentData, this.contentWindow);
return this.wrapPromise(
ExperimentAPI.ready().then(() => {
const experimentData = ExperimentAPI.getExperiment({
group: "aboutwelcome",
});
if (experimentData && experimentData.slug) {
log.debug(
`Loading about:welcome with experiment: ${experimentData.slug}`
);
} else {
log.debug("Loading about:welcome without experiment");
}
return Cu.cloneInto(experimentData || {}, this.contentWindow);
})
);
}
AWGetFxAMetricsFlowURI() {

View File

@ -1,212 +0,0 @@
/* 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";
const EXPORTED_SYMBOLS = ["ExperimentAPI", "TEST_REFERENCE_RECIPE"];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"testSlug",
"browser.aboutwelcome.temp.testExperiment.slug",
""
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"testBranch",
"browser.aboutwelcome.temp.testExperiment.branch",
"",
null,
val => val || "control"
);
// This is used by xpcshell tests
const TEST_REFERENCE_RECIPE = {
slug: "about_welcome_test",
branches: [
{
slug: "control",
ratio: 1,
value: {
title: "This is the control branch",
},
},
{
slug: "variant",
ratio: 1,
value: {
title: "This is the variant branch",
subtitle: "With a subtitle.",
},
},
],
};
const PERSONAL_DATA_PROMISE_CARD = {
id: "TRAILHEAD_CARD_12",
content: {
title: { string_id: "onboarding-personal-data-promise-title" },
text: { string_id: "onboarding-personal-data-promise-text" },
icon: "pledge",
primary_button: {
label: { string_id: "onboarding-personal-data-promise-button" },
action: {
type: "OPEN_URL",
data: {
args: "https://www.mozilla.org/firefox/privacy/",
where: "tabshifted",
},
},
},
},
};
const BROWSE_PRIVATELY_CARD = {
content: {
title: {
string_id: "onboarding-browse-privately-title",
},
text: {
string_id: "onboarding-browse-privately-text",
},
icon: "private",
primary_button: {
label: {
string_id: "onboarding-browse-privately-button",
},
action: {
type: "OPEN_PRIVATE_BROWSER_WINDOW",
},
},
},
id: "TRAILHEAD_CARD_4",
};
const FX_MONITOR_CARD = {
content: {
title: {
string_id: "onboarding-firefox-monitor-title",
},
text: {
string_id: "onboarding-firefox-monitor-text2",
},
icon: "ffmonitor",
primary_button: {
label: {
string_id: "onboarding-firefox-monitor-button",
},
action: {
type: "OPEN_URL",
data: {
args: "https://monitor.firefox.com/",
where: "tabshifted",
},
},
},
},
id: "TRAILHEAD_CARD_3",
};
const SYNC_CARD = {
content: {
title: {
string_id: "onboarding-data-sync-title",
},
text: {
string_id: "onboarding-data-sync-text2",
},
icon: "devices",
primary_button: {
label: {
string_id: "onboarding-data-sync-button2",
},
action: {
type: "OPEN_URL",
addFlowParams: true,
data: {
args:
"https://accounts.firefox.com/?service=sync&action=email&context=fx_desktop_v3&entrypoint=activity-stream-firstrun&style=trailhead",
where: "tabshifted",
},
},
},
},
id: "TRAILHEAD_CARD_2",
};
const PULL_FACTOR_PRIVACY_RECIPE = {
slug: "aw_pull_factor_privacy", // experiment id
branches: [
{
slug: "control",
ratio: 1,
value: {},
},
{
slug: "variant_1", // branch id
ratio: 1,
value: {
title: "Welcome to Firefox. Fast, safe, private.",
cards: [
PERSONAL_DATA_PROMISE_CARD,
FX_MONITOR_CARD,
BROWSE_PRIVATELY_CARD,
],
},
},
{
slug: "variant_2",
ratio: 1,
value: {
title: "Welcome to Firefox",
cards: [
PERSONAL_DATA_PROMISE_CARD,
FX_MONITOR_CARD,
BROWSE_PRIVATELY_CARD,
],
},
},
{
slug: "variant_3",
ratio: 1,
value: {
title: "Welcome to Firefox. Fast, safe, private.",
cards: [SYNC_CARD, FX_MONITOR_CARD, BROWSE_PRIVATELY_CARD],
},
},
],
};
const ExperimentAPI = {
_RECIPES: [
TEST_REFERENCE_RECIPE,
PULL_FACTOR_PRIVACY_RECIPE,
// Add more recipes below
],
getExperiment() {
const recipes = this._RECIPES;
const experiment = testSlug && recipes.find(r => r.slug === testSlug);
if (experiment) {
const branch = experiment.branches.find(b => b.slug === testBranch);
return branch ? { slug: experiment.slug, branch } : {};
}
return {};
},
getValue() {
const recipes = this._RECIPES;
const experiment = testSlug && recipes.find(r => r.slug === testSlug);
if (experiment) {
const branch = experiment.branches.find(b => b.slug === testBranch);
return branch ? branch.value : {};
}
return {};
},
};

View File

@ -1,85 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
"use strict";
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { ExperimentAPI, TEST_REFERENCE_RECIPE } = ChromeUtils.import(
"resource://activity-stream/aboutwelcome/lib/AboutWelcomeExperimentAPI.jsm"
);
const SLUG_PREF = "browser.aboutwelcome.temp.testExperiment.slug";
const BRANCH_PREF = "browser.aboutwelcome.temp.testExperiment.branch";
add_task(async function test_getValue() {
registerCleanupFunction(() => {
Services.prefs.clearUserPref(SLUG_PREF);
Services.prefs.clearUserPref(BRANCH_PREF);
});
// Return empty array by default
Assert.deepEqual(
ExperimentAPI.getValue(),
{},
"should return empty data by default"
);
// Get control branch for a slug if no branch is set
Services.prefs.setCharPref(SLUG_PREF, "about_welcome_test");
Assert.deepEqual(
ExperimentAPI.getValue(),
TEST_REFERENCE_RECIPE.branches[0].value,
"should return control branch for a given test experiment if defined in the slug pref"
);
// Get recipe for a slug and branch
Services.prefs.setCharPref(SLUG_PREF, "about_welcome_test");
Services.prefs.setCharPref(BRANCH_PREF, "variant");
Assert.deepEqual(
ExperimentAPI.getValue(),
TEST_REFERENCE_RECIPE.branches[1].value,
"should return variant branch for a given test experiment if defined in prefs"
);
});
add_task(async function test_getExperiment() {
Services.prefs.clearUserPref(SLUG_PREF);
Services.prefs.clearUserPref(BRANCH_PREF);
// Return empty array by default
Assert.deepEqual(
ExperimentAPI.getExperiment(),
{},
"should return empty data by default"
);
// Get control branch for a slug if no branch is set
Services.prefs.setCharPref(SLUG_PREF, "about_welcome_test");
Assert.deepEqual(
ExperimentAPI.getExperiment().slug,
"about_welcome_test",
"should return test experiment slug"
);
Assert.deepEqual(
ExperimentAPI.getExperiment().branch,
TEST_REFERENCE_RECIPE.branches[0],
"should return control branch for a given test experiment if defined in the slug pref"
);
// Get recipe for a slug and branch
Services.prefs.setCharPref(SLUG_PREF, "about_welcome_test");
Services.prefs.setCharPref(BRANCH_PREF, "variant");
Assert.deepEqual(
ExperimentAPI.getExperiment().branch,
TEST_REFERENCE_RECIPE.branches[1],
"should return variant branch for a given test experiment if defined in prefs"
);
registerCleanupFunction(() => {
Services.prefs.clearUserPref(SLUG_PREF);
Services.prefs.clearUserPref(BRANCH_PREF);
});
});

View File

@ -7,4 +7,3 @@ skip-if = toolkit == 'android'
[test_ASRouterTargeting_attribution.js]
skip-if = toolkit != "cocoa" # osx specific tests
[test_AboutWelcomeTelemetry.js]
[test_AboutWelcomeExperimentAPI.js]

View File

@ -18,4 +18,5 @@ export interface Enrollment {
branch: Branch;
active: boolean;
experimentType: string;
source: string;
}

View File

@ -20,13 +20,19 @@ XPCOMUtils.defineLazyModuleGetters(this, {
ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
ExperimentStore:
"resource://messaging-system/experiments/ExperimentStore.jsm",
LogManager: "resource://normandy/lib/LogManager.jsm",
NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
Sampling: "resource://gre/modules/components-utils/Sampling.jsm",
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
});
XPCOMUtils.defineLazyGetter(this, "log", () => {
const { Logger } = ChromeUtils.import(
"resource://messaging-system/lib/Logger.jsm"
);
return new Logger("ExperimentManager");
});
// This is included with event telemetry e.g. "enroll"
// TODO: Add a new type called "messaging_study"
const EVENT_TELEMETRY_STUDY_TYPE = "preference_study";
@ -43,8 +49,15 @@ class _ExperimentManager {
constructor({ id = "experimentmanager", store } = {}) {
this.id = id;
this.store = store || new ExperimentStore();
this.slugsSeenInThisSession = new Set();
this.log = LogManager.getLogger("ExperimentManager");
this.sessions = new Map();
this.filterContext = {};
Object.defineProperty(this.filterContext, "activeExperiments", {
get: async () => {
await this.store.ready();
return this.store.getAllActive().map(exp => exp.slug);
},
});
}
/**
@ -62,29 +75,46 @@ class _ExperimentManager {
/**
* Runs every time a Recipe is updated or seen for the first time.
* @param {RecipeArgs} recipe
* @param {string} source
*/
async onRecipe(recipe) {
async onRecipe(recipe, source) {
const { slug, isEnrollmentPaused } = recipe;
this.slugsSeenInThisSession.add(slug);
if (!source) {
throw new Error("When calling onRecipe, you must specify a source.");
}
if (!this.sessions.has(source)) {
this.sessions.set(source, new Set());
}
this.sessions.get(source).add(slug);
if (this.store.has(slug)) {
this.updateEnrollment(recipe);
} else if (isEnrollmentPaused) {
this.log.debug(`Enrollment is paused for "${slug}"`);
log.debug(`Enrollment is paused for "${slug}"`);
} else {
await this.enroll(recipe);
await this.enroll(recipe, source);
}
}
// Runs when the all recipes been processed during an update, including at first run.
onFinalize() {
/**
* Runs when the all recipes been processed during an update, including at first run.
* @param {string} sourceToCheck
*/
onFinalize(sourceToCheck) {
if (!sourceToCheck) {
throw new Error("When calling onFinalize, you must specify a source.");
}
const activeExperiments = this.store.getAllActive();
for (const experiment of activeExperiments) {
const { slug } = experiment;
if (!this.slugsSeenInThisSession.has(slug)) {
this.log.debug(`Stopping study for recipe ${slug}`);
const { slug, source } = experiment;
if (sourceToCheck !== source) {
continue;
}
if (!this.sessions.get(source)?.has(slug)) {
log.debug(`Stopping study for recipe ${slug}`);
try {
this.unenroll(slug, "recipe-not-seen");
} catch (err) {
@ -93,18 +123,22 @@ class _ExperimentManager {
}
}
this.slugsSeenInThisSession.clear();
this.sessions.delete(sourceToCheck);
}
/**
* Start a new experiment by enrolling the users
*
* @param {RecipeArgs} recipe
* @param {string} source
* @returns {Promise<Enrollment>} The experiment object stored in the data store
* @rejects {Error}
* @memberof _ExperimentManager
*/
async enroll({ slug, branches, experimentType = DEFAULT_EXPERIMENT_TYPE }) {
async enroll(
{ slug, branches, experimentType = DEFAULT_EXPERIMENT_TYPE },
source
) {
if (this.store.has(slug)) {
this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
throw new Error(`An experiment with the slug "${slug}" already exists.`);
@ -114,7 +148,7 @@ class _ExperimentManager {
const branch = await this.chooseBranch(slug, branches);
if (branch.groups && this.store.hasExperimentForGroups(branch.groups)) {
this.log.debug(
log.debug(
`Skipping enrollment for "${slug}" because there is an existing experiment for one of its groups.`
);
this.sendFailureTelemetry("enrollFailed", slug, "group-conflict");
@ -128,13 +162,14 @@ class _ExperimentManager {
active: true,
enrollmentId,
experimentType,
source,
};
this.store.addExperiment(experiment);
this.setExperimentActive(experiment);
this.sendEnrollmentTelemetry(experiment);
this.log.debug(`New experiment started: ${slug}, ${branch.slug}`);
log.debug(`New experiment started: ${slug}, ${branch.slug}`);
return experiment;
}
@ -150,13 +185,13 @@ class _ExperimentManager {
// Don't update experiments that were already unenrolled.
if (experiment.active === false) {
this.log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
return;
}
// Stay in the same branch, don't re-sample every time.
const branch = recipe.branches.find(
branch => branch.slug === experiment.branch
branch => branch.slug === experiment.branch.slug
);
if (!branch) {
@ -169,10 +204,9 @@ class _ExperimentManager {
* Stop an experiment that is currently active
*
* @param {string} slug
* @param {object} [options]
* @param {string} [options.reason]
* @param {string} reason
*/
unenroll(slug, { reason = "unknown" } = {}) {
unenroll(slug, reason = "unknown") {
const experiment = this.store.get(slug);
if (!experiment) {
this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
@ -196,7 +230,7 @@ class _ExperimentManager {
experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
});
this.log.debug(`Experiment unenrolled: ${slug}}`);
log.debug(`Experiment unenrolled: ${slug}}`);
}
/**

View File

@ -4,6 +4,5 @@
toolkit.jar:
% resource messaging-system %res/messaging-system/
res/messaging-system/experiments/ExperimentManager.jsm (./experiments/ExperimentManager.jsm)
res/messaging-system/experiments/ExperimentStore.jsm (./experiments/ExperimentStore.jsm)
res/messaging-system/experiments/ (./experiments/*)
res/messaging-system/lib/ (./lib/*)

View File

@ -0,0 +1,22 @@
/* 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";
const EXPORTED_SYMBOLS = ["Logger"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm");
const LOGGING_PREF = "messaging-system.log";
class Logger extends ConsoleAPI {
constructor(name) {
let consoleOptions = {
prefix: name,
maxLogLevel: Services.prefs.getCharPref(LOGGING_PREF, "warn"),
maxLogLevelPref: LOGGING_PREF,
};
super(consoleOptions);
}
}

View File

@ -0,0 +1,164 @@
/* 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";
const EXPORTED_SYMBOLS = [
"_RemoteSettingsExperimentLoader",
"RemoteSettingsExperimentLoader",
];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
ExperimentManager:
"resource://messaging-system/experiments/ExperimentManager.jsm",
RemoteSettings: "resource://services-settings/remote-settings.js",
CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
});
XPCOMUtils.defineLazyGetter(this, "log", () => {
const { Logger } = ChromeUtils.import(
"resource://messaging-system/lib/Logger.jsm"
);
return new Logger("RSLoader");
});
XPCOMUtils.defineLazyServiceGetter(
this,
"timerManager",
"@mozilla.org/updates/timer-manager;1",
"nsIUpdateTimerManager"
);
const COLLECTION_ID = "messaging-experiments";
const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled";
const TIMER_NAME = "rs-experiment-loader-timer";
const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`;
// Use the same update interval as normandy
const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
class _RemoteSettingsExperimentLoader {
constructor() {
// Has the timer been set?
this._initialized = false;
// Are we in the middle of updating recipes already?
this._updating = false;
XPCOMUtils.defineLazyGetter(this, "remoteSettingsClient", () => {
return RemoteSettings(COLLECTION_ID);
});
XPCOMUtils.defineLazyPreferenceGetter(
this,
"enabled",
ENABLED_PREF,
false,
this.onEnabledPrefChange.bind(this)
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"intervalInSeconds",
RUN_INTERVAL_PREF,
21600,
() => this.setTimer()
);
}
async init() {
if (this._initialized || !this.enabled) {
return;
}
this.setTimer();
CleanupManager.addCleanupHandler(() => this.uninit());
this._initialized = true;
await this.updateRecipes();
}
uninit() {
if (!this._initialized) {
return;
}
timerManager.unregisterTimer(TIMER_NAME);
this._initialized = false;
}
async updateRecipes(trigger) {
if (this._updating || !this._initialized) {
return;
}
this._updating = true;
log.debug("Updating recipes");
let recipes;
let loadingError = false;
try {
recipes = await this.remoteSettingsClient.get();
} catch (e) {
loadingError = true;
Cu.reportError(e);
}
if (recipes && !loadingError) {
log.debug("Updating ExperimentManager with new recipes");
for (const r of recipes) {
if (
await ASRouterTargeting.isMatch(
r.filter_expression,
ExperimentManager.filterContext,
err => {
log.debug("Targeting failed because of an error");
Cu.reportError(err);
}
)
) {
log.debug(`${r.id} passed filter_expression`);
await ExperimentManager.onRecipe(r.arguments, "rs-loader");
} else {
log.debug(`${r.id} failed filter_expression`);
}
}
ExperimentManager.onFinalize("rs-loader");
}
if (trigger !== "timer") {
const lastUpdateTime = Math.round(Date.now() / 1000);
Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
}
this._updating = false;
}
onEnabledPrefChange(prefName, oldValue, newValue) {
if (this._initialized && !newValue) {
this.uninit();
} else if (!this._initialized && newValue) {
this.init();
}
}
setTimer() {
// When this function is called, updateRecipes is called immediately
// and then every this.intervalInSeconds
timerManager.registerTimer(
TIMER_NAME,
() => this.updateRecipes("timer"),
this.intervalInSeconds
);
log.debug("Registered update timer");
}
}
const RemoteSettingsExperimentLoader = new _RemoteSettingsExperimentLoader();

View File

@ -10,7 +10,6 @@ with Files('**'):
XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
TESTING_JS_MODULES += [
'experiments/ExperimentAPI.jsm',
'test/MSTestUtils.jsm'
]

View File

@ -18,6 +18,10 @@ const { FileTestUtils } = ChromeUtils.import(
);
const PATH = FileTestUtils.getTempFile("shared-data-map").path;
const { _RemoteSettingsExperimentLoader } = ChromeUtils.import(
"resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm"
);
const EXPORTED_SYMBOLS = ["ExperimentFakes"];
const ExperimentFakes = {
@ -30,12 +34,22 @@ const ExperimentFakes = {
childStore() {
return new ExperimentStore("FakeStore", { isParent: false });
},
rsLoader() {
const loader = new _RemoteSettingsExperimentLoader();
// Replace RS client with a fake
Object.defineProperty(loader, "remoteSettingsClient", {
get: () => ({ get: () => Promise.resolve([]) }),
});
return loader;
},
experiment(slug, props = {}) {
return {
slug,
active: true,
enrollmentId: NormandyUtils.generateUuid(),
branch: { slug: "treatment", value: { title: "hello" } },
source: "test",
...props,
};
},

View File

@ -1,7 +1,7 @@
"use strict";
const { ExperimentAPI } = ChromeUtils.import(
"resource://testing-common/ExperimentAPI.jsm"
"resource://messaging-system/experiments/ExperimentAPI.jsm"
);
const { ExperimentFakes } = ChromeUtils.import(
"resource://testing-common/MSTestUtils.jsm"

View File

@ -51,7 +51,7 @@ add_task(async function test_onStartup_setExperimentActive_called() {
/**
* onRecipe()
* - should add recipe slug to .slugsSeenInThisSession
* - should add recipe slug to .session[source]
* - should call .enroll() if the recipe hasn't been seen before;
* - should call .update() if the Enrollment already exists in the store;
* - should skip enrollment if recipe.isEnrollmentPaused is true
@ -66,12 +66,12 @@ add_task(async function test_onRecipe_track_slug() {
await manager.onStartup();
// The first time a recipe has seen;
await manager.onRecipe(fooRecipe);
await manager.onRecipe(fooRecipe, "test");
Assert.equal(
manager.slugsSeenInThisSession.has("foo"),
manager.sessions.get("test").has("foo"),
true,
"should add slug to slugsSeenInThisSession"
"should add slug to sessions[test]"
);
});
@ -84,7 +84,7 @@ add_task(async function test_onRecipe_enroll() {
const fooRecipe = ExperimentFakes.recipe("foo");
await manager.onStartup();
await manager.onRecipe(fooRecipe);
await manager.onRecipe(fooRecipe, "test");
Assert.equal(
manager.enroll.calledWith(fooRecipe),
@ -107,10 +107,9 @@ add_task(async function test_onRecipe_update() {
const fooRecipe = ExperimentFakes.recipe("foo");
await manager.onStartup();
await manager.onRecipe(fooRecipe);
await manager.onRecipe(fooRecipe, "test");
// Call again after recipe has already been enrolled
await manager.onRecipe(fooRecipe);
await manager.onRecipe(fooRecipe, "test");
Assert.equal(
manager.updateEnrollment.calledWith(fooRecipe),
@ -130,7 +129,7 @@ add_task(async function test_onRecipe_isEnrollmentPaused() {
const pausedRecipe = ExperimentFakes.recipe("xyz", {
isEnrollmentPaused: true,
});
await manager.onRecipe(pausedRecipe);
await manager.onRecipe(pausedRecipe, "test");
Assert.equal(
manager.enroll.calledWith(pausedRecipe),
false,
@ -147,7 +146,7 @@ add_task(async function test_onRecipe_isEnrollmentPaused() {
isEnrollmentPaused: true,
});
await manager.enroll(fooRecipe);
await manager.onRecipe(updatedRecipe);
await manager.onRecipe(updatedRecipe, "test");
Assert.equal(
manager.updateEnrollment.calledWith(updatedRecipe),
true,
@ -172,11 +171,12 @@ add_task(async function test_onFinalize_unenroll() {
manager.store.addExperiment(ExperimentFakes.experiment("foo"));
// Simulate adding some other recipes
await manager.onRecipe(ExperimentFakes.recipe("bar"));
await manager.onRecipe(ExperimentFakes.recipe("baz"));
await manager.onStartup();
await manager.onRecipe(ExperimentFakes.recipe("bar"), "test");
await manager.onRecipe(ExperimentFakes.recipe("baz"), "test");
// Finalize
manager.onFinalize();
manager.onFinalize("test");
Assert.equal(
manager.unenroll.callCount,
@ -189,8 +189,8 @@ add_task(async function test_onFinalize_unenroll() {
"should unenroll a experiment whose recipe wasn't seen in the current session"
);
Assert.equal(
manager.slugsSeenInThisSession.size,
0,
"should clear slugsSeenInThisSession"
manager.sessions.has("test"),
false,
"should clear sessions[test]"
);
});

View File

@ -32,7 +32,7 @@ add_task(async function test_set_inactive() {
await manager.onStartup();
manager.store.addExperiment(ExperimentFakes.experiment("foo"));
manager.unenroll("foo", { reason: "some-reason" });
manager.unenroll("foo", "some-reason");
Assert.equal(
manager.store.get("foo").active,
@ -49,7 +49,7 @@ add_task(async function test_setExperimentInactive_called() {
await manager.onStartup();
manager.store.addExperiment(experiment);
manager.unenroll("foo", { reason: "some-reason" });
manager.unenroll("foo", "some-reason");
Assert.ok(
TelemetryEnvironment.setExperimentInactive.calledWith("foo"),
@ -65,7 +65,7 @@ add_task(async function test_send_unenroll_event() {
await manager.onStartup();
manager.store.addExperiment(experiment);
manager.unenroll("foo", { reason: "some-reason" });
manager.unenroll("foo", "some-reason");
Assert.ok(TelemetryEvents.sendEvent.calledOnce);
Assert.deepEqual(

View File

@ -0,0 +1,73 @@
"use strict";
const { ExperimentFakes } = ChromeUtils.import(
"resource://testing-common/MSTestUtils.jsm"
);
const { CleanupManager } = ChromeUtils.import(
"resource://normandy/lib/CleanupManager.jsm"
);
const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled";
const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
add_task(async function test_lazy_pref_getters() {
const loader = ExperimentFakes.rsLoader();
sinon.stub(loader, "updateRecipes").resolves();
Services.prefs.setIntPref(RUN_INTERVAL_PREF, 123456);
equal(
loader.intervalInSeconds,
123456,
`should set intervalInSeconds to the value of ${RUN_INTERVAL_PREF}`
);
Services.prefs.setBoolPref(ENABLED_PREF, true);
equal(
loader.enabled,
true,
`should set enabled to the value of ${ENABLED_PREF}`
);
Services.prefs.setBoolPref(ENABLED_PREF, false);
equal(loader.enabled, false);
Services.prefs.clearUserPref(RUN_INTERVAL_PREF);
Services.prefs.clearUserPref(ENABLED_PREF);
});
add_task(async function test_init() {
const loader = ExperimentFakes.rsLoader();
sinon.stub(loader, "setTimer");
sinon.stub(loader, "updateRecipes").resolves();
Services.prefs.setBoolPref(ENABLED_PREF, false);
await loader.init();
equal(
loader.setTimer.callCount,
0,
`should not initialize if ${ENABLED_PREF} pref is false`
);
Services.prefs.setBoolPref(ENABLED_PREF, true);
await loader.init();
ok(loader.setTimer.calledOnce, "should call .setTimer");
ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
});
add_task(async function test_init() {
const loader = ExperimentFakes.rsLoader();
sinon.stub(loader, "setTimer");
sinon.stub(loader, "updateRecipes").resolves();
Services.prefs.setBoolPref(ENABLED_PREF, false);
await loader.init();
equal(
loader.setTimer.callCount,
0,
`should not initialize if ${ENABLED_PREF} pref is false`
);
Services.prefs.setBoolPref(ENABLED_PREF, true);
await loader.init();
ok(loader.setTimer.calledOnce, "should call .setTimer");
ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
});

View File

@ -9,3 +9,4 @@ firefox-appdir = browser
[test_ExperimentStore.js]
[test_SharedDataMap.js]
[test_ExperimentAPI.js]
[test_RemoteSettingsExperimentLoader.js]

View File

@ -23,6 +23,8 @@ XPCOMUtils.defineLazyModuleGetters(this, {
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
ExperimentManager:
"resource://messaging-system/experiments/ExperimentManager.jsm",
RemoteSettingsExperimentLoader:
"resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm",
});
var EXPORTED_SYMBOLS = ["Normandy"];
@ -112,6 +114,12 @@ var Normandy = {
log.error("Failed to initialize ExperimentManager:", err);
}
try {
await RemoteSettingsExperimentLoader.init();
} catch (err) {
log.error("Failed to initialize RemoteSettingsExperimentLoader:", err);
}
try {
await AddonStudies.init();
} catch (err) {

View File

@ -21,6 +21,7 @@ ChromeUtils.defineModuleGetter(
);
const EXPORTED_SYMBOLS = ["MessagingExperimentAction"];
const RECIPE_SOURCE = "normandy";
class MessagingExperimentAction extends BaseStudyAction {
constructor() {
@ -33,11 +34,11 @@ class MessagingExperimentAction extends BaseStudyAction {
async _run(recipe) {
if (recipe.arguments) {
await this.manager.onRecipe(recipe.arguments);
await this.manager.onRecipe(recipe.arguments, RECIPE_SOURCE);
}
}
async _finalize() {
this.manager.onFinalize();
this.manager.onFinalize(RECIPE_SOURCE);
}
}

View File

@ -57,8 +57,8 @@ decorate_task(
Assert.deepEqual(reportRecipe.args, [[recipe, Uptake.RECIPE_SUCCESS]]);
Assert.deepEqual(
onRecipeStub.args,
[[recipe.arguments]],
"should call onRecipe with recipe args"
[[recipe.arguments, "normandy"]],
"should call onRecipe with recipe args and 'normandy' source"
);
}
);