summaryrefslogtreecommitdiffstats
path: root/toolkit/components/nimbus/test/browser
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/nimbus/test/browser')
-rw-r--r--toolkit/components/nimbus/test/browser/browser.ini25
-rw-r--r--toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js104
-rw-r--r--toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js128
-rw-r--r--toolkit/components/nimbus/test/browser/browser_experimentstore_load.js90
-rw-r--r--toolkit/components/nimbus/test/browser/browser_experimentstore_load_single_feature.js90
-rw-r--r--toolkit/components/nimbus/test/browser/browser_nimbus_telemetry.js157
-rw-r--r--toolkit/components/nimbus/test/browser/browser_prefs.js85
-rw-r--r--toolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js115
-rw-r--r--toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_force_enrollment.js250
-rw-r--r--toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_init.js84
-rw-r--r--toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js584
-rw-r--r--toolkit/components/nimbus/test/browser/head.js38
12 files changed, 1750 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/test/browser/browser.ini b/toolkit/components/nimbus/test/browser/browser.ini
new file mode 100644
index 0000000000..a2845089ea
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser.ini
@@ -0,0 +1,25 @@
+[DEFAULT]
+support-files =
+ head.js
+prefs =
+ # This turns off the update interval for fetching recipes from Remote Settings
+ app.normandy.run_interval_seconds=0
+skip-if =
+ toolkit == "android"
+ appname == "thunderbird"
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+
+[browser_experiment_single_feature_enrollment.js]
+[browser_prefs.js]
+[browser_remotesettingsexperimentloader_remote_defaults.js]
+[browser_remotesettingsexperimentloader_force_enrollment.js]
+[browser_experimentstore_load.js]
+[browser_experimentstore_load_single_feature.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_remotesettings_experiment_enroll.js]
+[browser_experiment_evaluate_jexl.js]
+[browser_remotesettingsexperimentloader_init.js]
+[browser_nimbus_telemetry.js]
+tags = remote-settings
+
diff --git a/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js b/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js
new file mode 100644
index 0000000000..50d83330a9
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js
@@ -0,0 +1,104 @@
+"use strict";
+
+const { EnrollmentsContext, RemoteSettingsExperimentLoader } =
+ ChromeUtils.importESModule(
+ "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
+ );
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+add_setup(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["messaging-system.log", "all"],
+ ["app.shield.optoutstudies.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+
+ CONTEXT = new EnrollmentsContext(RemoteSettingsExperimentLoader.manager);
+});
+
+let CONTEXT;
+
+const FAKE_CONTEXT = {
+ experiment: ExperimentFakes.recipe("fake-test-experiment"),
+ source: "browser_experiment_evaluate_jexl",
+};
+
+add_task(async function test_throws_if_no_experiment_in_context() {
+ await Assert.rejects(
+ CONTEXT.evaluateJexl("true", {
+ customThing: 1,
+ source: "test_throws_if_no_experiment_in_context",
+ }),
+ /Expected an .experiment/,
+ "should throw if experiment is not passed to the custom context"
+ );
+});
+
+add_task(async function test_evaluate_jexl() {
+ Assert.deepEqual(
+ await CONTEXT.evaluateJexl(`["hello"]`, FAKE_CONTEXT),
+ ["hello"],
+ "should return the evaluated result of a jexl expression"
+ );
+});
+
+add_task(async function test_evaluate_custom_context() {
+ const result = await CONTEXT.evaluateJexl("experiment.slug", FAKE_CONTEXT);
+ Assert.equal(
+ result,
+ "fake-test-experiment",
+ "should have the custom .experiment context"
+ );
+});
+
+add_task(async function test_evaluate_active_experiments_isFirstStartup() {
+ const result = await CONTEXT.evaluateJexl("isFirstStartup", FAKE_CONTEXT);
+ Assert.equal(
+ typeof result,
+ "boolean",
+ "should have a .isFirstStartup property from ExperimentManager "
+ );
+});
+
+add_task(async function test_evaluate_active_experiments_activeExperiments() {
+ // Add an experiment to active experiments
+ const slug = "foo" + Math.random();
+ // Init the store before we use it
+ await ExperimentManager.onStartup();
+
+ let recipe = ExperimentFakes.recipe(slug);
+ recipe.branches[0].slug = "mochitest-active-foo";
+ delete recipe.branches[1];
+
+ let { enrollmentPromise, doExperimentCleanup } =
+ ExperimentFakes.enrollmentHelper(recipe);
+
+ await enrollmentPromise;
+
+ Assert.equal(
+ await CONTEXT.evaluateJexl(`"${slug}" in activeExperiments`, FAKE_CONTEXT),
+ true,
+ "should find an active experiment"
+ );
+
+ Assert.equal(
+ await CONTEXT.evaluateJexl(
+ `"does-not-exist-fake" in activeExperiments`,
+ FAKE_CONTEXT
+ ),
+ false,
+ "should not find an experiment that doesn't exist"
+ );
+
+ await doExperimentCleanup();
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js b/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js
new file mode 100644
index 0000000000..b9a016d778
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+
+const SINGLE_FEATURE_RECIPE = {
+ appId: "firefox-desktop",
+ appName: "firefox_desktop",
+ arguments: {},
+ branches: [
+ {
+ feature: {
+ featureId: "urlbar",
+ isEarlyStartup: true,
+ value: {
+ enabled: true,
+ quickSuggestEnabled: false,
+ quickSuggestNonSponsoredIndex: -1,
+ quickSuggestShouldShowOnboardingDialog: true,
+ quickSuggestShowOnboardingDialogAfterNRestarts: 2,
+ quickSuggestSponsoredIndex: -1,
+ },
+ },
+ ratio: 1,
+ slug: "control",
+ },
+ {
+ feature: {
+ featureId: "urlbar",
+ isEarlyStartup: true,
+ value: {
+ enabled: true,
+ quickSuggestEnabled: true,
+ quickSuggestNonSponsoredIndex: -1,
+ quickSuggestShouldShowOnboardingDialog: false,
+ quickSuggestShowOnboardingDialogAfterNRestarts: 2,
+ quickSuggestSponsoredIndex: -1,
+ },
+ },
+ ratio: 1,
+ slug: "treatment",
+ },
+ ],
+ bucketConfig: {
+ count: 10000,
+ namespace: "urlbar-9",
+ randomizationUnit: "normandy_id",
+ start: 0,
+ total: 10000,
+ },
+ channel: "release",
+ endDate: null,
+ featureIds: ["urlbar"],
+ id: "firefox-suggest-history-vs-offline",
+ isEnrollmentPaused: false,
+ outcomes: [],
+ probeSets: [],
+ proposedDuration: 28,
+ proposedEnrollment: 7,
+ referenceBranch: "control",
+ schemaVersion: "1.5.0",
+ slug: "firefox-suggest-history-vs-offline",
+ startDate: "2021-07-21",
+ targeting: "true",
+ userFacingDescription: "Smarter suggestions in the AwesomeBar",
+ userFacingName: "Firefox Suggest - History vs Offline",
+};
+
+const SYNC_DATA_PREF_BRANCH = "nimbus.syncdatastore.";
+
+add_task(async function test_TODO() {
+ let { enrollmentPromise, doExperimentCleanup } =
+ ExperimentFakes.enrollmentHelper(SINGLE_FEATURE_RECIPE);
+ let sandbox = sinon.createSandbox();
+ let stub = sandbox.stub(ExperimentAPI, "recordExposureEvent");
+
+ await enrollmentPromise;
+
+ Assert.ok(
+ ExperimentAPI.getExperiment({ featureId: "urlbar" }),
+ "Should enroll in single feature experiment"
+ );
+
+ Assert.ok(
+ Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}urlbar`),
+ "Should store early startup feature for sync access"
+ );
+ Assert.equal(
+ Services.prefs.getIntPref(
+ `${SYNC_DATA_PREF_BRANCH}urlbar.quickSuggestSponsoredIndex`
+ ),
+ -1,
+ "Should store early startup variable for sync access"
+ );
+
+ Assert.equal(
+ NimbusFeatures.urlbar.getVariable(
+ "quickSuggestShowOnboardingDialogAfterNRestarts"
+ ),
+ 2,
+ "Should return value"
+ );
+
+ NimbusFeatures.urlbar.recordExposureEvent();
+
+ Assert.ok(stub.calledOnce, "Should be called once by urlbar");
+ Assert.equal(
+ stub.firstCall.args[0].experimentSlug,
+ "firefox-suggest-history-vs-offline",
+ "Should have expected slug"
+ );
+ Assert.equal(
+ stub.firstCall.args[0].featureId,
+ "urlbar",
+ "Should have expected featureId"
+ );
+
+ await doExperimentCleanup();
+ sandbox.restore();
+ NimbusFeatures.urlbar._didSendExposureEvent = false;
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_experimentstore_load.js b/toolkit/components/nimbus/test/browser/browser_experimentstore_load.js
new file mode 100644
index 0000000000..a6f526e764
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_experimentstore_load.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentStore } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentStore.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentFeatures } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+});
+
+function getPath() {
+ const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ // NOTE: If this test is failing because you have updated this path in `ExperimentStore`,
+ // users will lose their old experiment data. You should do something to migrate that data.
+ return PathUtils.join(profileDir, "ExperimentStoreData.json");
+}
+
+// Ensure that data persisted to disk is succesfully loaded by the store.
+// We write data to the expected location in the user profile and
+// instantiate an ExperimentStore that should then see the value.
+add_task(async function test_loadFromFile() {
+ const previousSession = new JSONFile({ path: getPath() });
+ await previousSession.load();
+ previousSession.data.test = {
+ slug: "test",
+ active: true,
+ lastSeen: Date.now(),
+ };
+ previousSession.saveSoon();
+ await previousSession.finalize();
+
+ // Create a store and expect to load data from previous session
+ const store = new ExperimentStore();
+
+ await store.init();
+ await store.ready();
+
+ Assert.equal(
+ previousSession.path,
+ store._store.path,
+ "Should have the same path"
+ );
+
+ Assert.ok(
+ store.get("test"),
+ "This should pass if the correct store path loaded successfully"
+ );
+});
+
+add_task(async function test_load_from_disk_event() {
+ const experiment = ExperimentFakes.experiment("foo", {
+ branch: {
+ slug: "variant",
+ features: [{ featureId: "green" }],
+ },
+ lastSeen: Date.now(),
+ });
+ const stub = sinon.stub();
+ const previousSession = new JSONFile({ path: getPath() });
+ await previousSession.load();
+ previousSession.data.foo = experiment;
+ previousSession.saveSoon();
+ await previousSession.finalize();
+
+ // Create a store and expect to load data from previous session
+ const store = new ExperimentStore();
+
+ store._onFeatureUpdate("green", stub);
+
+ await store.init();
+ await store.ready();
+
+ Assert.equal(
+ previousSession.path,
+ store._store.path,
+ "Should have the same path as previousSession."
+ );
+
+ await TestUtils.waitForCondition(() => stub.called, "Stub was called");
+
+ Assert.ok(stub.firstCall.args[1], "feature-experiment-loaded");
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_experimentstore_load_single_feature.js b/toolkit/components/nimbus/test/browser/browser_experimentstore_load_single_feature.js
new file mode 100644
index 0000000000..7e9a19e21d
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_experimentstore_load_single_feature.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentStore } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentStore.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+});
+
+const SINGLE_FEATURE_RECIPE = {
+ ...ExperimentFakes.experiment(),
+ branch: {
+ feature: {
+ featureId: "urlbar",
+ value: {
+ valueThatWillDefinitelyShowUp: 42,
+ quickSuggestNonSponsoredIndex: 2021,
+ },
+ },
+ ratio: 1,
+ slug: "control",
+ },
+ featureIds: ["urlbar"],
+ slug: "browser_experimentstore_load_single_feature",
+ userFacingDescription: "Smarter suggestions in the AwesomeBar",
+ userFacingName: "Firefox Suggest - History vs Offline",
+};
+
+function getPath() {
+ const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ // NOTE: If this test is failing because you have updated this path in `ExperimentStore`,
+ // users will lose their old experiment data. You should do something to migrate that data.
+ return PathUtils.join(profileDir, "ExperimentStoreData.json");
+}
+
+add_task(async function test_load_from_disk_event() {
+ Services.prefs.setStringPref("messaging-system.log", "all");
+ const stub = sinon.stub();
+ const previousSession = new JSONFile({ path: getPath() });
+ await previousSession.load();
+ previousSession.data[SINGLE_FEATURE_RECIPE.slug] = SINGLE_FEATURE_RECIPE;
+ previousSession.saveSoon();
+ await previousSession.finalize();
+
+ // Create a store and expect to load data from previous session
+ const store = new ExperimentStore();
+
+ let apiStoreStub = sinon.stub(ExperimentAPI, "_store").get(() => store);
+
+ store._onFeatureUpdate("urlbar", stub);
+
+ await store.init();
+ await store.ready();
+
+ await TestUtils.waitForCondition(() => stub.called, "Stub was called");
+ Assert.ok(
+ store.get(SINGLE_FEATURE_RECIPE.slug)?.slug,
+ "Experiment is loaded from disk"
+ );
+ Assert.ok(stub.firstCall.args[1], "feature-experiment-loaded");
+ Assert.equal(
+ NimbusFeatures.urlbar.getAllVariables().valueThatWillDefinitelyShowUp,
+ SINGLE_FEATURE_RECIPE.branch.feature.value.valueThatWillDefinitelyShowUp,
+ "Should match getAllVariables"
+ );
+ Assert.equal(
+ NimbusFeatures.urlbar.getVariable("quickSuggestNonSponsoredIndex"),
+ SINGLE_FEATURE_RECIPE.branch.feature.value.quickSuggestNonSponsoredIndex,
+ "Should match getVariable"
+ );
+
+ registerCleanupFunction(async () => {
+ // Remove the experiment from disk
+ const fileStore = new JSONFile({ path: getPath() });
+ await fileStore.load();
+ fileStore.data = {};
+ fileStore.saveSoon();
+ await fileStore.finalize();
+ apiStoreStub.restore();
+ });
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_nimbus_telemetry.js b/toolkit/components/nimbus/test/browser/browser_nimbus_telemetry.js
new file mode 100644
index 0000000000..c5bae5eff2
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_nimbus_telemetry.js
@@ -0,0 +1,157 @@
+"use strict";
+
+const { ExperimentAPI, _ExperimentFeature: ExperimentFeature } =
+ ChromeUtils.importESModule("resource://nimbus/ExperimentAPI.sys.mjs");
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const TELEMETRY_CATEGORY = "normandy";
+const TELEMETRY_OBJECT = "nimbus_experiment";
+// Included with active experiment information
+const EXPERIMENT_TYPE = "nimbus";
+const EVENT_FILTER = { category: TELEMETRY_CATEGORY };
+
+add_setup(async function () {
+ let sandbox = sinon.createSandbox();
+ // stub the `observe` method to make sure the Experiment Manager
+ // pref listener doesn't trigger and cause side effects
+ sandbox.stub(ExperimentManager, "observe");
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.shield.optoutstudies.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ sandbox.restore();
+ });
+});
+
+add_task(async function test_experiment_enroll_unenroll_Telemetry() {
+ Services.telemetry.clearEvents();
+ const cleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "test-feature",
+ value: { enabled: false },
+ });
+ let experiment = ExperimentAPI.getExperiment({
+ featureId: "test-feature",
+ });
+
+ Assert.ok(experiment.branch, "Should be enrolled in the experiment");
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ method: "enroll",
+ object: TELEMETRY_OBJECT,
+ value: experiment.slug,
+ extra: {
+ experimentType: EXPERIMENT_TYPE,
+ branch: experiment.branch.slug,
+ enrollmentId: experiment.enrollmentId,
+ },
+ },
+ ],
+ EVENT_FILTER
+ );
+
+ await cleanup();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ method: "unenroll",
+ object: TELEMETRY_OBJECT,
+ value: experiment.slug,
+ extra: {
+ reason: "cleanup",
+ branch: experiment.branch.slug,
+ enrollmentId: experiment.enrollmentId,
+ },
+ },
+ ],
+ EVENT_FILTER
+ );
+});
+
+add_task(async function test_experiment_expose_Telemetry() {
+ const featureManifest = {
+ description: "Test feature",
+ exposureDescription: "Used in tests",
+ };
+ const cleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "test-feature",
+ value: { enabled: false },
+ });
+
+ let experiment = ExperimentAPI.getExperiment({
+ featureId: "test-feature",
+ });
+
+ const { featureId } = experiment.branch.features[0];
+ const feature = new ExperimentFeature(featureId, featureManifest);
+
+ Services.telemetry.clearEvents();
+ feature.recordExposureEvent();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ method: "expose",
+ object: TELEMETRY_OBJECT,
+ value: experiment.slug,
+ extra: {
+ branchSlug: experiment.branch.slug,
+ featureId,
+ },
+ },
+ ],
+ EVENT_FILTER
+ );
+
+ await cleanup();
+});
+
+add_task(async function test_rollout_expose_Telemetry() {
+ const featureManifest = {
+ description: "Test feature",
+ exposureDescription: "Used in tests",
+ };
+ const cleanup = await ExperimentFakes.enrollWithRollout({
+ featureId: "test-feature",
+ value: { enabled: false },
+ });
+
+ let rollout = ExperimentAPI.getRolloutMetaData({
+ featureId: "test-feature",
+ });
+
+ Assert.ok(rollout.slug, "Found enrolled experiment");
+
+ const feature = new ExperimentFeature("test-feature", featureManifest);
+
+ Services.telemetry.clearEvents();
+ feature.recordExposureEvent();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ method: "expose",
+ object: TELEMETRY_OBJECT,
+ value: rollout.slug,
+ extra: {
+ branchSlug: rollout.branch.slug,
+ featureId: feature.featureId,
+ },
+ },
+ ],
+ EVENT_FILTER
+ );
+
+ await cleanup();
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_prefs.js b/toolkit/components/nimbus/test/browser/browser_prefs.js
new file mode 100644
index 0000000000..6c38d16428
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_prefs.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+
+const EXPERIMENT_VALUE = "experiment-value";
+const ROLLOUT_VALUE = "rollout-value";
+const ROLLOUT = "rollout";
+const EXPERIMENT = "experiment";
+
+const VALUES = {
+ [ROLLOUT]: ROLLOUT_VALUE,
+ [EXPERIMENT]: EXPERIMENT_VALUE,
+};
+
+add_task(async function test_prefs_priority() {
+ const pref = "nimbus.testing.testSetString";
+ const featureId = "testFeature";
+
+ async function doTest({ settingEnrollments, expectedValue }) {
+ info(
+ `Enrolling in a rollout and experiment where the ${settingEnrollments.join(
+ " and "
+ )} set the same pref variable.`
+ );
+ const enrollmentCleanup = [];
+
+ for (const enrollmentKind of [ROLLOUT, EXPERIMENT]) {
+ const config = {
+ featureId,
+ value: {},
+ };
+
+ if (settingEnrollments.includes(enrollmentKind)) {
+ config.value.testSetString = VALUES[enrollmentKind];
+ }
+
+ enrollmentCleanup.push(
+ await ExperimentFakes.enrollWithFeatureConfig(config, {
+ isRollout: enrollmentKind === ROLLOUT,
+ })
+ );
+ }
+
+ is(
+ NimbusFeatures[featureId].getVariable("testSetString"),
+ expectedValue,
+ "Expected the variable to match the expected value"
+ );
+
+ is(
+ Services.prefs.getStringPref(pref),
+ expectedValue,
+ "Expected the pref to match the expected value"
+ );
+
+ for (const cleanup of enrollmentCleanup) {
+ await cleanup();
+ }
+
+ Services.prefs.deleteBranch(pref);
+ }
+
+ for (const settingEnrollments of [
+ [ROLLOUT],
+ [EXPERIMENT],
+ [ROLLOUT, EXPERIMENT],
+ ]) {
+ const expectedValue = settingEnrollments.includes(EXPERIMENT)
+ ? EXPERIMENT_VALUE
+ : ROLLOUT_VALUE;
+
+ await doTest({ settingEnrollments, expectedValue });
+ }
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js b/toolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js
new file mode 100644
index 0000000000..f4b1d27b4c
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js
@@ -0,0 +1,115 @@
+"use strict";
+
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
+);
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+let rsClient;
+
+add_setup(async function () {
+ rsClient = RemoteSettings("nimbus-desktop-experiments");
+ await rsClient.db.importChanges({}, Date.now(), [], { clear: true });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["messaging-system.log", "all"],
+ ["datareporting.healthreport.uploadEnabled", true],
+ ["app.shield.optoutstudies.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ await rsClient.db.clear();
+ });
+});
+
+add_task(async function test_experimentEnrollment() {
+ // Need to randomize the slug so subsequent test runs don't skip enrollment
+ // due to a conflicting slug
+ const recipe = ExperimentFakes.recipe("foo" + Math.random(), {
+ bucketConfig: {
+ start: 0,
+ // Make sure the experiment enrolls
+ count: 10000,
+ total: 10000,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ });
+ await rsClient.db.importChanges({}, Date.now(), [recipe], {
+ clear: true,
+ });
+
+ let waitForExperimentEnrollment = ExperimentFakes.waitForExperimentUpdate(
+ ExperimentAPI,
+ recipe.slug
+ );
+ RemoteSettingsExperimentLoader.updateRecipes("mochitest");
+
+ await waitForExperimentEnrollment;
+
+ let experiment = ExperimentAPI.getExperiment({
+ slug: recipe.slug,
+ });
+
+ Assert.ok(experiment.active, "Should be enrolled in the experiment");
+
+ let waitForExperimentUnenrollment = ExperimentFakes.waitForExperimentUpdate(
+ ExperimentAPI,
+ recipe.slug
+ );
+ ExperimentManager.unenroll(recipe.slug, "mochitest-cleanup");
+
+ await waitForExperimentUnenrollment;
+
+ experiment = ExperimentAPI.getExperiment({
+ slug: recipe.slug,
+ });
+
+ Assert.ok(!experiment.active, "Experiment is no longer active");
+ ExperimentAPI._store._deleteForTests(recipe.slug);
+});
+
+add_task(async function test_experimentEnrollment_startup() {
+ // Studies pref can turn the feature off but if the feature pref is off
+ // then it stays off.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["messaging-system.rsexperimentloader.enabled", false],
+ ["app.shield.optoutstudies.enabled", false],
+ ],
+ });
+
+ Assert.ok(!RemoteSettingsExperimentLoader.enabled, "Should be disabled");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.shield.optoutstudies.enabled", true]],
+ });
+
+ Assert.ok(
+ !RemoteSettingsExperimentLoader.enabled,
+ "Should still be disabled (feature pref is off)"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["messaging-system.rsexperimentloader.enabled", true]],
+ });
+
+ Assert.ok(
+ RemoteSettingsExperimentLoader.enabled,
+ "Should finally be enabled"
+ );
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_force_enrollment.js b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_force_enrollment.js
new file mode 100644
index 0000000000..86031e600b
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_force_enrollment.js
@@ -0,0 +1,250 @@
+//creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+
+async function setup(recipes) {
+ const client = RemoteSettings("nimbus-desktop-experiments");
+ await client.db.importChanges({}, Date.now(), recipes, {
+ clear: true,
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ async () => (await client.get()).length,
+ "RS is ready"
+ );
+
+ return {
+ client,
+ cleanup: () => client.db.clear(),
+ };
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["messaging-system.log", "all"],
+ ["datareporting.healthreport.uploadEnabled", true],
+ ["app.shield.optoutstudies.enabled", true],
+ ["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");
+ let recipes = [ExperimentFakes.recipe("slug123")];
+
+ const { cleanup } = await setup(recipes);
+
+ await Assert.rejects(
+ RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "slug123",
+ branch: "control",
+ }),
+ /Could not opt in/,
+ "should throw an error"
+ );
+
+ Assert.ok(stub.notCalled, "forceEnroll is not called");
+
+ Services.prefs.setBoolPref("nimbus.debug", true);
+
+ await RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "slug123",
+ branch: "control",
+ });
+
+ Assert.ok(stub.called, "forceEnroll is called");
+
+ sandbox.restore();
+ await cleanup();
+});
+
+add_task(async function test_fetch_recipe_and_branch_badslug() {
+ const sandbox = sinon.createSandbox();
+ let stub = sandbox.stub(ExperimentManager, "forceEnroll");
+ let recipes = [ExperimentFakes.recipe("slug123")];
+
+ const { cleanup } = await setup(recipes);
+
+ await Assert.rejects(
+ RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "other_slug",
+ branch: "control",
+ }),
+ /Could not find experiment slug other_slug/,
+ "should throw an error"
+ );
+
+ Assert.ok(stub.notCalled, "forceEnroll is not called");
+
+ sandbox.restore();
+ await cleanup();
+});
+
+add_task(async function test_fetch_recipe_and_branch_badbranch() {
+ const sandbox = sinon.createSandbox();
+ let stub = sandbox.stub(ExperimentManager, "forceEnroll");
+ let recipes = [ExperimentFakes.recipe("slug123")];
+
+ const { cleanup } = await setup(recipes);
+
+ await Assert.rejects(
+ RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "slug123",
+ branch: "other_branch",
+ }),
+ /Could not find branch slug other_branch in slug123/,
+ "should throw an error"
+ );
+
+ Assert.ok(stub.notCalled, "forceEnroll is not called");
+
+ sandbox.restore();
+ await cleanup();
+});
+
+add_task(async function test_fetch_recipe_and_branch() {
+ const sandbox = sinon.createSandbox();
+ let stub = sandbox.stub(ExperimentManager, "forceEnroll");
+ let recipes = [ExperimentFakes.recipe("slug_fetch_recipe")];
+
+ const { cleanup } = await setup(recipes);
+ await RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "slug_fetch_recipe",
+ branch: "control",
+ });
+
+ 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();
+ await cleanup();
+});
+
+add_task(async function test_invalid_recipe() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub(ExperimentManager, "forceEnroll");
+ const recipe = ExperimentFakes.recipe("invalid-recipe");
+ delete recipe.branches;
+
+ const { cleanup } = await setup([recipe]);
+
+ await Assert.rejects(
+ RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "invalid-recipe",
+ branch: "control",
+ }),
+ /failed validation/
+ );
+
+ Assert.ok(stub.notCalled, "forceEnroll not called");
+
+ sandbox.restore();
+ await cleanup();
+});
+
+add_task(async function test_invalid_branch_variablesOnly() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub(ExperimentManager, "forceEnroll");
+ const recipe = ExperimentFakes.recipe("invalid-value");
+ recipe.featureIds = ["testFeature"];
+ recipe.branches = [recipe.branches[0]];
+ recipe.branches[0].features[0].featureId = "testFeature";
+ recipe.branches[0].features[0].value = {
+ enabled: "foo",
+ testInt: true,
+ testSetString: 123,
+ };
+
+ const { cleanup } = await setup([recipe]);
+
+ await Assert.rejects(
+ RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "invalid-value",
+ branch: "control",
+ }),
+ /failed validation/
+ );
+
+ Assert.ok(stub.notCalled, "forceEnroll not called");
+
+ sandbox.restore();
+ await cleanup();
+});
+
+add_task(async function test_invalid_branch_schema() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub(ExperimentManager, "forceEnroll");
+
+ const recipe = ExperimentFakes.recipe("invalid-value");
+ recipe.featureIds = ["legacyHeartbeat"];
+ recipe.branches = [recipe.branches[0]];
+ recipe.branches[0].features[0].featureId = "legacyHeartbeat";
+ recipe.branches[0].features[0].value = {
+ foo: "bar",
+ };
+
+ const { cleanup } = await setup([recipe]);
+
+ await Assert.rejects(
+ RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "invalid-value",
+ branch: "control",
+ }),
+ /failed validation/
+ );
+
+ Assert.ok(stub.notCalled, "forceEnroll not called");
+
+ sandbox.restore();
+ await cleanup();
+});
+
+add_task(async function test_invalid_branch_featureId() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub(ExperimentManager, "forceEnroll");
+ const recipe = ExperimentFakes.recipe("invalid-value");
+ recipe.featureIds = ["UNKNOWN"];
+ recipe.branches = [recipe.branches[0]];
+ recipe.branches[0].features[0].featureId = "UNKNOWN";
+
+ const { cleanup } = await setup([recipe]);
+
+ await Assert.rejects(
+ RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: "invalid-value",
+ branch: "control",
+ }),
+ /failed validation/
+ );
+
+ Assert.ok(stub.notCalled, "forceEnroll not called");
+
+ sandbox.restore();
+ await cleanup();
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_init.js b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_init.js
new file mode 100644
index 0000000000..f80fb7dfa5
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_init.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+
+function getRecipe(slug) {
+ return ExperimentFakes.recipe(slug, {
+ bucketConfig: {
+ start: 0,
+ // Make sure the experiment enrolls
+ count: 10000,
+ total: 10000,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ targeting: "!(experiment.slug in activeExperiments)",
+ });
+}
+
+add_task(async function test_double_feature_enrollment() {
+ let sandbox = sinon.createSandbox();
+ let sendFailureTelemetryStub = sandbox.stub(
+ ExperimentManager,
+ "sendFailureTelemetry"
+ );
+ await ExperimentAPI.ready();
+
+ Assert.ok(
+ ExperimentManager.store.getAllActiveExperiments().length === 0,
+ "Clean state"
+ );
+
+ let recipe1 = getRecipe("foo" + Math.random());
+ let recipe2 = getRecipe("foo" + Math.random());
+
+ let enrollPromise1 = ExperimentFakes.waitForExperimentUpdate(
+ ExperimentAPI,
+ recipe1.slug
+ );
+
+ ExperimentManager.enroll(recipe1, "test_double_feature_enrollment");
+ await enrollPromise1;
+ ExperimentManager.enroll(recipe2, "test_double_feature_enrollment");
+
+ Assert.equal(
+ ExperimentManager.store.getAllActiveExperiments().length,
+ 1,
+ "1 active experiment"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => sendFailureTelemetryStub.callCount === 1,
+ "Expected to fail one of the recipes"
+ );
+
+ Assert.equal(
+ sendFailureTelemetryStub.firstCall.args[0],
+ "enrollFailed",
+ "Check expected event"
+ );
+ Assert.ok(
+ sendFailureTelemetryStub.firstCall.args[1] === recipe1.slug ||
+ sendFailureTelemetryStub.firstCall.args[1] === recipe2.slug,
+ "Failed one of the two recipes"
+ );
+ Assert.equal(
+ sendFailureTelemetryStub.firstCall.args[2],
+ "feature-conflict",
+ "Check expected reason"
+ );
+
+ await ExperimentFakes.cleanupAll([recipe1.slug]);
+ sandbox.restore();
+});
diff --git a/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js
new file mode 100644
index 0000000000..a500fa2c81
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js
@@ -0,0 +1,584 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const {
+ _ExperimentFeature: ExperimentFeature,
+
+ ExperimentAPI,
+} = ChromeUtils.importESModule("resource://nimbus/ExperimentAPI.sys.mjs");
+const { ExperimentTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
+);
+
+const FOO_FAKE_FEATURE_MANIFEST = {
+ isEarlyStartup: true,
+ variables: {
+ remoteValue: {
+ type: "int",
+ },
+ enabled: {
+ type: "boolean",
+ },
+ },
+};
+
+const BAR_FAKE_FEATURE_MANIFEST = {
+ isEarlyStartup: true,
+ variables: {
+ remoteValue: {
+ type: "int",
+ },
+ enabled: {
+ type: "boolean",
+ },
+ },
+};
+
+const ENSURE_ENROLLMENT = {
+ targeting: "true",
+ bucketConfig: {
+ namespace: "nimbus-test-utils",
+ randomizationUnit: "normandy_id",
+ start: 0,
+ count: 1000,
+ total: 1000,
+ },
+};
+
+const REMOTE_CONFIGURATION_FOO = ExperimentFakes.recipe("foo-rollout", {
+ isRollout: true,
+ branches: [
+ {
+ slug: "foo-rollout-branch",
+ ratio: 1,
+ features: [
+ {
+ featureId: "foo",
+ isEarlyStartup: true,
+ value: { remoteValue: 42, enabled: true },
+ },
+ ],
+ },
+ ],
+ ...ENSURE_ENROLLMENT,
+});
+const REMOTE_CONFIGURATION_BAR = ExperimentFakes.recipe("bar-rollout", {
+ isRollout: true,
+ branches: [
+ {
+ slug: "bar-rollout-branch",
+ ratio: 1,
+ features: [
+ {
+ featureId: "bar",
+ isEarlyStartup: true,
+ value: { remoteValue: 3, enabled: true },
+ },
+ ],
+ },
+ ],
+ ...ENSURE_ENROLLMENT,
+});
+
+const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore.";
+
+add_setup(function () {
+ const client = RemoteSettings("nimbus-desktop-experiments");
+ sinon.stub(client, "get").resolves([]);
+
+ registerCleanupFunction(() => client.get.restore());
+});
+
+async function setup(configuration) {
+ const client = RemoteSettings("nimbus-desktop-experiments");
+ client.get.resolves(
+ configuration ?? [REMOTE_CONFIGURATION_FOO, REMOTE_CONFIGURATION_BAR]
+ );
+
+ // Simulate a state where no experiment exists.
+ const cleanup = () => client.get.resolves([]);
+ return { client, cleanup };
+}
+
+add_task(async function test_remote_fetch_and_ready() {
+ const fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
+ const barInstance = new ExperimentFeature("bar", BAR_FAKE_FEATURE_MANIFEST);
+
+ const cleanupTestFeatures = ExperimentTestUtils.addTestFeatures(
+ fooInstance,
+ barInstance
+ );
+
+ const sandbox = sinon.createSandbox();
+ const setExperimentActiveStub = sandbox.stub(
+ TelemetryEnvironment,
+ "setExperimentActive"
+ );
+ const setExperimentInactiveStub = sandbox.stub(
+ TelemetryEnvironment,
+ "setExperimentInactive"
+ );
+
+ Assert.equal(
+ fooInstance.getVariable("remoteValue"),
+ undefined,
+ "This prop does not exist before we sync"
+ );
+
+ // Create to promises that get resolved when the features update
+ // with the remote setting rollouts
+ let fooUpdate = new Promise(resolve => fooInstance.onUpdate(resolve));
+ let barUpdate = new Promise(resolve => barInstance.onUpdate(resolve));
+
+ await ExperimentAPI.ready();
+
+ let { cleanup } = await setup();
+
+ // Fake being initialized so we can update recipes
+ // we don't need to start any timers
+ RemoteSettingsExperimentLoader._initialized = true;
+ await RemoteSettingsExperimentLoader.updateRecipes(
+ "browser_rsel_remote_defaults"
+ );
+
+ // We need to await here because remote configurations are processed
+ // async to evaluate targeting
+ await Promise.all([fooUpdate, barUpdate]);
+
+ Assert.equal(
+ fooInstance.getVariable("remoteValue"),
+ REMOTE_CONFIGURATION_FOO.branches[0].features[0].value.remoteValue,
+ "`foo` feature is set by remote defaults"
+ );
+ Assert.equal(
+ barInstance.getVariable("remoteValue"),
+ REMOTE_CONFIGURATION_BAR.branches[0].features[0].value.remoteValue,
+ "`bar` feature is set by remote defaults"
+ );
+
+ Assert.ok(
+ Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`),
+ "Pref cache is set"
+ );
+
+ // Check if we sent active experiment data for defaults
+ Assert.equal(
+ setExperimentActiveStub.callCount,
+ 2,
+ "setExperimentActive called once per feature"
+ );
+
+ Assert.ok(
+ setExperimentActiveStub.calledWith(
+ REMOTE_CONFIGURATION_FOO.slug,
+ REMOTE_CONFIGURATION_FOO.branches[0].slug,
+ {
+ type: "nimbus-rollout",
+ enrollmentId: sinon.match.string,
+ }
+ ),
+ "should call setExperimentActive with `foo` feature"
+ );
+ Assert.ok(
+ setExperimentActiveStub.calledWith(
+ REMOTE_CONFIGURATION_BAR.slug,
+ REMOTE_CONFIGURATION_BAR.branches[0].slug,
+ {
+ type: "nimbus-rollout",
+ enrollmentId: sinon.match.string,
+ }
+ ),
+ "should call setExperimentActive with `bar` feature"
+ );
+
+ // Test Glean experiment API interaction
+ Assert.equal(
+ Services.fog.testGetExperimentData(REMOTE_CONFIGURATION_FOO.slug).branch,
+ REMOTE_CONFIGURATION_FOO.branches[0].slug,
+ "Glean.setExperimentActive called with `foo` feature"
+ );
+ Assert.equal(
+ Services.fog.testGetExperimentData(REMOTE_CONFIGURATION_BAR.slug).branch,
+ REMOTE_CONFIGURATION_BAR.branches[0].slug,
+ "Glean.setExperimentActive called with `bar` feature"
+ );
+
+ Assert.equal(fooInstance.getVariable("remoteValue"), 42, "Has rollout value");
+ Assert.equal(barInstance.getVariable("remoteValue"), 3, "Has rollout value");
+
+ // Clear RS db and load again. No configurations so should clear the cache.
+ await cleanup();
+ await RemoteSettingsExperimentLoader.updateRecipes(
+ "browser_rsel_remote_defaults"
+ );
+
+ Assert.ok(
+ !fooInstance.getVariable("remoteValue"),
+ "foo-rollout should be removed"
+ );
+ Assert.ok(
+ !barInstance.getVariable("remoteValue"),
+ "bar-rollout should be removed"
+ );
+
+ // Check if we sent active experiment data for defaults
+ Assert.equal(
+ setExperimentInactiveStub.callCount,
+ 2,
+ "setExperimentInactive called once per feature"
+ );
+
+ Assert.ok(
+ setExperimentInactiveStub.calledWith(REMOTE_CONFIGURATION_FOO.slug),
+ "should call setExperimentInactive with `foo` feature"
+ );
+ Assert.ok(
+ setExperimentInactiveStub.calledWith(REMOTE_CONFIGURATION_BAR.slug),
+ "should call setExperimentInactive with `bar` feature"
+ );
+
+ Assert.ok(
+ !Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
+ "Should clear the pref"
+ );
+ Assert.ok(!barInstance.getVariable("remoteValue"), "Should be missing");
+
+ ExperimentAPI._store._deleteForTests("foo");
+ ExperimentAPI._store._deleteForTests("bar");
+ ExperimentAPI._store._deleteForTests(REMOTE_CONFIGURATION_FOO.slug);
+ ExperimentAPI._store._deleteForTests(REMOTE_CONFIGURATION_BAR.slug);
+ sandbox.restore();
+
+ cleanupTestFeatures();
+ await cleanup();
+});
+
+add_task(async function test_remote_fetch_on_updateRecipes() {
+ let sandbox = sinon.createSandbox();
+ let updateRecipesStub = sandbox.stub(
+ RemoteSettingsExperimentLoader,
+ "updateRecipes"
+ );
+ // Work around the pref change callback that would trigger `setTimer`
+ sandbox.replaceGetter(
+ RemoteSettingsExperimentLoader,
+ "intervalInSeconds",
+ () => 1
+ );
+
+ // This will un-register the timer
+ RemoteSettingsExperimentLoader._initialized = true;
+ RemoteSettingsExperimentLoader.uninit();
+ Services.prefs.clearUserPref(
+ "app.update.lastUpdateTime.rs-experiment-loader-timer"
+ );
+
+ RemoteSettingsExperimentLoader.setTimer();
+
+ await BrowserTestUtils.waitForCondition(
+ () => updateRecipesStub.called,
+ "Wait for timer to call"
+ );
+
+ Assert.ok(updateRecipesStub.calledOnce, "Timer calls function");
+ Assert.equal(updateRecipesStub.firstCall.args[0], "timer", "Called by timer");
+ sandbox.restore();
+ // This will un-register the timer
+ RemoteSettingsExperimentLoader._initialized = true;
+ RemoteSettingsExperimentLoader.uninit();
+ Services.prefs.clearUserPref(
+ "app.update.lastUpdateTime.rs-experiment-loader-timer"
+ );
+});
+
+add_task(async function test_finalizeRemoteConfigs_cleanup() {
+ const featureFoo = new ExperimentFeature("foo", {
+ description: "mochitests",
+ variables: {
+ foo: { type: "boolean" },
+ },
+ });
+ const featureBar = new ExperimentFeature("bar", {
+ description: "mochitests",
+ variables: {
+ bar: { type: "boolean" },
+ },
+ });
+
+ const cleanupTestFeatures = ExperimentTestUtils.addTestFeatures(
+ featureFoo,
+ featureBar
+ );
+
+ let fooCleanup = await ExperimentFakes.enrollWithRollout(
+ {
+ featureId: "foo",
+ isEarlyStartup: true,
+ value: { foo: true },
+ },
+ {
+ source: "rs-loader",
+ }
+ );
+ await ExperimentFakes.enrollWithRollout(
+ {
+ featureId: "bar",
+ isEarlyStartup: true,
+ value: { bar: true },
+ },
+ {
+ source: "rs-loader",
+ }
+ );
+ let stubFoo = sinon.stub();
+ let stubBar = sinon.stub();
+ featureFoo.onUpdate(stubFoo);
+ featureBar.onUpdate(stubBar);
+ let cleanupPromise = new Promise(resolve => featureBar.onUpdate(resolve));
+
+ // stubFoo and stubBar will be called because the store is ready. We are not interested in these calls.
+ // Reset call history and check calls stats after cleanup.
+ Assert.ok(
+ stubFoo.called,
+ "feature foo update triggered becuase store is ready"
+ );
+ Assert.ok(
+ stubBar.called,
+ "feature bar update triggered because store is ready"
+ );
+ stubFoo.resetHistory();
+ stubBar.resetHistory();
+
+ Services.prefs.setStringPref(
+ `${SYNC_DEFAULTS_PREF_BRANCH}foo`,
+ JSON.stringify({ foo: true, branch: { feature: { featureId: "foo" } } })
+ );
+ Services.prefs.setStringPref(
+ `${SYNC_DEFAULTS_PREF_BRANCH}bar`,
+ JSON.stringify({ bar: true, branch: { feature: { featureId: "bar" } } })
+ );
+
+ const remoteConfiguration = {
+ ...REMOTE_CONFIGURATION_FOO,
+ branches: [
+ {
+ ...REMOTE_CONFIGURATION_FOO.branches[0],
+ features: [
+ {
+ ...REMOTE_CONFIGURATION_FOO.branches[0].features[0],
+ value: {
+ foo: true,
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const { cleanup } = await setup([remoteConfiguration]);
+ RemoteSettingsExperimentLoader._initialized = true;
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await cleanupPromise;
+
+ Assert.ok(
+ stubFoo.notCalled,
+ "Not called, not enrolling in rollout feature already exists"
+ );
+ Assert.ok(stubBar.called, "Called because no recipe is seen, cleanup");
+ Assert.ok(
+ Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}foo`),
+ "Pref is not cleared"
+ );
+ Assert.ok(
+ !Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
+ "Pref was cleared"
+ );
+
+ await fooCleanup();
+ // This will also remove the inactive recipe from the store
+ // the previous update (from recipe not seen code path)
+ // only sets the recipe as inactive
+ ExperimentAPI._store._deleteForTests("bar-rollout");
+ ExperimentAPI._store._deleteForTests("foo-rollout");
+
+ cleanupTestFeatures();
+ cleanup();
+});
+
+// If the remote config data returned from the store is not modified
+// this test should not throw
+add_task(async function remote_defaults_no_mutation() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(ExperimentAPI._store, "getRolloutForFeature").returns(
+ Cu.cloneInto(
+ {
+ featureIds: ["foo"],
+ branch: {
+ features: [{ featureId: "foo", value: { remoteStub: true } }],
+ },
+ },
+ {},
+ { deepFreeze: true }
+ )
+ );
+
+ let fooInstance = new ExperimentFeature("foo", FOO_FAKE_FEATURE_MANIFEST);
+ let config = fooInstance.getAllVariables();
+
+ Assert.ok(config.remoteStub, "Got back the expected value");
+
+ sandbox.restore();
+});
+
+add_task(async function remote_defaults_active_remote_defaults() {
+ ExperimentAPI._store._deleteForTests("foo");
+ ExperimentAPI._store._deleteForTests("bar");
+ let barFeature = new ExperimentFeature("bar", {
+ description: "mochitest",
+ variables: { enabled: { type: "boolean" } },
+ });
+ let fooFeature = new ExperimentFeature("foo", {
+ description: "mochitest",
+ variables: { enabled: { type: "boolean" } },
+ });
+
+ const cleanupTestFeatures = ExperimentTestUtils.addTestFeatures(
+ barFeature,
+ fooFeature
+ );
+
+ let rollout1 = ExperimentFakes.recipe("bar", {
+ branches: [
+ {
+ slug: "bar-rollout-branch",
+ ratio: 1,
+ features: [
+ {
+ featureId: "bar",
+ value: { enabled: true },
+ },
+ ],
+ },
+ ],
+ isRollout: true,
+ ...ENSURE_ENROLLMENT,
+ targeting: "true",
+ });
+
+ let rollout2 = ExperimentFakes.recipe("foo", {
+ branches: [
+ {
+ slug: "foo-rollout-branch",
+ ratio: 1,
+ features: [
+ {
+ featureId: "foo",
+ value: { enabled: true },
+ },
+ ],
+ },
+ ],
+ isRollout: true,
+ ...ENSURE_ENROLLMENT,
+ targeting: "'bar' in activeRollouts",
+ });
+
+ // Order is important, rollout2 won't match at first
+ const { cleanup } = await setup([rollout2, rollout1]);
+ let updatePromise = new Promise(resolve => barFeature.onUpdate(resolve));
+ RemoteSettingsExperimentLoader._initialized = true;
+ await RemoteSettingsExperimentLoader.updateRecipes("mochitest");
+
+ await updatePromise;
+
+ Assert.ok(barFeature.getVariable("enabled"), "Enabled on first sync");
+ Assert.ok(!fooFeature.getVariable("enabled"), "Targeting doesn't match");
+
+ let featureUpdate = new Promise(resolve => fooFeature.onUpdate(resolve));
+ await RemoteSettingsExperimentLoader.updateRecipes("mochitest");
+ await featureUpdate;
+
+ Assert.ok(fooFeature.getVariable("enabled"), "Targeting should match");
+ ExperimentAPI._store._deleteForTests("foo");
+ ExperimentAPI._store._deleteForTests("bar");
+
+ cleanup();
+ cleanupTestFeatures();
+});
+
+add_task(async function remote_defaults_variables_storage() {
+ let barFeature = new ExperimentFeature("bar", {
+ description: "mochitest",
+ variables: {
+ enabled: {
+ type: "boolean",
+ },
+ storage: {
+ type: "int",
+ },
+ object: {
+ type: "json",
+ },
+ string: {
+ type: "string",
+ },
+ bool: {
+ type: "boolean",
+ },
+ },
+ });
+ let rolloutValue = {
+ storage: 42,
+ object: { foo: "foo" },
+ string: "string",
+ bool: true,
+ enabled: true,
+ };
+
+ let doCleanup = await ExperimentFakes.enrollWithRollout({
+ featureId: "bar",
+ isEarlyStartup: true,
+ value: rolloutValue,
+ });
+
+ Assert.ok(
+ Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar`, ""),
+ "Experiment stored in prefs"
+ );
+ Assert.ok(
+ Services.prefs.getIntPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar.storage`, 0),
+ "Stores variable in separate pref"
+ );
+ Assert.equal(
+ Services.prefs.getIntPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar.storage`, 0),
+ 42,
+ "Stores variable in correct type"
+ );
+ Assert.deepEqual(
+ barFeature.getAllVariables(),
+ rolloutValue,
+ "Test types are returned correctly"
+ );
+
+ await doCleanup();
+
+ Assert.equal(
+ Services.prefs.getIntPref(`${SYNC_DEFAULTS_PREF_BRANCH}bar.storage`, -1),
+ -1,
+ "Variable pref is cleared"
+ );
+ Assert.ok(!barFeature.getVariable("string"), "Variable is no longer defined");
+ ExperimentAPI._store._deleteForTests("bar");
+ ExperimentAPI._store._deleteForTests("bar-rollout");
+});
diff --git a/toolkit/components/nimbus/test/browser/head.js b/toolkit/components/nimbus/test/browser/head.js
new file mode 100644
index 0000000000..f8a91df3d5
--- /dev/null
+++ b/toolkit/components/nimbus/test/browser/head.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Globals
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
+ ExperimentTestUtils: "resource://testing-common/NimbusTestUtils.sys.mjs",
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+});
+
+add_setup(function () {
+ let sandbox = sinon.createSandbox();
+
+ /* We stub the functions that operate with enrollments and remote rollouts
+ * so that any access to store something is implicitly validated against
+ * the schema and no records have missing (or extra) properties while in tests
+ */
+
+ let origAddExperiment = ExperimentManager.store.addEnrollment.bind(
+ ExperimentManager.store
+ );
+ sandbox
+ .stub(ExperimentManager.store, "addEnrollment")
+ .callsFake(enrollment => {
+ ExperimentTestUtils.validateEnrollment(enrollment);
+ return origAddExperiment(enrollment);
+ });
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});