commit 7addb3e109535c9af6873ff2d4f160e299f57038
parent 39384659753b236c9e233587b932bd90d02ce980
Author: Beth Rennie <beth@brennie.ca>
Date: Mon, 27 Oct 2025 19:27:52 +0000
Bug 1972647 - Allow Firefox Labs to be enabled even if studies or telemetry are disabled r=mkaply,nimbus-reviewers,relud
Both Firefox Labs and one of either studies or telemetry must now be
disabled to fully disable Nimbus.
Differential Revision: https://phabricator.services.mozilla.com/D269920
Diffstat:
7 files changed, 185 insertions(+), 57 deletions(-)
diff --git a/browser/components/enterprisepolicies/Policies.sys.mjs b/browser/components/enterprisepolicies/Policies.sys.mjs
@@ -2924,6 +2924,10 @@ export var Policies = {
);
}
if ("FirefoxLabs" in param) {
+ if (!param.FirefoxLabs) {
+ manager.disallowFeature("FirefoxLabs");
+ }
+
PoliciesUtils.setDefaultPref(
"browser.preferences.experimental",
param.FirefoxLabs,
diff --git a/toolkit/components/nimbus/ExperimentAPI.sys.mjs b/toolkit/components/nimbus/ExperimentAPI.sys.mjs
@@ -370,6 +370,14 @@ export const ExperimentAPI = new (class {
this.#initialized = false;
}
+ get enabled() {
+ return this.studiesEnabled || this.labsEnabled;
+ }
+
+ get labsEnabled() {
+ return Services.policies.isAllowed("FirefoxLabs");
+ }
+
get studiesEnabled() {
return (
this.#prefValues.studiesEnabled &&
@@ -441,7 +449,7 @@ export const ExperimentAPI = new (class {
}
if (!this.studiesEnabled) {
- await this.manager._handleStudiesOptOut();
+ this.manager._handleStudiesOptOut();
}
await this._rsLoader.onEnabledPrefChange();
diff --git a/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs
@@ -308,10 +308,6 @@ export class ExperimentManager {
return;
}
- if (result.ok && recipe.isFirefoxLabsOptIn) {
- this.optInRecipes.push(recipe);
- }
-
if (!result.ok) {
lazy.NimbusTelemetry.recordEnrollmentStatus({
slug: recipe.slug,
@@ -322,8 +318,15 @@ export class ExperimentManager {
return;
}
+ // Unenrollment due to studies becoming disabled is handled in
+ // `_handleStudiesOptOut`.
+ if (result.status === lazy.MatchStatus.DISABLED) {
+ return;
+ }
+
if (recipe.isFirefoxLabsOptIn) {
// We do not enroll directly into Firefox Labs opt-ins.
+ this.optInRecipes.push(recipe);
return;
}
@@ -749,8 +752,17 @@ export class ExperimentManager {
const { EnrollmentStatus, EnrollmentStatusReason, UnenrollReason } =
lazy.NimbusTelemetry;
- if (result.ok && recipe?.isFirefoxLabsOptIn) {
- this.optInRecipes.push(recipe);
+ if (result.ok) {
+ // Unenrollment due to studies becoming disabled is handled in
+ // `_handleStudiesOptOut`. Firefox Labs can only be disabled by policy and
+ // thus its enabled state cannot change at runtime.
+ if (result.status === lazy.MatchStatus.DISABLED) {
+ return false;
+ }
+
+ if (recipe?.isFirefoxLabsOptIn) {
+ this.optInRecipes.push(recipe);
+ }
}
if (enrollment.active) {
@@ -925,8 +937,10 @@ export class ExperimentManager {
/**
* Unenroll from all active studies if user opts out.
*/
- async _handleStudiesOptOut() {
- for (const enrollment of this.store.getAllActiveExperiments()) {
+ _handleStudiesOptOut() {
+ for (const enrollment of this.store
+ .getAll()
+ .filter(e => e.active && !e.isFirefoxLabsOptIn)) {
this._unenroll(
enrollment,
UnenrollmentCause.fromReason(
@@ -934,16 +948,6 @@ export class ExperimentManager {
)
);
}
- for (const enrollment of this.store.getAllActiveRollouts()) {
- this._unenroll(
- enrollment,
- UnenrollmentCause.fromReason(
- lazy.NimbusTelemetry.UnenrollReason.STUDIES_OPT_OUT
- )
- );
- }
-
- this.optInRecipes = [];
}
/**
diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs
@@ -114,6 +114,7 @@ export const MatchStatus = Object.freeze({
TARGETING_ONLY: "TARGETING_ONLY",
TARGETING_AND_BUCKETING: "TARGETING_AND_BUCKETING",
UNENROLLED_IN_ANOTHER_PROFILE: "UNENROLLED_IN_ANOTHER_PROFILE",
+ DISABLED: "DISABLED",
});
export const CheckRecipeResult = {
@@ -261,9 +262,9 @@ export class RemoteSettingsExperimentLoader {
return;
}
- if (!lazy.ExperimentAPI.studiesEnabled) {
+ if (!lazy.ExperimentAPI.enabled) {
lazy.log.debug(
- "Not enabling RemoteSettingsExperimentLoader: studies disabled"
+ "Not enabling RemoteSettingsExperimentLoader: Nimbus disabled"
);
return;
}
@@ -431,6 +432,8 @@ export class RemoteSettingsExperimentLoader {
recipeValidator,
{
validationEnabled,
+ labsEnabled: lazy.ExperimentAPI.labsEnabled,
+ studiesEnabled: lazy.ExperimentAPI.studiesEnabled,
shouldCheckTargeting: true,
unenrolledExperimentSlugs,
}
@@ -768,15 +771,15 @@ export class RemoteSettingsExperimentLoader {
}
/**
- * Handles feature status based on STUDIES_OPT_OUT_PREF.
- *
- * Changing this pref to false will turn off any recipe fetching and
- * processing.
+ * Disable the RemoteSettingsExperimentLoader if Nimbus has become disabled
+ * and vice versa.
*/
async onEnabledPrefChange() {
- if (this._enabled && !lazy.ExperimentAPI.studiesEnabled) {
+ const nimbusEnabled = lazy.ExperimentAPI.enabled;
+
+ if (this._enabled && !nimbusEnabled) {
this.disable();
- } else if (!this._enabled && lazy.ExperimentAPI.studiesEnabled) {
+ } else if (!this._enabled && nimbusEnabled) {
// If the feature pref is turned on then turn on recipe processing.
// If the opt in pref is turned on then turn on recipe processing only if
// the feature pref is also enabled.
@@ -823,7 +826,7 @@ export class RemoteSettingsExperimentLoader {
* immediately.
*/
finishedUpdating() {
- if (!lazy.ExperimentAPI.studiesEnabled || !this._enabled) {
+ if (!lazy.ExperimentAPI.enabled || !this._enabled) {
return Promise.resolve();
}
@@ -912,12 +915,17 @@ export class EnrollmentsContext {
validationEnabled = true,
shouldCheckTargeting = true,
unenrolledExperimentSlugs,
+ studiesEnabled = true,
+ labsEnabled = true,
} = {}
) {
this.manager = manager;
this.recipeValidator = recipeValidator;
this.validationEnabled = validationEnabled;
+ this.studiesEnabled = studiesEnabled;
+ this.labsEnabled = labsEnabled;
+
this.validatorCache = {};
this.shouldCheckTargeting = shouldCheckTargeting;
this.unenrolledExperimentSlugs = unenrolledExperimentSlugs;
@@ -951,6 +959,13 @@ export class EnrollmentsContext {
}
}
+ if (
+ (recipe.isFirefoxLabsOptIn && !this.labsEnabled) ||
+ (!recipe.isFirefoxLabsOptIn && !this.studiesEnabled)
+ ) {
+ return CheckRecipeResult.Ok(MatchStatus.DISABLED);
+ }
+
// We don't include missing features here because if validation is enabled we report those errors later.
const unsupportedFeatureIds = recipe.featureIds.filter(
featureId =>
diff --git a/toolkit/components/nimbus/lib/Telemetry.sys.mjs b/toolkit/components/nimbus/lib/Telemetry.sys.mjs
@@ -81,6 +81,7 @@ const UnenrollReason = Object.freeze({
CHANGED_PREF: "changed-pref",
FORCE_ENROLLMENT: "force-enrollment",
INDIVIDUAL_OPT_OUT: "individual-opt-out",
+ LABS_DIABLED: "labs-disabled",
LABS_OPT_OUT: "labs-opt-out",
PREF_FLIPS_CONFLICT: "prefFlips-conflict",
PREF_FLIPS_FAILED: "prefFlips-failed",
diff --git a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js
@@ -63,17 +63,21 @@ add_task(async function test_init_with_opt_in() {
await initExperimentAPI();
- equal(
+ Assert.equal(
loader.setTimer.callCount,
- 0,
- `should not initialize if ${STUDIES_OPT_OUT_PREF} pref is false`
+ 1,
+ `should initialize even if ${STUDIES_OPT_OUT_PREF} pref is false`
);
- Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true);
- Assert.ok(loader.setTimer.calledOnce, "should call .setTimer");
- Assert.ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
+ Assert.equal(
+ loader.updateRecipes.callCount,
+ 1,
+ "should call updateRecipes()"
+ );
await cleanup();
+
+ Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true);
});
add_task(async function test_updateRecipes() {
diff --git a/toolkit/components/nimbus/test/unit/test_policy.js b/toolkit/components/nimbus/test/unit/test_policy.js
@@ -8,18 +8,43 @@ const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
"resource://testing-common/EnterprisePolicyTesting.sys.mjs"
);
+const RECIPES = [
+ NimbusTestUtils.factories.recipe.withFeatureConfig("experiment", {
+ featureId: "no-feature-firefox-desktop",
+ }),
+ NimbusTestUtils.factories.recipe.withFeatureConfig(
+ "rollout",
+ { featureId: "no-feature-firefox-desktop" },
+ { isRollout: true }
+ ),
+ NimbusTestUtils.factories.recipe.withFeatureConfig(
+ "optin",
+ { featureId: "no-feature-firefox-desktop" },
+ {
+ isRollout: true,
+ isFirefoxLabsOptIn: true,
+ firefoxLabsTitle: "title",
+ firefoxLabsDescription: "description",
+ firefoxLabsGroup: "group",
+ requiresRestart: false,
+ }
+ ),
+];
+
add_setup(function setup() {
// Instantiate the enterprise policy service.
void Cc["@mozilla.org/enterprisepolicies;1"].getService(Ci.nsIObserver);
});
-add_task(async function testPolicyDisablesNimbus() {
+async function doTest({
+ policies,
+ labsEnabled,
+ studiesEnabled,
+ expectedEnrollments,
+ expectedOptIns,
+}) {
info("Enabling policy");
- await EnterprisePolicyTesting.setupPolicyEngineWithJson({
- policies: {
- DisableFirefoxStudies: true,
- },
- });
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({ policies });
info("Is policy engine active?");
Assert.equal(
@@ -28,28 +53,95 @@ add_task(async function testPolicyDisablesNimbus() {
"Policy engine is active"
);
- const loader = NimbusTestUtils.stubs.rsLoader();
- const manager = loader.manager;
- await manager.store.init();
- await manager.onStartup();
+ const { initExperimentAPI, cleanup, loader } =
+ await NimbusTestUtils.setupTest({
+ init: false,
+ experiments: RECIPES,
+ });
- Assert.ok(!manager.studiesEnabled, "ExperimentManager is disabled");
+ sinon.spy(loader, "updateRecipes");
+ sinon.spy(loader, "setTimer");
- const setTimerStub = sinon.stub(loader, "setTimer");
- const updateRecipes = sinon.stub(loader, "updateRecipes");
+ await initExperimentAPI();
- await loader.enable();
+ Assert.equal(
+ ExperimentAPI.studiesEnabled,
+ studiesEnabled,
+ "Studies are enabled"
+ );
+ Assert.equal(
+ ExperimentAPI.labsEnabled,
+ labsEnabled,
+ "FirefoxLabs is enabled"
+ );
+
+ Assert.equal(
+ loader._enabled,
+ studiesEnabled || labsEnabled,
+ "RemoteSettingsExperimentLoader initialized"
+ );
+
+ Assert.equal(
+ loader.setTimer.called,
+ studiesEnabled || labsEnabled,
+ "RemoteSettingsExperimentLoader polling for recipes"
+ );
- Assert.ok(
- !loader._initialized,
- "RemoteSettingsExperimentLoader not initailized"
+ Assert.equal(
+ loader.updateRecipes.called,
+ studiesEnabled || labsEnabled,
+ "RemoteSettingsExperimentLoader polling for recipes"
);
- Assert.ok(
- setTimerStub.notCalled,
- "RemoteSettingsExperimentLoader not polling for recipes"
+
+ Assert.deepEqual(
+ ExperimentAPI.manager.store
+ .getAll()
+ .filter(e => e.active)
+ .map(e => e.slug)
+ .sort(),
+ expectedEnrollments.sort(),
+ "Should have expected enrollments"
);
- Assert.ok(
- updateRecipes.notCalled,
- "RemoteSettingsExperimentLoader not updating recipes after startup"
+
+ Assert.deepEqual(
+ ExperimentAPI.manager.optInRecipes.map(e => e.slug).sort(),
+ expectedOptIns,
+ "Should have expected available opt-ins"
);
+
+ await NimbusTestUtils.cleanupManager(expectedEnrollments);
+ await cleanup();
+}
+
+add_task(async function testDisableStudiesPolicy() {
+ await doTest({
+ policies: { DisableFirefoxStudies: true },
+ labsEnabled: true,
+ studiesEnabled: false,
+ expectedEnrollments: [],
+ expectedOptIns: ["optin"],
+ });
+});
+
+add_task(async function testDisableLabsPolicy() {
+ await doTest({
+ policies: { UserMessaging: { FirefoxLabs: false } },
+ labsEnabled: false,
+ studiesEnabled: true,
+ expectedEnrollments: ["experiment", "rollout"],
+ expectedOptIns: [],
+ });
+});
+
+add_task(async function testNimbusDisabled() {
+ await doTest({
+ policies: {
+ DisableFirefoxStudies: true,
+ UserMessaging: { FirefoxLabs: false },
+ },
+ labsEnabled: false,
+ studiesEnabled: false,
+ expectedEnrollments: [],
+ expectedOptIns: [],
+ });
});