summaryrefslogtreecommitdiffstats
path: root/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js')
-rw-r--r--toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js584
1 files changed, 584 insertions, 0 deletions
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");
+});