tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 9b32a29ee6bd3a64aec22f1dfd399ca8a6d3cce9
parent 5a5d32606016abf413fe9091dce3d2e90777938b
Author: Beth Rennie <beth@brennie.ca>
Date:   Fri,  9 Jan 2026 21:10:30 +0000

Bug 2003350 - Add a separate opt-out for rollouts r=nimbus-reviewers,relud

Differential Revision: https://phabricator.services.mozilla.com/D277367

Diffstat:
Mbrowser/app/profile/firefox.js | 4++++
Mtoolkit/components/nimbus/ExperimentAPI.sys.mjs | 91++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mtoolkit/components/nimbus/lib/ExperimentManager.sys.mjs | 33++++++++++++++++++++++++++++-----
Mtoolkit/components/nimbus/lib/Migrations.sys.mjs | 10++++++++++
Mtoolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs | 35+++++++++++++++++++++++++++++++----
Mtoolkit/components/nimbus/lib/Telemetry.sys.mjs | 1+
Mtoolkit/components/nimbus/test/NimbusTestUtils.sys.mjs | 12+++++++++++-
Mtoolkit/components/nimbus/test/unit/test_ExperimentManager_unenroll.js | 202++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mtoolkit/components/nimbus/test/unit/test_Migrations.js | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
9 files changed, 373 insertions(+), 175 deletions(-)

diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -1744,6 +1744,7 @@ pref("services.sync.prefs.sync.media.eme.enabled", true); pref("services.sync.prefs.sync-seen.media.eme.enabled", false); pref("services.sync.prefs.sync.media.videocontrols.picture-in-picture.video-toggle.enabled", true); pref("services.sync.prefs.sync.network.cookie.cookieBehavior", true); +pref("services.sync.prefs.sync.nimbus.rollouts.enabled", true); pref("services.sync.prefs.sync.permissions.default.image", true); pref("services.sync.prefs.sync.pref.downloads.disable_button.edit_actions", true); pref("services.sync.prefs.sync.pref.privacy.disable_button.cookie_exceptions", true); @@ -2164,6 +2165,9 @@ pref("nimbus.profilesdatastoreservice.sync.enabled", false); pref("nimbus.telemetry.targetingContextEnabled", true); #endif +// Enable Rollouts by default. +pref("nimbus.rollouts.enabled", true); + // Nimbus QA prefs. Used to monitor pref-setting test experiments. pref("nimbus.qa.pref-1", "default"); pref("nimbus.qa.pref-2", "default"); diff --git a/toolkit/components/nimbus/ExperimentAPI.sys.mjs b/toolkit/components/nimbus/ExperimentAPI.sys.mjs @@ -37,6 +37,7 @@ const CRASHREPORTER_ENABLED = const IS_MAIN_PROCESS = Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; +const ROLLOUTS_ENABLED_PREF = "nimbus.rollouts.enabled"; const UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled"; const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; @@ -148,6 +149,13 @@ export const ExperimentAPI = new (class { */ #prefValues = { /** + * Whether or not rollouts are enabled. + * + * @see {@link ROLLOUTS_ENABLED_PREF} + */ + rolloutsEnabled: false, + + /** * Whether or not opt-out studies are enabled. * * @see {@link STUDIES_OPT_OUT_PREF} @@ -162,6 +170,11 @@ export const ExperimentAPI = new (class { telemetryEnabled: false, }; + /** + * Whether or not studies are enabled. + * + * @see {@link studiesEnabled} + */ #studiesEnabled = false; constructor() { @@ -183,7 +196,7 @@ export const ExperimentAPI = new (class { } } - this._onStudiesEnabledChanged = this._onStudiesEnabledChanged.bind(this); + this._onEnabledPrefChange = this._onEnabledPrefChange.bind(this); this._annotateCrashReport = this._annotateCrashReport.bind(this); this._removeCrashReportAnnotator = this._removeCrashReportAnnotator.bind(this); @@ -194,8 +207,7 @@ export const ExperimentAPI = new (class { } /** - * The topic that is notified when either the studies enabled pref or the - * telemetry enabled pref changes. + * The topic that is notified when the Nimbus enabled state changes. * * Consumers can listen for notifications on this topic to react to * Nimbus being enabled or disabled. @@ -233,7 +245,6 @@ export const ExperimentAPI = new (class { // state to change during ExperimentAPI initialization, but we do not // register our observers until the end of this function. this.#computeEnabled(); - const studiesEnabled = this.studiesEnabled; try { await lazy.NimbusMigrations.applyMigrations( @@ -248,6 +259,8 @@ export const ExperimentAPI = new (class { ); } + this.#computeEnabled(); + try { await this.manager.store.init(); } catch (e) { @@ -300,19 +313,18 @@ export const ExperimentAPI = new (class { } Services.prefs.addObserver( - STUDIES_OPT_OUT_PREF, - this._onStudiesEnabledChanged - ); - Services.prefs.addObserver( - UPLOAD_ENABLED_PREF, - this._onStudiesEnabledChanged + ROLLOUTS_ENABLED_PREF, + this._onEnabledPrefChange ); + Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this._onEnabledPrefChange); + Services.prefs.addObserver(UPLOAD_ENABLED_PREF, this._onEnabledPrefChange); // If Nimbus was disabled between the start of this function and registering // the pref observers we have not handled it yet. - if (studiesEnabled !== this.studiesEnabled) { - await this._onStudiesEnabledChanged(); - } + // + // If the enabled state hasn't actually changed, calling this function is a + // no-op. + await this._onEnabledPrefChange(); return true; } @@ -368,18 +380,26 @@ export const ExperimentAPI = new (class { this.#experimentManager = null; Services.prefs.removeObserver( + ROLLOUTS_ENABLED_PREF, + this._onEnabledPrefChange + ); + Services.prefs.removeObserver( STUDIES_OPT_OUT_PREF, - this._onStudiesEnabledChanged + this._onEnabledPrefChange ); Services.prefs.removeObserver( UPLOAD_ENABLED_PREF, - this._onStudiesEnabledChanged + this._onEnabledPrefChange ); this.#initialized = false; } #computeEnabled() { + this.#prefValues.rolloutsEnabled = Services.prefs.getBoolPref( + ROLLOUTS_ENABLED_PREF, + false + ); this.#prefValues.studiesEnabled = Services.prefs.getBoolPref( STUDIES_OPT_OUT_PREF, false @@ -396,13 +416,17 @@ export const ExperimentAPI = new (class { } get enabled() { - return this.studiesEnabled || this.labsEnabled; + return this.labsEnabled || this.rolloutsEnabled || this.studiesEnabled; } get labsEnabled() { return Services.policies.isAllowed("FirefoxLabs"); } + get rolloutsEnabled() { + return this.#prefValues.rolloutsEnabled; + } + get studiesEnabled() { return this.#studiesEnabled; } @@ -472,31 +496,36 @@ export const ExperimentAPI = new (class { } } - async _onStudiesEnabledChanged(_topic, _subject, prefName) { - const studiesPreviouslyEnabled = this.studiesEnabled; - - switch (prefName) { - case STUDIES_OPT_OUT_PREF: - case UPLOAD_ENABLED_PREF: - this.#computeEnabled(); - break; - - default: - return; - } - + /** + * Handle a pref change that may result in Nimbus being enabled or disabled. + */ + async _onEnabledPrefChange() { if (!this.#initialized) { return; } - if (studiesPreviouslyEnabled !== this.studiesEnabled) { + const studiesPreviouslyEnabled = this.studiesEnabled; + const rolloutsPreviouslyEnabled = this.rolloutsEnabled; + + this.#computeEnabled(); + + const studiesEnabledChanged = + studiesPreviouslyEnabled !== this.studiesEnabled; + const rolloutsEnabledChanged = + rolloutsPreviouslyEnabled !== this.rolloutsEnabled; + + if (studiesEnabledChanged || rolloutsEnabledChanged) { if (!this.studiesEnabled) { this.manager._handleStudiesOptOut(); } + if (!this.rolloutsEnabled) { + this.manager._handleRolloutsOptOut(); + } + // Labs is disabled only by policy, so it cannot be disabled at runtime. // Thus we only need to notify the RemoteSettingsExperimentLoader when - // studies become enabled or disabled. + // studies or rollouts become enabled or disabled. await this._rsLoader.onEnabledPrefChange(); Services.obs.notifyObservers(null, this.STUDIES_ENABLED_CHANGED); diff --git a/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs @@ -330,14 +330,18 @@ export class ExperimentManager { this._prefFlips.init(); - if (!lazy.ExperimentAPI.studiesEnabled) { - this._handleStudiesOptOut(); - } - if (!lazy.ExperimentAPI.labsEnabled) { this._handleLabsDisabled(); } + if (!lazy.ExperimentAPI.rolloutsEnabled) { + this._handleRolloutsOptOut(); + } + + if (!lazy.ExperimentAPI.studiesEnabled) { + this._handleStudiesOptOut(); + } + lazy.NimbusFeatures.nimbusTelemetry.onUpdate(() => { // Providing default values ensure we disable metrics when unenrolling. const cfg = { @@ -971,6 +975,7 @@ export class ExperimentManager { [ UnenrollReason.BUCKETING, UnenrollReason.TARGETING_MISMATCH, + UnenrollReason.ROLLOUTS_OPT_OUT, UnenrollReason.STUDIES_OPT_OUT, ].includes(enrollment.unenrollReason) ) { @@ -1067,12 +1072,30 @@ export class ExperimentManager { } /** + * Unenroll from all active rollouts if user opts out. + */ + _handleRolloutsOptOut() { + const enrollments = this.store + .getAll() + .filter(e => e.active && !e.isFirefoxLabsOptIn && e.isRollout); + + for (const enrollment of enrollments) { + this._unenroll( + enrollment, + UnenrollmentCause.fromReason( + lazy.NimbusTelemetry.UnenrollReason.ROLLOUTS_OPT_OUT + ) + ); + } + } + + /** * Unenroll from all active studies if user opts out. */ _handleStudiesOptOut() { const enrollments = this.store .getAll() - .filter(e => e.active && !e.isFirefoxLabsOptIn); + .filter(e => e.active && !e.isFirefoxLabsOptIn && !e.isRollout); for (const enrollment of enrollments) { this._unenroll( diff --git a/toolkit/components/nimbus/lib/Migrations.sys.mjs b/toolkit/components/nimbus/lib/Migrations.sys.mjs @@ -100,6 +100,15 @@ function migrateMultiphase() { } } +/** + * Disable rollouts if the user has previously opted out of studies. + */ +function migrateSeparateRolloutOptOut() { + if (!Services.prefs.getBoolPref("app.shield.optoutstudies.enabled")) { + Services.prefs.setBoolPref("nimbus.rollouts.enabled", false); + } +} + function migrateNoop() { // This migration intentionally left blank. // @@ -477,6 +486,7 @@ export const NimbusMigrations = { MIGRATIONS: { [Phase.INIT_STARTED]: [ migration("multi-phase-migrations", migrateMultiphase), + migration("separate-rollout-opt-out", migrateSeparateRolloutOptOut), ], [Phase.AFTER_STORE_INITIALIZED]: [ diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs @@ -119,6 +119,27 @@ export const MatchStatus = Object.freeze({ DISABLED: "DISABLED", }); +const DeliveryKind = Object.freeze({ + FIREFOX_LABS_OPT_IN: "firefox-labs-opt-in", + ROLLOUT: "rollout", + STUDY: "study", +}); + +/** + * @returns {DeliveryKind} + */ +function getDeliveryKind(recipe) { + if (recipe.isFirefoxLabsOptIn) { + return DeliveryKind.FIREFOX_LABS_OPT_IN; + } + + if (recipe.isRollout) { + return DeliveryKind.ROLLOUT; + } + + return DeliveryKind.STUDY; +} + export const CheckRecipeResult = { Ok(status) { return { @@ -435,6 +456,7 @@ export class RemoteSettingsExperimentLoader { { validationEnabled, labsEnabled: lazy.ExperimentAPI.labsEnabled, + rolloutsEnabled: lazy.ExperimentAPI.rolloutsEnabled, studiesEnabled: lazy.ExperimentAPI.studiesEnabled, shouldCheckTargeting: true, unenrolledExperimentSlugs, @@ -931,16 +953,18 @@ export class EnrollmentsContext { validationEnabled = true, shouldCheckTargeting = true, unenrolledExperimentSlugs, - studiesEnabled = true, labsEnabled = true, + rolloutsEnabled = true, + studiesEnabled = true, } = {} ) { this.manager = manager; this.recipeValidator = recipeValidator; this.validationEnabled = validationEnabled; - this.studiesEnabled = studiesEnabled; this.labsEnabled = labsEnabled; + this.rolloutsEnabled = rolloutsEnabled; + this.studiesEnabled = studiesEnabled; this.validatorCache = {}; this.shouldCheckTargeting = shouldCheckTargeting; @@ -975,9 +999,12 @@ export class EnrollmentsContext { } } + const deliveryKind = getDeliveryKind(recipe); if ( - (recipe.isFirefoxLabsOptIn && !this.labsEnabled) || - (!recipe.isFirefoxLabsOptIn && !this.studiesEnabled) + (deliveryKind === DeliveryKind.FIREFOX_LABS_OPT_IN && + !this.labsEnabled) || + (deliveryKind === DeliveryKind.ROLLOUT && !this.rolloutsEnabled) || + (deliveryKind === DeliveryKind.STUDY && !this.studiesEnabled) ) { return CheckRecipeResult.Ok(MatchStatus.DISABLED); } diff --git a/toolkit/components/nimbus/lib/Telemetry.sys.mjs b/toolkit/components/nimbus/lib/Telemetry.sys.mjs @@ -92,6 +92,7 @@ const UnenrollReason = Object.freeze({ PREF_VARIABLE_MISSING: "pref-variable-missing", PREF_VARIABLE_NO_LONGER: "pref-variable-no-longer", RECIPE_NOT_SEEN: "recipe-not-seen", + ROLLOUTS_OPT_OUT: "rollouts-opt-out", STUDIES_OPT_OUT: "studies-opt-out", TARGETING_MISMATCH: "targeting-mismatch", UNENROLLED_IN_ANOTHER_PROFILE: "unenrolled-in-another-profile", diff --git a/toolkit/components/nimbus/test/NimbusTestUtils.sys.mjs b/toolkit/components/nimbus/test/NimbusTestUtils.sys.mjs @@ -563,13 +563,23 @@ export const NimbusTestUtils = { }); }, + get SEPARATE_ROLLOUT_OPT_OUT() { + const { Phase } = lazy.NimbusMigrations; + + return NimbusTestUtils.makeMigrationState({ + [Phase.INIT_STARTED]: "separate-rollout-opt-out", + [Phase.AFTER_STORE_INITIALIZED]: "graduate-firefox-labs-auto-pip", + [Phase.AFTER_REMOTE_SETTINGS_UPDATE]: "firefox-labs-enrollments", + }); + }, + /** * A migration state that represents all migrations applied. * * @type {Record<Phase, number>} */ get LATEST() { - return NimbusTestUtils.migrationState.GRADUATED_FIREFOX_LABS_AUTO_PIP; + return NimbusTestUtils.migrationState.SEPARATE_ROLLOUT_OPT_OUT; }, }, diff --git a/toolkit/components/nimbus/test/unit/test_ExperimentManager_unenroll.js b/toolkit/components/nimbus/test/unit/test_ExperimentManager_unenroll.js @@ -3,7 +3,12 @@ const { TelemetryEnvironment } = ChromeUtils.importESModule( "resource://gre/modules/TelemetryEnvironment.sys.mjs" ); -const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; +const { FirefoxLabs } = ChromeUtils.importESModule( + "resource://nimbus/FirefoxLabs.sys.mjs" +); + +const ROLLOUTS_ENABLED_PREF = "nimbus.rollouts.enabled"; +const STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled"; const UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled"; add_setup(function test_setup() { @@ -35,14 +40,44 @@ add_task(async function test_set_inactive() { await cleanup(); }); -add_task(async function test_unenroll_opt_out() { - Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); +async function doOptOutTest({ + rolloutsEnabled = true, + studiesEnabled = true, +} = {}) { + Services.prefs.setBoolPref(ROLLOUTS_ENABLED_PREF, true); + Services.prefs.setBoolPref(STUDIES_ENABLED_PREF, true); + + const recipes = { + experiment: NimbusTestUtils.factories.recipe.withFeatureConfig( + "experiment", + { featureId: "no-feature-firefox-desktop" } + ), + rollout: NimbusTestUtils.factories.recipe.withFeatureConfig( + "rollout", + { featureId: "no-feature-firefox-desktop" }, + { isRollout: true } + ), + optin: NimbusTestUtils.factories.recipe.withFeatureConfig( + "optin", + { featureId: "no-feature-firefox-desktop" }, + { + isRollout: true, + isFirefoxLabsOptIn: true, + firefoxLabsTitle: "title", + firefoxLabsDescription: "description", + firefoxLabsDescriptionLinks: null, + firefoxLabsGroup: "group", + requiresRestart: false, + } + ), + }; - const { manager, cleanup } = await setupTest(); - const experiment = NimbusTestUtils.factories.recipe.withFeatureConfig("foo", { - featureId: "testFeature", + const { manager, cleanup } = await setupTest({ + experiments: Object.values(recipes), }); - await manager.enroll(experiment, "test"); + + const labs = await FirefoxLabs.create(); + await labs.enroll(recipes.optin.slug, "control"); // Check that there aren't any Glean normandy unenrollNimbusExperiment events yet Assert.equal( @@ -58,109 +93,88 @@ add_task(async function test_unenroll_opt_out() { "no Glean unenrollment events before unenrollment" ); - Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, false); + Services.prefs.setBoolPref(ROLLOUTS_ENABLED_PREF, rolloutsEnabled); + Services.prefs.setBoolPref(STUDIES_ENABLED_PREF, studiesEnabled); - await NimbusTestUtils.assert.enrollmentExists(experiment.slug, { - active: false, + await NimbusTestUtils.assert.enrollmentExists(recipes.experiment.slug, { + active: studiesEnabled, }); - Assert.equal( - manager.store.get(experiment.slug).active, - false, - "should set .active to false" + manager.store.get(recipes.experiment.slug).active, + studiesEnabled ); - // We expect only one event and that that one event matches the expected enrolled experiment - Assert.deepEqual( + await NimbusTestUtils.assert.enrollmentExists(recipes.rollout.slug, { + active: rolloutsEnabled, + }); + Assert.equal(manager.store.get(recipes.rollout.slug).active, rolloutsEnabled); + + await NimbusTestUtils.assert.enrollmentExists(recipes.optin.slug, { + active: true, + }); + Assert.ok(manager.store.get(recipes.optin.slug).active); + + const normandyEvents = Glean.normandy.unenrollNimbusExperiment .testGetValue("events") - .map(ev => ev.extra), - [ - { - value: experiment.slug, - branch: experiment.branches[0].slug, - reason: "studies-opt-out", - }, - ] - ); + ?.map(ev => ev.extra) ?? []; + const nimbusEvents = + Glean.nimbusEvents.unenrollment + .testGetValue("events") + ?.map(ev => ev.extra) ?? []; + + const unenrolledSlugs = []; + if (!rolloutsEnabled) { + unenrolledSlugs.push(recipes.rollout.slug); + } + if (!studiesEnabled) { + unenrolledSlugs.push(recipes.experiment.slug); + } - // We expect only one event and that that one event matches the expected enrolled experiment Assert.deepEqual( - Glean.nimbusEvents.unenrollment.testGetValue("events").map(ev => ev.extra), - [ - { - experiment: experiment.slug, - branch: experiment.branches[0].slug, - reason: "studies-opt-out", - }, - ] + normandyEvents, + unenrolledSlugs.map(slug => ({ + value: slug, + branch: "control", + reason: recipes[slug].isRollout ? "rollouts-opt-out" : "studies-opt-out", + })), + "Expected normandy unenroll events" ); + Assert.deepEqual( + nimbusEvents, + unenrolledSlugs.map(slug => ({ + experiment: slug, + branch: "control", + reason: recipes[slug].isRollout ? "rollouts-opt-out" : "studies-opt-out", + })), + "Expected nimbus unenroll events" + ); + + if (rolloutsEnabled) { + manager.unenroll(recipes.rollout.slug, { reason: "test" }); + } + if (studiesEnabled) { + manager.unenroll(recipes.experiment.slug, { reason: "test" }); + } + + labs.unenroll(recipes.optin.slug); await cleanup(); - Services.prefs.clearUserPref(STUDIES_OPT_OUT_PREF); -}); - -add_task(async function test_unenroll_rollout_opt_out() { - Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); - - const { manager, cleanup } = await setupTest(); - const rollout = NimbusTestUtils.factories.recipe("foo", { isRollout: true }); - await manager.enroll(rollout, "test"); - // Check that there aren't any Glean normandy unenrollNimbusExperiment events yet - Assert.equal( - Glean.normandy.unenrollNimbusExperiment.testGetValue("events"), - undefined, - "no Glean normandy unenrollNimbusExperiment events before unenrollment" - ); - - // Check that there aren't any Glean unenrollment events yet - Assert.equal( - Glean.nimbusEvents.unenrollment.testGetValue("events"), - undefined, - "no Glean unenrollment events before unenrollment" - ); - - Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, false); - - await NimbusTestUtils.assert.enrollmentExists(rollout.slug, { - active: false, - }); - - Assert.equal( - manager.store.get(rollout.slug).active, - false, - "should set .active to false" - ); + Services.prefs.clearUserPref(ROLLOUTS_ENABLED_PREF); + Services.prefs.clearUserPref(STUDIES_ENABLED_PREF); +} - // We expect only one event and that that one event matches the expected enrolled experiment - Assert.deepEqual( - Glean.normandy.unenrollNimbusExperiment - .testGetValue("events") - .map(ev => ev.extra), - [ - { - value: rollout.slug, - branch: rollout.branches[0].slug, - reason: "studies-opt-out", - }, - ] - ); +add_task(async function testUnenrollStudiesOptOut() { + await doOptOutTest({ studiesEnabled: false }); +}); - // We expect only one event and that that one event matches the expected enrolled experiment - Assert.deepEqual( - Glean.nimbusEvents.unenrollment.testGetValue("events").map(ev => ev.extra), - [ - { - experiment: rollout.slug, - branch: rollout.branches[0].slug, - reason: "studies-opt-out", - }, - ] - ); +add_task(async function testUnenrollRolloutOptOut() { + await doOptOutTest({ rolloutsEnabled: false }); +}); - await cleanup(); - Services.prefs.clearUserPref(STUDIES_OPT_OUT_PREF); +add_task(async function testUnenrollAllOptOut() { + await doOptOutTest({ rolloutsEnabled: false, studiesEnabled: false }); }); add_task(async function test_unenroll_uploadPref() { diff --git a/toolkit/components/nimbus/test/unit/test_Migrations.js b/toolkit/components/nimbus/test/unit/test_Migrations.js @@ -1649,46 +1649,46 @@ add_task(async function testGraduateFirefoxLabsAutoPip() { Services.prefs.setBoolPref(ENABLED_PREF, true); - let cleanup, manager; - await GleanPings.nimbusTargetingContext.testSubmission( - () => { - Assert.deepEqual( - Glean.nimbusEvents.enrollmentStatus - .testGetValue("nimbus-targeting-context") - .map(event => event.extra), - [ - { - slug: "firefox-labs-auto-pip", - branch: "control", - status: "WasEnrolled", - reason: "Migration", - migration: "graduate-firefox-labs-auto-pip", + const { cleanup, initExperimentAPI, manager } = + await NimbusTestUtils.setupTest({ + clearTelemetry: true, + init: false, + storePath: await NimbusTestUtils.createStoreWith(store => { + NimbusTestUtils.addEnrollmentForRecipe(recipe, { + store, + extra: { + prefs: [ + { + name: ENABLED_PREF, + featureId: "auto-pip", + variable: "enabled", + branch: "user", + originalValue: false, + }, + ], }, - ] - ); - }, - async () => - ({ cleanup, manager } = await NimbusTestUtils.setupTest({ - storePath: await NimbusTestUtils.createStoreWith(store => { - NimbusTestUtils.addEnrollmentForRecipe(recipe, { - store, - extra: { - prefs: [ - { - name: ENABLED_PREF, - featureId: "auto-pip", - variable: "enabled", - branch: "user", - originalValue: false, - }, - ], - }, - }); - }), - migrationState: - NimbusTestUtils.migrationState.IMPORTED_ENROLLMENTS_TO_SQL, - })) - ); + }); + }), + migrationState: + NimbusTestUtils.migrationState.IMPORTED_ENROLLMENTS_TO_SQL, + }); + + await GleanPings.nimbusTargetingContext.testSubmission(() => { + Assert.deepEqual( + Glean.nimbusEvents.enrollmentStatus + .testGetValue("nimbus-targeting-context") + .map(event => event.extra), + [ + { + slug: "firefox-labs-auto-pip", + branch: "control", + status: "WasEnrolled", + reason: "Migration", + migration: "graduate-firefox-labs-auto-pip", + }, + ] + ); + }, initExperimentAPI); const enrollment = manager.store.get(SLUG); @@ -1706,8 +1706,12 @@ add_task(async function testGraduateFirefoxLabsAutoPip() { Glean.nimbusEvents.migration.testGetValue().map(event => event.extra), [ { + migration_id: "separate-rollout-opt-out", success: "true", + }, + { migration_id: "graduate-firefox-labs-auto-pip", + success: "true", }, ] ); @@ -1728,6 +1732,82 @@ add_task(async function testGraduateFirefoxLabsAutoPip() { Services.prefs.setBoolPref(ENABLED_PREF, false); await cleanup(); +}); + +add_task(async function testSeparateRolloutOptOut() { + const STUDIES_PREF = "app.shield.optoutstudies.enabled"; + const TELEMETRY_PREF = "datareporting.healthreport.uploadEnabled"; + const ROLLOUT_PREF = "nimbus.rollouts.enabled"; + + const rollout = NimbusTestUtils.factories.recipe.withFeatureConfig( + "test-rollout", + { featureId: "no-feature-firefox-desktop" }, + { isRollout: true } + ); + + for (const studiesEnabled of [true, false]) { + for (const telemetryEnabled of [true, false]) { + info( + `testSeparateRolloutOptOut: studiesEnabled=${studiesEnabled} telemetryEnabled=${telemetryEnabled}\n` + ); + Services.prefs.setBoolPref(STUDIES_PREF, studiesEnabled); + Services.prefs.setBoolPref(TELEMETRY_PREF, telemetryEnabled); - Services.fog.testResetFOG(); + const { manager, cleanup } = await NimbusTestUtils.setupTest({ + migrationState: + NimbusTestUtils.migrationState.GRADUATED_FIREFOX_LABS_AUTO_PIP, + experiments: [rollout], + clearTelemetry: true, + }); + + Assert.deepEqual( + Glean.nimbusEvents.migration + .testGetValue("events") + .map(event => event.extra), + [ + { + migration_id: "separate-rollout-opt-out", + success: "true", + }, + ] + ); + + Assert.equal( + Services.prefs.getBoolPref(ROLLOUT_PREF), + studiesEnabled, + "Rollout pref matches expected value" + ); + Assert.equal( + ExperimentAPI.rolloutsEnabled, + studiesEnabled, + "Rollouts enable status correct" + ); + + const activeEnrollments = manager.store + .getAll() + .filter(e => e.active) + .map(e => e.slug); + + if (studiesEnabled) { + Assert.deepEqual( + activeEnrollments, + ["test-rollout"], + "Should have enrolled in rollout" + ); + manager.unenroll("test-rollout", "test-cleanup"); + } else { + Assert.deepEqual( + activeEnrollments, + [], + "Should not have enrolled in rollout" + ); + } + + await cleanup(); + } + + Services.prefs.clearUserPref(STUDIES_PREF); + Services.prefs.clearUserPref(TELEMETRY_PREF); + Services.prefs.clearUserPref(ROLLOUT_PREF); + } });