diff options
Diffstat (limited to 'toolkit/components/nimbus/test/browser')
12 files changed, 1645 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..c3667e04e7 --- /dev/null +++ b/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js @@ -0,0 +1,113 @@ +"use strict"; + +const { RemoteSettingsExperimentLoader } = ChromeUtils.import( + "resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm" +); +const { ExperimentManager } = ChromeUtils.import( + "resource://nimbus/lib/ExperimentManager.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["messaging-system.log", "all"], + ["app.shield.optoutstudies.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + }); +}); + +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( + RemoteSettingsExperimentLoader.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 RemoteSettingsExperimentLoader.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 RemoteSettingsExperimentLoader.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 RemoteSettingsExperimentLoader.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 RemoteSettingsExperimentLoader.evaluateJexl( + `"${slug}" in activeExperiments`, + FAKE_CONTEXT + ), + true, + "should find an active experiment" + ); + + Assert.equal( + await RemoteSettingsExperimentLoader.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..17e821019f --- /dev/null +++ b/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); +const { ExperimentAPI, NimbusFeatures } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); + +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..92993a9771 --- /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.import( + "resource://nimbus/lib/ExperimentStore.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); +const { ExperimentFeatures } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); +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..7a7dc4fe47 --- /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.import( + "resource://nimbus/lib/ExperimentStore.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); +const { NimbusFeatures, ExperimentAPI } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); +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..9197a78dad --- /dev/null +++ b/toolkit/components/nimbus/test/browser/browser_nimbus_telemetry.js @@ -0,0 +1,159 @@ +"use strict"; + +const { + ExperimentAPI, + _ExperimentFeature: ExperimentFeature, +} = ChromeUtils.import("resource://nimbus/ExperimentAPI.jsm"); +const { ExperimentManager } = ChromeUtils.import( + "resource://nimbus/lib/ExperimentManager.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); +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..7e63618a24 --- /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, NimbusFeatures } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); +const { ExperimentManager } = ChromeUtils.import( + "resource://nimbus/lib/ExperimentManager.jsm" +); + +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..10143a74cb --- /dev/null +++ b/toolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js @@ -0,0 +1,115 @@ +"use strict"; + +const { RemoteSettings } = ChromeUtils.import( + "resource://services-settings/remote-settings.js" +); +const { RemoteSettingsExperimentLoader } = ChromeUtils.import( + "resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm" +); +const { ExperimentAPI } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); +const { ExperimentManager } = ChromeUtils.import( + "resource://nimbus/lib/ExperimentManager.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); + +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, + { slug: 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, + { slug: 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..584b595dac --- /dev/null +++ b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_force_enrollment.js @@ -0,0 +1,147 @@ +/* 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 { RemoteSettingsExperimentLoader } = ChromeUtils.import( + "resource://nimbus/lib/RemoteSettingsExperimentLoader.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({}, Date.now(), recipes, { + clear: true, + }); + + await BrowserTestUtils.waitForCondition( + async () => (await client.get()).length, + "RS is ready" + ); + + registerCleanupFunction(async () => { + await client.db.clear(); + }); + + return client; +} + +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").returns(true); + let recipes = [ExperimentFakes.recipe("slug123")]; + + 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); + + const 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); + + 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(); +}); + +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); + + 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(); +}); + +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("slug_fetch_recipe")]; + + await setup(recipes); + let result = await RemoteSettingsExperimentLoader.optInToExperiment({ + slug: "slug_fetch_recipe", + 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(); +}); 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..b2479ae48b --- /dev/null +++ b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_init.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); +const { ExperimentManager } = ChromeUtils.import( + "resource://nimbus/lib/ExperimentManager.jsm" +); +const { ExperimentAPI } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); + +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.getAllActive().length === 0, "Clean state"); + + let recipe1 = getRecipe("foo" + Math.random()); + let recipe2 = getRecipe("foo" + Math.random()); + + let enrollPromise1 = ExperimentFakes.waitForExperimentUpdate(ExperimentAPI, { + slug: recipe1.slug, + }); + + ExperimentManager.enroll(recipe1, "test_double_feature_enrollment"); + await enrollPromise1; + ExperimentManager.enroll(recipe2, "test_double_feature_enrollment"); + + Assert.equal( + ExperimentManager.store.getAllActive().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..211657660c --- /dev/null +++ b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js @@ -0,0 +1,573 @@ +/* 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 { + _ExperimentFeature: ExperimentFeature, + NimbusFeatures, + ExperimentAPI, +} = ChromeUtils.import("resource://nimbus/ExperimentAPI.jsm"); +const { ExperimentTestUtils } = ChromeUtils.import( + "resource://testing-common/NimbusTestUtils.jsm" +); +const { ExperimentManager } = ChromeUtils.import( + "resource://nimbus/lib/ExperimentManager.jsm" +); +const { RemoteSettingsExperimentLoader } = ChromeUtils.import( + "resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm" +); +const { TelemetryEnvironment } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryEnvironment.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."; + +async function setup(configuration) { + const client = RemoteSettings("nimbus-desktop-experiments"); + await client.db.importChanges( + {}, + Date.now(), + configuration || [REMOTE_CONFIGURATION_FOO, REMOTE_CONFIGURATION_BAR], + { + clear: true, + } + ); + + // Simulate a state where no experiment exists. + const cleanup = () => + client.db.importChanges({}, Date.now(), [], { clear: true }); + 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)); + + 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..c63d7ab1dd --- /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.import("resource://testing-common/Sinon.jsm"); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +XPCOMUtils.defineLazyModuleGetters(this, { + ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm", + ExperimentTestUtils: "resource://testing-common/NimbusTestUtils.jsm", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.jsm", +}); + +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(async enrollment => { + await ExperimentTestUtils.validateEnrollment(enrollment); + return origAddExperiment(enrollment); + }); + + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); |