mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-24 13:21:05 +00:00
Bug 1707901 - Add opt in to nimbus experiments via URL r=k88hudson
Differential Revision: https://phabricator.services.mozilla.com/D113641
This commit is contained in:
parent
cb53a7c598
commit
92b0ab56f1
@ -1489,6 +1489,7 @@ pref("browser.messaging-system.personalized-cfr.score-threshold", 5000);
|
||||
pref("messaging-system.log", "warn");
|
||||
pref("messaging-system.rsexperimentloader.enabled", true);
|
||||
pref("messaging-system.rsexperimentloader.collection_id", "nimbus-desktop-experiments");
|
||||
pref("nimbus.debug", false);
|
||||
|
||||
// Enable the DOM fullscreen API.
|
||||
pref("full-screen-api.enabled", true);
|
||||
|
@ -673,7 +673,7 @@ let JSWINDOWACTORS = {
|
||||
ShieldPageEvent: { wantUntrusted: true },
|
||||
},
|
||||
},
|
||||
matches: ["about:studies"],
|
||||
matches: ["about:studies*"],
|
||||
},
|
||||
|
||||
ASRouter: {
|
||||
|
@ -176,22 +176,13 @@ class _ExperimentManager {
|
||||
* @rejects {Error}
|
||||
* @memberof _ExperimentManager
|
||||
*/
|
||||
async enroll(
|
||||
{
|
||||
slug,
|
||||
branches,
|
||||
experimentType = TELEMETRY_DEFAULT_EXPERIMENT_TYPE,
|
||||
userFacingName,
|
||||
userFacingDescription,
|
||||
},
|
||||
source
|
||||
) {
|
||||
async enroll(recipe, source) {
|
||||
let { slug, branches } = recipe;
|
||||
if (this.store.has(slug)) {
|
||||
this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
|
||||
throw new Error(`An experiment with the slug "${slug}" already exists.`);
|
||||
}
|
||||
|
||||
const enrollmentId = NormandyUtils.generateUuid();
|
||||
const branch = await this.chooseBranch(slug, branches);
|
||||
|
||||
if (
|
||||
@ -208,12 +199,26 @@ class _ExperimentManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._enroll(recipe, branch, source);
|
||||
}
|
||||
|
||||
_enroll(
|
||||
{
|
||||
slug,
|
||||
experimentType = TELEMETRY_DEFAULT_EXPERIMENT_TYPE,
|
||||
userFacingName,
|
||||
userFacingDescription,
|
||||
},
|
||||
branch,
|
||||
source,
|
||||
options = {}
|
||||
) {
|
||||
/** @type {Enrollment} */
|
||||
const experiment = {
|
||||
slug,
|
||||
branch,
|
||||
active: true,
|
||||
enrollmentId,
|
||||
enrollmentId: NormandyUtils.generateUuid(),
|
||||
experimentType,
|
||||
source,
|
||||
userFacingName,
|
||||
@ -221,6 +226,12 @@ class _ExperimentManager {
|
||||
lastSeen: new Date().toJSON(),
|
||||
};
|
||||
|
||||
// Tag this as a forced enrollment. This prevents all unenrolling unless
|
||||
// manually triggered from about:studies
|
||||
if (options.force) {
|
||||
experiment.force = true;
|
||||
}
|
||||
|
||||
this.store.addExperiment(experiment);
|
||||
this.setExperimentActive(experiment);
|
||||
this.sendEnrollmentTelemetry(experiment);
|
||||
@ -230,6 +241,29 @@ class _ExperimentManager {
|
||||
return experiment;
|
||||
}
|
||||
|
||||
forceEnroll(recipe, branch, source = "force-enrollment") {
|
||||
/**
|
||||
* If we happen to be enrolled in an experiment for the same feature
|
||||
* we need to unenroll from that experiment.
|
||||
* If the experiment has the same slug after unenrollment adding it to the
|
||||
* store will overwrite the initial experiment.
|
||||
*/
|
||||
let experiment = this.store.getExperimentForFeature(
|
||||
branch.feature?.featureId
|
||||
);
|
||||
if (experiment) {
|
||||
log.debug(
|
||||
`Existing experiment found for the same feature ${branch?.feature.featureId}, unenrolling.`
|
||||
);
|
||||
|
||||
this.unenroll(experiment.slug, source);
|
||||
}
|
||||
|
||||
recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`;
|
||||
|
||||
return this._enroll(recipe, branch, source, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an enrollment that was already set
|
||||
*
|
||||
@ -245,6 +279,11 @@ class _ExperimentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't check forced enrollments
|
||||
if (experiment.force) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stay in the same branch, don't re-sample every time.
|
||||
const branch = recipe.branches.find(
|
||||
branch => branch.slug === experiment.branch.slug
|
||||
|
@ -47,6 +47,7 @@ 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";
|
||||
const NIMBUS_DEBUG_PREF = "nimbus.debug";
|
||||
|
||||
XPCOMUtils.defineLazyPreferenceGetter(
|
||||
this,
|
||||
@ -54,6 +55,12 @@ XPCOMUtils.defineLazyPreferenceGetter(
|
||||
COLLECTION_ID_PREF,
|
||||
COLLECTION_ID_FALLBACK
|
||||
);
|
||||
XPCOMUtils.defineLazyPreferenceGetter(
|
||||
this,
|
||||
"NIMBUS_DEBUG",
|
||||
NIMBUS_DEBUG_PREF,
|
||||
false
|
||||
);
|
||||
|
||||
/**
|
||||
* Responsible for pre-fetching remotely defined configurations from
|
||||
@ -314,6 +321,41 @@ class _RemoteSettingsExperimentLoader {
|
||||
this._updating = false;
|
||||
}
|
||||
|
||||
async optInToExperiment({ slug, branch: branchSlug, collection }) {
|
||||
if (!NIMBUS_DEBUG) {
|
||||
log.debug(
|
||||
`Force enrollment only works when '${NIMBUS_DEBUG_PREF}' is enabled.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let recipes;
|
||||
try {
|
||||
recipes = await RemoteSettings(collection || COLLECTION_ID).get();
|
||||
} catch (e) {
|
||||
log.debug("Error getting recipes from remote settings.");
|
||||
Cu.reportError(e);
|
||||
}
|
||||
|
||||
let recipe = recipes.find(r => r.slug === slug);
|
||||
|
||||
if (!recipe) {
|
||||
log.debug(
|
||||
`Could not find experiment slug ${slug} in collection ${collection ||
|
||||
COLLECTION_ID}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let branch = recipe.branches.find(b => b.slug === branchSlug);
|
||||
if (!branch) {
|
||||
log.debug(`Could not find branch slug ${branch} in ${slug}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return ExperimentManager.forceEnroll(recipe, branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles feature status based on feature pref and STUDIES_OPT_OUT_PREF.
|
||||
* Changing any of them to false will turn off any recipe fetching and
|
||||
|
@ -3,6 +3,7 @@ support-files =
|
||||
head.js
|
||||
|
||||
[browser_remotesettingsexperimentloader_remote_defaults.js]
|
||||
[browser_remotesettingsexperimentloader_force_enrollment.js]
|
||||
[browser_experimentstore_load.js]
|
||||
[browser_remotesettings_experiment_enroll.js]
|
||||
[browser_experiment_evaluate_jexl.js]
|
||||
|
@ -0,0 +1,139 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
const { RemoteSettings } = ChromeUtils.import(
|
||||
"resource://services-settings/remote-settings.js"
|
||||
);
|
||||
const {
|
||||
RemoteDefaultsLoader,
|
||||
RemoteSettingsExperimentLoader,
|
||||
} = ChromeUtils.import(
|
||||
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
|
||||
);
|
||||
const { BrowserTestUtils } = ChromeUtils.import(
|
||||
"resource://testing-common/BrowserTestUtils.jsm"
|
||||
);
|
||||
const { ExperimentFakes } = ChromeUtils.import(
|
||||
"resource://testing-common/NimbusTestUtils.jsm"
|
||||
);
|
||||
const { ExperimentManager } = ChromeUtils.import(
|
||||
"resource://nimbus/lib/ExperimentManager.jsm"
|
||||
);
|
||||
|
||||
async function setup(recipes) {
|
||||
const client = RemoteSettings("nimbus-desktop-experiments");
|
||||
await client.db.importChanges({}, 42, recipes, {
|
||||
clear: true,
|
||||
});
|
||||
|
||||
await BrowserTestUtils.waitForCondition(
|
||||
async () => (await client.get()).length,
|
||||
"RS is ready"
|
||||
);
|
||||
|
||||
registerCleanupFunction(async () => {
|
||||
await client.db.clear();
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
add_task(async function setup() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
["messaging-system.log", "all"],
|
||||
["nimbus.debug", true],
|
||||
],
|
||||
});
|
||||
|
||||
registerCleanupFunction(async () => {
|
||||
await SpecialPowers.popPrefEnv();
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function test_fetch_recipe_and_branch_no_debug() {
|
||||
const sandbox = sinon.createSandbox();
|
||||
Services.prefs.setBoolPref("nimbus.debug", false);
|
||||
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
|
||||
let recipes = [ExperimentFakes.recipe("slug123")];
|
||||
|
||||
await setup(recipes);
|
||||
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
|
||||
slug: "slug123",
|
||||
branch: "control",
|
||||
});
|
||||
|
||||
Assert.ok(!result, "Pref is not turned on");
|
||||
Assert.ok(stub.notCalled, "forceEnroll is not called");
|
||||
|
||||
Services.prefs.setBoolPref("nimbus.debug", true);
|
||||
|
||||
result = await RemoteSettingsExperimentLoader.optInToExperiment({
|
||||
slug: "slug123",
|
||||
branch: "control",
|
||||
});
|
||||
|
||||
Assert.ok(result, "Pref was turned on");
|
||||
Assert.ok(stub.called, "forceEnroll is called");
|
||||
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
add_task(async function test_fetch_recipe_and_branch_badslug() {
|
||||
const sandbox = sinon.createSandbox();
|
||||
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
|
||||
let recipes = [ExperimentFakes.recipe("slug123")];
|
||||
|
||||
await setup(recipes);
|
||||
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
|
||||
slug: "slug12",
|
||||
branch: "control",
|
||||
});
|
||||
|
||||
Assert.ok(!result, "Recipe slug doesn't exist");
|
||||
Assert.ok(stub.notCalled, "forceEnroll is not called");
|
||||
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
add_task(async function test_fetch_recipe_and_branch_badbranch() {
|
||||
const sandbox = sinon.createSandbox();
|
||||
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
|
||||
let recipes = [ExperimentFakes.recipe("slug123")];
|
||||
|
||||
await setup(recipes);
|
||||
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
|
||||
slug: "slug123",
|
||||
branch: "branch",
|
||||
});
|
||||
|
||||
Assert.ok(!result, "Recipe slug doesn't exist");
|
||||
Assert.ok(stub.notCalled, "forceEnroll is not called");
|
||||
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
add_task(async function test_fetch_recipe_and_branch() {
|
||||
const sandbox = sinon.createSandbox();
|
||||
let stub = sandbox.stub(ExperimentManager, "forceEnroll").returns(true);
|
||||
let recipes = [ExperimentFakes.recipe("slug123")];
|
||||
|
||||
await setup(recipes);
|
||||
let result = await RemoteSettingsExperimentLoader.optInToExperiment({
|
||||
slug: "slug123",
|
||||
branch: "control",
|
||||
});
|
||||
|
||||
Assert.ok(result, "Recipe found");
|
||||
Assert.ok(stub.called, "Called forceEnroll");
|
||||
Assert.deepEqual(stub.firstCall.args[0], recipes[0], "Called with recipe");
|
||||
Assert.deepEqual(
|
||||
stub.firstCall.args[1],
|
||||
recipes[0].branches[0],
|
||||
"Called with branch"
|
||||
);
|
||||
|
||||
sandbox.restore();
|
||||
});
|
@ -12,6 +12,9 @@ const { Sampling } = ChromeUtils.import(
|
||||
const { ClientEnvironment } = ChromeUtils.import(
|
||||
"resource://normandy/lib/ClientEnvironment.jsm"
|
||||
);
|
||||
const { TestUtils } = ChromeUtils.import(
|
||||
"resource://testing-common/TestUtils.jsm"
|
||||
);
|
||||
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
|
||||
|
||||
// Experiment store caches in prefs Enrollments for fast sync access
|
||||
@ -247,3 +250,102 @@ add_task(async function enroll_in_reference_aw_experiment() {
|
||||
// in prefs.
|
||||
Assert.ok(prefValue.length < 3498, "Make sure we don't bloat the prefs");
|
||||
});
|
||||
|
||||
add_task(async function test_forceEnroll_cleanup() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
const sandbox = sinon.createSandbox();
|
||||
let unenrollStub = sandbox.spy(manager, "unenroll");
|
||||
let existingRecipe = ExperimentFakes.recipe("foo", {
|
||||
branches: [
|
||||
{
|
||||
slug: "treatment",
|
||||
ratio: 1,
|
||||
feature: { featureId: "force-enrollment", enabled: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
let forcedRecipe = ExperimentFakes.recipe("bar", {
|
||||
branches: [
|
||||
{
|
||||
slug: "treatment",
|
||||
ratio: 1,
|
||||
feature: { featureId: "force-enrollment", enabled: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await manager.onStartup();
|
||||
await manager.enroll(existingRecipe);
|
||||
|
||||
let setExperimentActiveSpy = sandbox.spy(manager, "setExperimentActive");
|
||||
manager.forceEnroll(forcedRecipe, forcedRecipe.branches[0]);
|
||||
|
||||
Assert.ok(unenrollStub.called, "Unenrolled from existing experiment");
|
||||
Assert.equal(
|
||||
unenrollStub.firstCall.args[0],
|
||||
existingRecipe.slug,
|
||||
"Called with existing recipe slug"
|
||||
);
|
||||
Assert.ok(setExperimentActiveSpy.calledOnce, "Activated forced experiment");
|
||||
Assert.equal(
|
||||
setExperimentActiveSpy.firstCall.args[0].slug,
|
||||
forcedRecipe.slug,
|
||||
"Called with forced experiment slug"
|
||||
);
|
||||
Assert.equal(
|
||||
manager.store.getExperimentForFeature("force-enrollment").slug,
|
||||
forcedRecipe.slug,
|
||||
"Enrolled in forced experiment"
|
||||
);
|
||||
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
add_task(async function test_updateEnrollment_skip_force() {
|
||||
const manager = ExperimentFakes.manager();
|
||||
let recipe = ExperimentFakes.recipe("foo");
|
||||
const sandbox = sinon.createSandbox();
|
||||
const updateEnrollmentSpy = sandbox.spy(manager, "updateEnrollment");
|
||||
const unenrollSpy = sandbox.spy(manager, "unenroll");
|
||||
|
||||
await manager.onStartup();
|
||||
|
||||
await manager.enroll(recipe);
|
||||
|
||||
Assert.ok(manager.store.has("foo"), "Finished enrollment");
|
||||
|
||||
// Something about the experiment change and we won't fit in the same
|
||||
// branch assignment
|
||||
await manager.onRecipe(
|
||||
{ ...recipe, branches: [] },
|
||||
"test_ExperimentManager_enroll"
|
||||
);
|
||||
|
||||
Assert.ok(
|
||||
updateEnrollmentSpy.calledOnce,
|
||||
"Update enrollement is called because we have the same slug"
|
||||
);
|
||||
Assert.ok(unenrollSpy.calledOnce, "Because no matching branch is found");
|
||||
Assert.ok(unenrollSpy.firstCall.args[0], "foo");
|
||||
|
||||
updateEnrollmentSpy.resetHistory();
|
||||
unenrollSpy.resetHistory();
|
||||
|
||||
recipe = ExperimentFakes.recipe("bar");
|
||||
await manager.forceEnroll(recipe, recipe.branches[0]);
|
||||
|
||||
Assert.ok(manager.store.has("bar"), "Finished enrollment");
|
||||
|
||||
// Something about the experiment change and we won't fit in the same
|
||||
// branch assignment but this time it's on a forced enrollment
|
||||
await manager.onRecipe(
|
||||
{ ...recipe, branches: [] },
|
||||
"test_ExperimentManager_enroll"
|
||||
);
|
||||
|
||||
Assert.ok(
|
||||
updateEnrollmentSpy.calledOnce,
|
||||
"Update enrollement is called because we have the same slug"
|
||||
);
|
||||
Assert.ok(unenrollSpy.notCalled, "Because this is a forced enrollment");
|
||||
});
|
||||
|
@ -33,6 +33,11 @@ ChromeUtils.defineModuleGetter(
|
||||
"ExperimentManager",
|
||||
"resource://nimbus/lib/ExperimentManager.jsm"
|
||||
);
|
||||
ChromeUtils.defineModuleGetter(
|
||||
this,
|
||||
"RemoteSettingsExperimentLoader",
|
||||
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
|
||||
);
|
||||
|
||||
var EXPORTED_SYMBOLS = ["AboutPages"];
|
||||
|
||||
@ -120,6 +125,10 @@ XPCOMUtils.defineLazyGetter(AboutPages, "aboutStudies", () => {
|
||||
return ExperimentManager.store.getAll();
|
||||
},
|
||||
|
||||
optInToExperiment(data) {
|
||||
RemoteSettingsExperimentLoader.optInToExperiment(data);
|
||||
},
|
||||
|
||||
/** Add a browsing context to the weak set;
|
||||
* this weak set keeps track of all contexts
|
||||
* that are housing an about:studies page.
|
||||
|
@ -126,6 +126,9 @@ class ShieldFrameChild extends JSWindowActorChild {
|
||||
strings
|
||||
);
|
||||
break;
|
||||
case "ExperimentOptIn":
|
||||
this.sendQuery("Shield:ExperimentOptIn", event.detail.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,6 +47,8 @@ class ShieldFrameParent extends JSWindowActorParent {
|
||||
break;
|
||||
case "Shield:GetStudiesEnabled":
|
||||
return aboutStudies.getStudiesEnabled();
|
||||
case "Shield:ExperimentOptIn":
|
||||
return aboutStudies.optInToExperiment(msg.data);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -23,6 +23,15 @@ function sendPageEvent(action, data) {
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function readOptinParams() {
|
||||
let searchParams = new URLSearchParams(new URL(location).search);
|
||||
return {
|
||||
slug: searchParams.get("optin_slug"),
|
||||
branch: searchParams.get("optin_branch"),
|
||||
collection: searchParams.get("optin_collection"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle basic layout and routing within about:studies.
|
||||
*/
|
||||
@ -50,6 +59,10 @@ class AboutStudies extends React.Component {
|
||||
document.addEventListener(`ReceiveRemoteValue:${remoteName}`, this);
|
||||
sendPageEvent(`GetRemoteValue:${remoteName}`);
|
||||
}
|
||||
let optinParams = readOptinParams();
|
||||
if (optinParams.branch && optinParams.slug) {
|
||||
sendPageEvent(`ExperimentOptIn`, optinParams);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -12,6 +12,9 @@ const { ExperimentFakes } = ChromeUtils.import(
|
||||
const { ExperimentManager } = ChromeUtils.import(
|
||||
"resource://nimbus/lib/ExperimentManager.jsm"
|
||||
);
|
||||
const { RemoteSettingsExperimentLoader } = ChromeUtils.import(
|
||||
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm"
|
||||
);
|
||||
const { PromiseUtils } = ChromeUtils.import(
|
||||
"resource://gre/modules/PromiseUtils.jsm"
|
||||
);
|
||||
@ -747,3 +750,34 @@ add_task(async function test_getStudiesEnabled() {
|
||||
"about:studies is enabled if the pref is enabled"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_forceEnroll() {
|
||||
let sandbox = sinon.createSandbox();
|
||||
let stub = sandbox.stub(RemoteSettingsExperimentLoader, "optInToExperiment");
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
{
|
||||
gBrowser,
|
||||
url:
|
||||
"about:studies?optin_collection=collection123&optin_branch=branch123&optin_slug=slug123",
|
||||
},
|
||||
async browser => {
|
||||
await SpecialPowers.spawn(browser, [], async () => {
|
||||
await ContentTaskUtils.waitForCondition(
|
||||
() => content.document.querySelector(".info-box-content"),
|
||||
"Wait for content to load"
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
Assert.ok(stub.called, "Called optInToExperiment");
|
||||
Assert.deepEqual(
|
||||
stub.firstCall.args[0],
|
||||
{ slug: "slug123", branch: "branch123", collection: "collection123" },
|
||||
"Called with correct arguments"
|
||||
);
|
||||
sandbox.restore();
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user