From 33480d77174fea5ede5d492c166f83268888b28f Mon Sep 17 00:00:00 2001 From: Beth Rennie Date: Tue, 5 May 2026 10:41:46 -0400 Subject: [PATCH] Bug 2045566 - Add Firefox Labs APIs --- CHANGELOG.md | 1 + .../org/mozilla/experiments/nimbus/Nimbus.kt | 66 +++ .../experiments/nimbus/NimbusInterface.kt | 27 + .../mozilla/experiments/nimbus/NimbusTests.kt | 1 + components/nimbus/src/enrollment.rs | 80 ++- components/nimbus/src/metrics.rs | 2 +- components/nimbus/src/nimbus.udl | 51 +- components/nimbus/src/schema.rs | 41 ++ components/nimbus/src/stateful/dbcache.rs | 114 +++++ components/nimbus/src/stateful/enrollment.rs | 183 ++++++- .../nimbus/src/stateful/firefox_labs.rs | 50 ++ components/nimbus/src/stateful/mod.rs | 1 + .../nimbus/src/stateful/nimbus_client.rs | 62 ++- components/nimbus/src/tests/helpers.rs | 12 +- .../src/tests/stateful/test_gecko_prefs.rs | 9 +- .../nimbus/src/tests/stateful/test_nimbus.rs | 478 +++++++++++++++++- .../nimbus/src/tests/test_enrollment.rs | 31 +- components/nimbus/src/tests/test_schema.rs | 128 +++++ 18 files changed, 1285 insertions(+), 52 deletions(-) create mode 100644 components/nimbus/src/stateful/firefox_labs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b32e031e..270ae6b455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - There are new separate DisqualifiedReason and NotEnrolledReason for global (experiment and rollout) opt-outs. ([#7400](https://github.com/mozilla/application-services/pull/7400)) - The Kotlin client now has per-feature update notifications. ([#7354](https://github.com/mozilla/application-services/pull/7354)) - The Nimbus client now understands Firefox Labs opt-ins and will not automatically enroll in them. ([#7403](https://github.com/mozilla/application-services/pull/7403)) +- Firefox Labs support for Android. ([#7413](https://github.com/mozilla/application-services/pull/7413)) [Full Changelog](In progress) diff --git a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt index 20e459dbb2..d3abbc8a7d 100644 --- a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt +++ b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt @@ -41,6 +41,9 @@ import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEventType import org.mozilla.experiments.nimbus.internal.EnrollmentStatusExtraDef import org.mozilla.experiments.nimbus.internal.FeatureExposureExtraDef import org.mozilla.experiments.nimbus.internal.FeatureUpdateDispatcher +import org.mozilla.experiments.nimbus.internal.FirefoxLabsEnrollStatus +import org.mozilla.experiments.nimbus.internal.FirefoxLabsMetadata +import org.mozilla.experiments.nimbus.internal.FirefoxLabsUnenrollStatus import org.mozilla.experiments.nimbus.internal.GeckoPrefHandler import org.mozilla.experiments.nimbus.internal.GeckoPrefState import org.mozilla.experiments.nimbus.internal.MalformedFeatureConfigExtraDef @@ -702,6 +705,69 @@ open class Nimbus( nimbusClient.recordMalformedFeatureConfig(featureId, partId) } + override fun getAvailableFirefoxLabs(): Deferred> { + return dbScope.async { getAvailableFirefoxLabsOnThisThread() } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getAvailableFirefoxLabsOnThisThread(): List { + return withCatchAll("getAvailableFirefoxLabs") { + nimbusClient.getAvailableFirefoxLabs() + } ?: emptyList() + } + + override fun enrollInFirefoxLab(slug: String): Deferred { + return dbScope.async { enrollInFirefoxLabOnThisThread(slug) } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun enrollInFirefoxLabOnThisThread(slug: String): FirefoxLabsEnrollStatus { + return withCatchAll("enrollInFirefoxLab") { + val result = nimbusClient.enrollInFirefoxLab(slug) + if (result.enrollmentChangeEvents.isNotEmpty()) { + recordExperimentTelemetryEvents(result.enrollmentChangeEvents) + postEnrolmentCalculation() + updateDispatcher.notifyChanged(result.enrollmentChangeEvents) + } + + result.status + } ?: FirefoxLabsEnrollStatus.ERROR + } + + override fun unenrollFromFirefoxLab(slug: String): Deferred { + return dbScope.async { unenrollFromFirefoxLabOnThisThread(slug) } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun unenrollFromFirefoxLabOnThisThread(slug: String): FirefoxLabsUnenrollStatus { + return withCatchAll("unenrollFromFirefoxLab") { + val result = nimbusClient.unenrollFromFirefoxLab(slug) + if (result.enrollmentChangeEvents.isNotEmpty()) { + recordExperimentTelemetryEvents(result.enrollmentChangeEvents) + postEnrolmentCalculation() + updateDispatcher.notifyChanged(result.enrollmentChangeEvents) + } + + result.status + } ?: FirefoxLabsUnenrollStatus.ERROR + } + + override fun unenrollFromAllFirefoxLabs() { + dbScope.launch { unenrollFromAllFirefoxLabsOnThisThread() } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun unenrollFromAllFirefoxLabsOnThisThread() { + withCatchAll("unenrollFromAllFirefoxLabs") { + val enrollmentChangeEvents = nimbusClient.unenrollFromAllFirefoxLabs() + if (enrollmentChangeEvents.isNotEmpty()) { + recordExperimentTelemetryEvents(enrollmentChangeEvents) + postEnrolmentCalculation() + updateDispatcher.notifyChanged(enrollmentChangeEvents) + } + } + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun buildExperimentContext( context: Context, diff --git a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusInterface.kt b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusInterface.kt index 1ca93fc389..e793a8307f 100644 --- a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusInterface.kt +++ b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusInterface.kt @@ -17,7 +17,11 @@ import org.mozilla.experiments.nimbus.internal.AvailableExperiment import org.mozilla.experiments.nimbus.internal.EnrolledExperiment import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent import org.mozilla.experiments.nimbus.internal.ExperimentBranch +import org.mozilla.experiments.nimbus.internal.FirefoxLabsEnrollStatus +import org.mozilla.experiments.nimbus.internal.FirefoxLabsMetadata +import org.mozilla.experiments.nimbus.internal.FirefoxLabsUnenrollStatus import org.mozilla.experiments.nimbus.internal.GeckoPrefState +import org.mozilla.experiments.nimbus.internal.NimbusException import org.mozilla.experiments.nimbus.internal.PrefUnenrollReason import org.mozilla.experiments.nimbus.internal.PreviousGeckoPrefState import java.time.Duration @@ -208,6 +212,29 @@ interface NimbusInterface : FeaturesInterface, NimbusMessagingInterface, NimbusE geckoPrefStates: List, ) = Unit + /** + * Return the list of available Firefox Labs opt-ins. + */ + fun getAvailableFirefoxLabs(): Deferred> = + CompletableDeferred(emptyList()) + + /** + * Attempt to enroll in a Firefox Labs opt-in. + */ + fun enrollInFirefoxLab(slug: String): Deferred = + CompletableDeferred(FirefoxLabsEnrollStatus.NO_EXPERIMENT) + + /** + * Attempt to unenroll from a Firefox Labs opt-in. + */ + fun unenrollFromFirefoxLab(slug: String): Deferred = + CompletableDeferred(FirefoxLabsUnenrollStatus.NO_EXPERIMENT) + + /** + * Attempt to unenroll from all Firefox Labs. + */ + fun unenrollFromAllFirefoxLabs() = Unit + /** * Reset internal state in response to application-level telemetry reset. * Consumers should call this method when the user resets the telemetry state of the diff --git a/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt b/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt index 3e8ee97d23..d4f9a37fec 100644 --- a/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt +++ b/components/nimbus/android/src/test/java/org/mozilla/experiments/nimbus/NimbusTests.kt @@ -137,6 +137,7 @@ class NimbusTests { branchSlug = "test-branch", userFacingDescription = "A test experiment for testing experiments", userFacingName = "Test Experiment", + isRollout = false, ), ) diff --git a/components/nimbus/src/enrollment.rs b/components/nimbus/src/enrollment.rs index 1ccc92392c..3619117f12 100644 --- a/components/nimbus/src/enrollment.rs +++ b/components/nimbus/src/enrollment.rs @@ -30,7 +30,8 @@ pub enum EnrolledReason { Qualified, /// Explicit opt-in. OptIn, - /// Opted-in via Firefox Labs + #[cfg(feature = "stateful")] + /// Opt-in via Firefox Labs. FirefoxLabsOptIn, } @@ -40,6 +41,7 @@ impl Display for EnrolledReason { match self { EnrolledReason::Qualified => "Qualified", EnrolledReason::OptIn => "OptIn", + #[cfg(feature = "stateful")] EnrolledReason::FirefoxLabsOptIn => "FirefoxLabsOptIn", }, f, @@ -72,6 +74,7 @@ pub enum NotEnrolledReason { RolloutsOptOut, /// This is a Firefox Labs opt-in and we have not opted-in. + #[cfg(feature = "stateful")] FirefoxLabs, /// This state represents several cases: @@ -105,6 +108,7 @@ impl Display for NotEnrolledReason { NotEnrolledReason::NotTargeted => "NotTargeted", NotEnrolledReason::ExperimentsOptOut => "ExperimentsOptOut", NotEnrolledReason::RolloutsOptOut => "RolloutsOptOut", + #[cfg(feature = "stateful")] NotEnrolledReason::FirefoxLabs => "FirefoxLabs", NotEnrolledReason::OptOut => "OptOut", }, @@ -138,9 +142,9 @@ pub enum DisqualifiedReason { Error, /// The user opted-out from this experiment. OptOut, - /// The user opted-out from all rollouts. + /// The user opted-out from all experiments. ExperimentsOptOut, - /// The user reset their telemetry identifiers. + // The user opted-out from all rollouts. RolloutsOptOut, /// The targeting has changed for an experiment. NotTargeted, @@ -148,7 +152,11 @@ pub enum DisqualifiedReason { NotSelected, /// A pref used in the experiment was set by the user. #[cfg(feature = "stateful")] - PrefUnenrollReason { reason: PrefUnenrollReason }, + PrefUnenrollReason { + reason: PrefUnenrollReason, + }, + #[cfg(feature = "stateful")] + FirefoxLabsOptOut, } impl Display for DisqualifiedReason { @@ -166,12 +174,35 @@ impl Display for DisqualifiedReason { PrefUnenrollReason::Changed => "PrefChanged", PrefUnenrollReason::FailedToSet => "PrefFailedToSet", }, + #[cfg(feature = "stateful")] + DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut", }, f, ) } } +impl DisqualifiedReason { + // TODO(bug 2046987): Unify with Display impl? + fn for_enrollment_change_event(&self) -> &'static str { + match self { + DisqualifiedReason::NotSelected => "bucketing", + DisqualifiedReason::NotTargeted => "targeting", + DisqualifiedReason::OptOut => "optout", + DisqualifiedReason::ExperimentsOptOut => "experiments-opt-out", + DisqualifiedReason::RolloutsOptOut => "rollouts-opt-out", + DisqualifiedReason::Error => "error", + #[cfg(feature = "stateful")] + DisqualifiedReason::PrefUnenrollReason { reason } => match reason { + PrefUnenrollReason::Changed => "pref_changed", + PrefUnenrollReason::FailedToSet => "pref_failed_to_set", + }, + #[cfg(feature = "stateful")] + DisqualifiedReason::FirefoxLabsOptOut => "FirefoxLabsOptOut", + } + } +} + // The previous state of a Gecko pref before enrollment took place. // ⚠️ Attention : Changes to this type should be accompanied by a new test ⚠️ // ⚠️ in `src/stateful/tests/test_enrollment_bw_compat.rs` below, and may require a DB migration. ⚠️ @@ -290,6 +321,7 @@ impl ExperimentEnrollment { pub(crate) fn from_explicit_opt_in( experiment: &Experiment, branch_slug: &str, + reason: EnrolledReason, out_enrollment_events: &mut Vec, ) -> Result { if !experiment.has_branch(branch_slug) { @@ -308,7 +340,7 @@ impl ExperimentEnrollment { } let enrollment = Self { slug: experiment.slug.clone(), - status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug), + status: EnrollmentStatus::new_enrolled(reason, branch_slug), }; out_enrollment_events.push(enrollment.get_change_event(Some(experiment))); Ok(enrollment) @@ -545,6 +577,7 @@ impl ExperimentEnrollment { &self, experiment: Option<&Experiment>, out_enrollment_events: &mut Vec, + reason: DisqualifiedReason, #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>, ) -> ExperimentEnrollment { match self.status { @@ -552,7 +585,7 @@ impl ExperimentEnrollment { #[cfg(feature = "stateful")] self.maybe_revert_all_gecko_pref_states(gecko_pref_store); - let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut); + let enrollment = self.disqualify_from_enrolled(reason); out_enrollment_events.push(enrollment.get_change_event(experiment)); enrollment } @@ -654,9 +687,16 @@ impl ExperimentEnrollment { ) -> Self { let updated = match self.status { EnrollmentStatus::Enrolled { .. } => { - let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut); - out_enrollment_events.push(disqualified.get_change_event(experiment)); - disqualified + if let Some(experiment) = experiment + && experiment.is_firefox_labs_opt_in + { + // Firefox Labs is unrelated to telemetry. + self.clone() + } else { + let disqualified = self.disqualify_from_enrolled(DisqualifiedReason::OptOut); + out_enrollment_events.push(disqualified.get_change_event(experiment)); + disqualified + } } EnrollmentStatus::NotEnrolled { .. } | EnrollmentStatus::Disqualified { .. } @@ -707,19 +747,7 @@ impl ExperimentEnrollment { EnrollmentStatus::Disqualified { branch, reason, .. } => EnrollmentChangeEvent::new( &self.slug, branch, - match reason { - DisqualifiedReason::NotSelected => Some("bucketing"), - DisqualifiedReason::NotTargeted => Some("targeting"), - DisqualifiedReason::OptOut => Some("optout"), - DisqualifiedReason::ExperimentsOptOut => Some("experiments-opt-out"), - DisqualifiedReason::RolloutsOptOut => Some("rollouts-opt-out"), - DisqualifiedReason::Error => Some("error"), - #[cfg(feature = "stateful")] - DisqualifiedReason::PrefUnenrollReason { reason } => match reason { - PrefUnenrollReason::Changed => Some("pref_changed"), - PrefUnenrollReason::FailedToSet => Some("pref_failed_to_set"), - }, - }, + Some(reason.for_enrollment_change_event()), EnrollmentChangeEventType::Disqualification, experiment, ), @@ -866,6 +894,14 @@ impl EnrollmentStatus { pub fn is_enrolled(&self) -> bool { matches!(self, EnrollmentStatus::Enrolled { .. }) } + + pub fn is_enrolled_with_reason(&self, expected_reason: EnrolledReason) -> bool { + matches!( + self, + EnrollmentStatus::Enrolled { reason, ..} + if *reason == expected_reason + ) + } } pub(crate) trait ExperimentMetadata { diff --git a/components/nimbus/src/metrics.rs b/components/nimbus/src/metrics.rs index fec6b21aa8..ab97ddd3ec 100644 --- a/components/nimbus/src/metrics.rs +++ b/components/nimbus/src/metrics.rs @@ -12,7 +12,7 @@ pub use crate::metrics::detail::*; use crate::enrollment::NotEnrolledReason; #[derive(Serialize, Deserialize, Clone)] -#[cfg_attr(test, derive(Debug))] +#[cfg_attr(test, derive(Debug, Default, Eq, PartialEq))] pub struct EnrollmentStatusExtraDef { pub branch: Option, pub conflict_slug: Option, diff --git a/components/nimbus/src/nimbus.udl b/components/nimbus/src/nimbus.udl index cd3b8464a3..00ae2fbae3 100644 --- a/components/nimbus/src/nimbus.udl +++ b/components/nimbus/src/nimbus.udl @@ -53,6 +53,7 @@ dictionary EnrolledExperiment { string user_facing_name; string user_facing_description; string branch_slug; + boolean is_rollout; }; dictionary AvailableExperiment { @@ -225,7 +226,6 @@ interface RecordedContext { void record(); }; - interface NimbusClient { [Throws=NimbusError] constructor( @@ -413,8 +413,57 @@ interface NimbusClient { [Throws=NimbusError] sequence? get_previous_gecko_pref_states(string experiment_slug); + + [Throws=NimbusError] + sequence get_available_firefox_labs(); + + [Throws=NimbusError] + FirefoxLabsEnrollResult enroll_in_firefox_lab([ByRef] string slug); + + [Throws=NimbusError] + FirefoxLabsUnenrollResult unenroll_from_firefox_lab([ByRef] string slug); + + [Throws=NimbusError] + sequence unenroll_from_all_firefox_labs(); }; +dictionary FirefoxLabsMetadata { + string slug; + string title_string_id; + string description_string_id; + string? connect_url; + boolean enrolled; + boolean requires_restart; +}; + +enum FirefoxLabsEnrollStatus { + "Enrolled", + "AlreadyEnrolled", + "NoExperiment", + "NotFirefoxLabsOptIn", + "FeatureConflict", + "Error", +}; + +dictionary FirefoxLabsEnrollResult { + FirefoxLabsEnrollStatus status; + sequence enrollment_change_events; +}; + +enum FirefoxLabsUnenrollStatus { + "Unenrolled", + "AlreadyUnenrolled", + "NoExperiment", + "NotFirefoxLabsOptIn", + "Error", +}; + +dictionary FirefoxLabsUnenrollResult { + FirefoxLabsUnenrollStatus status; + sequence enrollment_change_events; +}; + + interface NimbusTargetingHelper { /// Execute the given jexl expression and evaluate against the existing targeting parameters and context passed to /// the helper at construction. diff --git a/components/nimbus/src/schema.rs b/components/nimbus/src/schema.rs index af07af25f2..c16f48ba9d 100644 --- a/components/nimbus/src/schema.rs +++ b/components/nimbus/src/schema.rs @@ -11,6 +11,8 @@ use uuid::Uuid; use crate::defaults::Defaults; use crate::enrollment::ExperimentMetadata; use crate::error::{trace, warn}; +#[cfg(feature = "stateful")] +use crate::stateful::firefox_labs::{FIREFOX_LABS_CONNECT_URL_KEY, FirefoxLabsMetadata}; use crate::{NimbusError, Result}; const DEFAULT_TOTAL_BUCKETS: u32 = 10000; @@ -23,6 +25,7 @@ pub struct EnrolledExperiment { pub user_facing_name: String, pub user_facing_description: String, pub branch_slug: String, + pub is_rollout: bool, } // ⚠️ Attention : Changes to this type should be accompanied by a new test ⚠️ @@ -112,6 +115,44 @@ impl Experiment { } serde_json::from_value(experiment).unwrap() } + + #[cfg(feature = "stateful")] + pub(crate) fn get_firefox_labs_metadata(&self, enrolled: bool) -> Option { + // We do not enforce at a schema level that is_firefox_labs_opt_in + // implies is_rollout, but only rollouts are supported so we must + // enforce it here. + if self.is_firefox_labs_opt_in + && self.is_rollout + && self.branches.len() == 1 + && let Some(title) = self.firefox_labs_title.as_deref() + && let Some(description) = self.firefox_labs_description.as_deref() + { + let connect_url = self + .firefox_labs_description_links + .as_ref() + .and_then(|links| links.get(FIREFOX_LABS_CONNECT_URL_KEY).cloned()); + + Some(FirefoxLabsMetadata { + slug: self.slug.clone(), + title_string_id: title.into(), + description_string_id: description.into(), + connect_url, + enrolled, + requires_restart: self.requires_restart, + }) + } else { + None + } + } + + #[cfg(feature = "stateful")] + pub(crate) fn is_valid_firefox_lab(&self) -> bool { + self.is_firefox_labs_opt_in + && self.is_rollout + && self.branches.len() == 1 + && self.firefox_labs_title.is_some() + && self.firefox_labs_description.is_some() + } } impl ExperimentMetadata for Experiment { diff --git a/components/nimbus/src/stateful/dbcache.rs b/components/nimbus/src/stateful/dbcache.rs index 1a02425d23..2d91068ae4 100644 --- a/components/nimbus/src/stateful/dbcache.rs +++ b/components/nimbus/src/stateful/dbcache.rs @@ -9,9 +9,12 @@ use crate::enrollment::{ EnrolledFeature, EnrolledFeatureConfig, ExperimentEnrollment, map_features_by_feature_id, }; use crate::error::{NimbusError, Result, warn}; +use crate::evaluator::{ExperimentAvailable, is_experiment_available}; use crate::stateful::enrollment::get_enrollments; +use crate::stateful::firefox_labs::FirefoxLabsMetadata; use crate::stateful::gecko_prefs::GeckoPrefStore; use crate::stateful::persistence::{Database, StoreId, Writer}; +use crate::targeting::NimbusTargetingHelper; use crate::{EnrolledExperiment, Experiment}; // This module manages an in-memory cache of the database, so that some @@ -184,4 +187,115 @@ impl DatabaseCache { } })? } + + pub fn check_for_feature_conflict( + &self, + slug: &str, + coenrolling_feature_ids: &[String], + ) -> Result> { + self.get_data(|data| { + if data.experiments_by_slug.contains_key(slug) { + // Cannot conflict with itself. + return Some(false); + } + + if let Some(experiment) = data.experiments.iter().find(|e| e.slug == slug) { + let coenrolling_feature_ids: HashSet<&str> = + coenrolling_feature_ids.iter().map(|s| s.as_ref()).collect(); + + let enrolled_feature_ids = + compute_enrolled_feature_ids(&data.experiments_by_slug, true); + + Some(!features_available( + experiment, + &enrolled_feature_ids, + &coenrolling_feature_ids, + )) + } else { + None + } + }) + } + + pub fn get_available_firefox_labs_metadata( + &self, + targeting_helper: &NimbusTargetingHelper, + coenrolling_feature_ids: &[String], + ) -> Result> { + let mut all_labs: Vec<_> = self.get_data(|data| { + let enrolled_feature_ids = + compute_enrolled_feature_ids(&data.experiments_by_slug, true); + + let coenrolling_feature_ids: HashSet<&str> = + coenrolling_feature_ids.iter().map(|s| s.as_ref()).collect(); + + data.experiments + .iter() + .filter_map(|experiment| { + if !experiment.is_firefox_labs_opt_in { + return None; + } + + let enrolled = data.experiments_by_slug.contains_key(&experiment.slug); + + // We call is_experiment_available with is_release=true + // because being able to enroll in experiments for different channels is a bug. + // + // See-also https://bugzilla.mozilla.org/show_bug.cgi?id=1909348 + if is_experiment_available(targeting_helper, experiment, true) + == ExperimentAvailable::Available + && (enrolled + || (features_available( + experiment, + &enrolled_feature_ids, + &coenrolling_feature_ids, + ) && !experiment.is_enrollment_paused)) + { + experiment.get_firefox_labs_metadata(enrolled) + } else { + None + } + }) + .collect() + })?; + + // XXX: This is maybe only useful for tests, but at least we get a + // stable order. + all_labs.sort_by(|e1, e2| Ord::cmp(&e1.slug, &e2.slug)); + + Ok(all_labs) + } + + #[cfg(test)] + pub fn get_experiment_enrollment(&self, slug: &str) -> Result> { + self.get_data(|data| data.enrollments.iter().find(|e| e.slug == slug).cloned()) + } +} + +fn compute_enrolled_feature_ids( + experiments_by_slug: &HashMap, + is_rollout: bool, +) -> HashSet<&str> { + experiments_by_slug + .values() + .filter(|e| e.is_rollout == is_rollout) + .flat_map(|e| e.feature_ids.iter()) + .map(|f| f.as_ref()) + .collect() +} + +fn features_available( + experiment: &Experiment, + enrolled_feature_ids: &HashSet<&str>, + coenrolling_feature_ids: &HashSet<&str>, +) -> bool { + for feature_id in &experiment.feature_ids { + if enrolled_feature_ids.contains(&**feature_id) + && !coenrolling_feature_ids.contains(&**feature_id) + { + return false; + } + } + + true } diff --git a/components/nimbus/src/stateful/enrollment.rs b/components/nimbus/src/stateful/enrollment.rs index 45c0d331f0..b4cc3b6fe6 100644 --- a/components/nimbus/src/stateful/enrollment.rs +++ b/components/nimbus/src/stateful/enrollment.rs @@ -11,6 +11,10 @@ use crate::enrollment::{ map_enrollments, }; use crate::error::{Result, debug, warn}; +use crate::stateful::firefox_labs::{ + FirefoxLabsEnrollResult, FirefoxLabsEnrollStatus, FirefoxLabsUnenrollResult, + FirefoxLabsUnenrollStatus, +}; use crate::stateful::gecko_prefs::GeckoPrefStore; use crate::stateful::gecko_prefs::PrefUnenrollReason; use crate::stateful::persistence::{ @@ -97,6 +101,7 @@ pub fn get_enrollments<'r>( user_facing_name: experiment.user_facing_name, user_facing_description: experiment.user_facing_description, branch_slug: branch.to_string(), + is_rollout: experiment.is_rollout, }); } _ => { @@ -122,7 +127,12 @@ pub fn opt_in_with_branch( .get_store(StoreId::Experiments) .get::(writer, experiment_slug) { - let enrollment = ExperimentEnrollment::from_explicit_opt_in(&exp, branch, &mut events); + let enrollment = ExperimentEnrollment::from_explicit_opt_in( + &exp, + branch, + EnrolledReason::OptIn, + &mut events, + ); db.get_store(StoreId::Enrollments) .put(writer, experiment_slug, &enrollment.unwrap())?; } else { @@ -138,6 +148,176 @@ pub fn opt_in_with_branch( Ok(events) } +pub fn enroll_in_firefox_lab( + db: &Database, + writer: &mut Writer, + slug: &str, + feature_conflict: Option, +) -> Result { + let mut events = vec![]; + + let status = match feature_conflict { + None => FirefoxLabsEnrollStatus::NoExperiment, + + Some(true) => FirefoxLabsEnrollStatus::FeatureConflict, + + Some(false) => match get_enrollment_and_experiment(db, writer, slug) { + // We computed feature_conflict via the dbcache, so we actually + // can't hit this case, but rewriting all the enrollment update in + // terms of the dbcache is a much larger endeavour. + // + // This technically could have been written in terms of the dbcache + // on the first pass, however, no other enrollment logic writes to + // the database from the cache, so it would be less obvious if we + // missed something. + // + // TODO(bug 2038055): rewrite in terms of the db cache + Ok((_, None)) => FirefoxLabsEnrollStatus::NoExperiment, + + Ok((_, Some(experiment))) if !experiment.is_valid_firefox_lab() => { + FirefoxLabsEnrollStatus::NotFirefoxLabsOptIn + } + + Ok((Some(enrollment), _)) if enrollment.status.is_enrolled() => { + FirefoxLabsEnrollStatus::AlreadyEnrolled + } + + Ok((_, Some(experiment))) => { + let new_enrollment = ExperimentEnrollment::from_explicit_opt_in( + &experiment, + &experiment.branches[0].slug, + EnrolledReason::FirefoxLabsOptIn, + &mut events, + )?; + db.get_store(StoreId::Enrollments) + .put(writer, slug, &new_enrollment)?; + + FirefoxLabsEnrollStatus::Enrolled + } + + Err(_) => FirefoxLabsEnrollStatus::Error, + }, + }; + + if status != FirefoxLabsEnrollStatus::Enrolled { + events.push(EnrollmentChangeEvent { + experiment_slug: slug.to_string(), + branch_slug: "N/A".to_string(), + reason: Some( + match status { + FirefoxLabsEnrollStatus::Enrolled => unreachable!("status != Enrolled"), + FirefoxLabsEnrollStatus::AlreadyEnrolled => "already-enrolled", + FirefoxLabsEnrollStatus::NoExperiment => "lab-does-not-exist", + FirefoxLabsEnrollStatus::NotFirefoxLabsOptIn => "not-lab", + FirefoxLabsEnrollStatus::FeatureConflict => "feature-conflict", + FirefoxLabsEnrollStatus::Error => "error", + } + .into(), + ), + change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: vec![], + }); + } + + Ok(FirefoxLabsEnrollResult { + status, + enrollment_change_events: events, + }) +} + +pub fn unenroll_from_firefox_lab( + db: &Database, + writer: &mut Writer, + slug: &str, + gecko_prefs: Option<&GeckoPrefStore>, +) -> Result { + let mut events = vec![]; + + let status = match get_enrollment_and_experiment(db, writer, slug) { + Ok((_, Some(experiment))) if !experiment.is_valid_firefox_lab() => { + FirefoxLabsUnenrollStatus::NotFirefoxLabsOptIn + } + Ok((_, None)) => FirefoxLabsUnenrollStatus::NoExperiment, + Ok((Some(enrollment), _)) if !enrollment.status.is_enrolled() => { + FirefoxLabsUnenrollStatus::AlreadyUnenrolled + } + Ok((Some(enrollment), experiment)) => { + let updated_enrollment = enrollment.on_explicit_opt_out( + experiment.as_ref(), + &mut events, + DisqualifiedReason::FirefoxLabsOptOut, + gecko_prefs, + ); + db.get_store(StoreId::Enrollments) + .put(writer, slug, &updated_enrollment)?; + + FirefoxLabsUnenrollStatus::Unenrolled + } + Ok((None, _)) => FirefoxLabsUnenrollStatus::NoExperiment, + Err(_) => FirefoxLabsUnenrollStatus::Error, + }; + + if status != FirefoxLabsUnenrollStatus::Unenrolled { + events.push(EnrollmentChangeEvent { + experiment_slug: slug.into(), + branch_slug: "N/A".into(), + reason: Some( + match status { + FirefoxLabsUnenrollStatus::Unenrolled => unreachable!("status != Unenrolled"), + FirefoxLabsUnenrollStatus::AlreadyUnenrolled => "already-unenrolled", + FirefoxLabsUnenrollStatus::NoExperiment => "lab-does-not-exist", + FirefoxLabsUnenrollStatus::NotFirefoxLabsOptIn => "not-lab", + FirefoxLabsUnenrollStatus::Error => "error", + } + .into(), + ), + change: EnrollmentChangeEventType::UnenrollFailed, + feature_ids: vec![], + }); + } + + Ok(FirefoxLabsUnenrollResult { + status, + enrollment_change_events: events, + }) +} + +pub fn unenroll_from_all_firefox_labs( + db: &Database, + writer: &mut Writer, + gecko_prefs: Option<&GeckoPrefStore>, +) -> Result> { + // TODO(bug 2038055): Compute this using the database cache. + + let mut events = vec![]; + let enrollments: Vec = + db.get_store(StoreId::Enrollments).collect_all(writer)?; + + for enrollment in &enrollments { + if !enrollment + .status + .is_enrolled_with_reason(EnrolledReason::FirefoxLabsOptIn) + { + continue; + } + let experiment: Option = db + .get_store(StoreId::Experiments) + .get(writer, &enrollment.slug)?; + + let updated_enrollment = enrollment.on_explicit_opt_out( + experiment.as_ref(), + &mut events, + DisqualifiedReason::FirefoxLabsOptOut, + gecko_prefs, + ); + + db.get_store(StoreId::Enrollments) + .put(writer, &enrollment.slug, &updated_enrollment)?; + } + + Ok(events) +} + fn get_enrollment_and_experiment( db: &Database, writer: &mut Writer, @@ -172,6 +352,7 @@ pub fn opt_out( let updated_enrollment = &existing_enrollment.on_explicit_opt_out( maybe_experiment.as_ref(), &mut events, + DisqualifiedReason::OptOut, gecko_prefs, ); diff --git a/components/nimbus/src/stateful/firefox_labs.rs b/components/nimbus/src/stateful/firefox_labs.rs new file mode 100644 index 0000000000..7eaba43dff --- /dev/null +++ b/components/nimbus/src/stateful/firefox_labs.rs @@ -0,0 +1,50 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::enrollment::EnrollmentChangeEvent; + +pub const FIREFOX_LABS_CONNECT_URL_KEY: &str = "connect"; + +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +pub struct FirefoxLabsMetadata { + pub slug: String, + pub enrolled: bool, + pub requires_restart: bool, + pub title_string_id: String, + pub description_string_id: String, + pub connect_url: Option, +} + +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +pub struct FirefoxLabsEnrollResult { + pub status: FirefoxLabsEnrollStatus, + pub enrollment_change_events: Vec, +} + +#[derive(Eq, PartialEq)] +#[cfg_attr(test, derive(Debug))] +pub enum FirefoxLabsEnrollStatus { + Enrolled, + AlreadyEnrolled, + NoExperiment, + NotFirefoxLabsOptIn, + FeatureConflict, + Error, +} + +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +pub struct FirefoxLabsUnenrollResult { + pub status: FirefoxLabsUnenrollStatus, + pub enrollment_change_events: Vec, +} + +#[derive(Eq, PartialEq)] +#[cfg_attr(test, derive(Debug))] +pub enum FirefoxLabsUnenrollStatus { + Unenrolled, + AlreadyUnenrolled, + NoExperiment, + NotFirefoxLabsOptIn, + Error, +} diff --git a/components/nimbus/src/stateful/mod.rs b/components/nimbus/src/stateful/mod.rs index 880c45c3a7..2ebd04d257 100644 --- a/components/nimbus/src/stateful/mod.rs +++ b/components/nimbus/src/stateful/mod.rs @@ -7,6 +7,7 @@ pub mod client; pub mod dbcache; pub mod enrollment; pub mod evaluator; +pub mod firefox_labs; pub mod gecko_prefs; pub mod matcher; pub mod nimbus_client; diff --git a/components/nimbus/src/stateful/nimbus_client.rs b/components/nimbus/src/stateful/nimbus_client.rs index fce9d4e297..08d541c8b9 100644 --- a/components/nimbus/src/stateful/nimbus_client.rs +++ b/components/nimbus/src/stateful/nimbus_client.rs @@ -33,9 +33,14 @@ use crate::stateful::behavior::EventStore; use crate::stateful::client::{NimbusServerSettings, SettingsClient, create_client}; use crate::stateful::dbcache::DatabaseCache; use crate::stateful::enrollment::{ - get_experiment_participation, get_rollout_participation, opt_in_with_branch, opt_out, - reset_telemetry_identifiers, set_experiment_participation, set_rollout_participation, - unenroll_for_pref, + enroll_in_firefox_lab, get_experiment_participation, get_rollout_participation, + opt_in_with_branch, opt_out, reset_telemetry_identifiers, set_experiment_participation, + set_rollout_participation, unenroll_for_pref, unenroll_from_all_firefox_labs, + unenroll_from_firefox_lab, +}; +use crate::stateful::firefox_labs::{ + FirefoxLabsEnrollResult, FirefoxLabsEnrollStatus, FirefoxLabsMetadata, + FirefoxLabsUnenrollResult, FirefoxLabsUnenrollStatus, }; use crate::stateful::gecko_prefs::{ GeckoPref, GeckoPrefHandler, GeckoPrefState, GeckoPrefStore, OriginalGeckoPref, PrefBranch, @@ -941,9 +946,7 @@ impl NimbusClient { }) .expect("failed to unwrap GeckoPrefHandler object") } -} -impl NimbusClient { pub fn set_install_time(&mut self, then: DateTime) { let mut state = self.mutable_state.lock().unwrap(); state.install_date = Some(then); @@ -955,9 +958,7 @@ impl NimbusClient { state.update_date = Some(then); state.update_time_to_now(Utc::now()); } -} -impl NimbusClient { /// This is only called from `get_feature_config_variables` which is itself is cached with /// thread safety in the FeatureHolder.kt and FeatureHolder.swift fn record_feature_activation_if_needed(&self, feature_id: &str) { @@ -1036,6 +1037,53 @@ impl NimbusClient { self.metrics_handler.submit_targeting_context(); Ok(()) } + + pub fn get_available_firefox_labs(&self) -> Result> { + let targeting_attributes = self.get_targeting_attributes(); + let targeting_helper = NimbusTargetingHelper::with_targeting_attributes( + &targeting_attributes, + self.event_store.clone(), + self.gecko_prefs.clone(), + ); + self.database_cache + .get_available_firefox_labs_metadata(&targeting_helper, &self.coenrolling_feature_ids) + } + + pub fn enroll_in_firefox_lab(&self, slug: &str) -> Result { + let feature_conflict = self + .database_cache + .check_for_feature_conflict(slug, &self.coenrolling_feature_ids)?; + + let db = self.db()?; + let mut writer = db.write()?; + let result = enroll_in_firefox_lab(db, &mut writer, slug, feature_conflict); + let mut state = self.mutable_state.lock().unwrap(); + self.end_initialize(db, writer, &mut state)?; + result + } + + pub fn unenroll_from_firefox_lab(&self, slug: &str) -> Result { + let db = self.db()?; + let mut writer = db.write()?; + let result = unenroll_from_firefox_lab(db, &mut writer, slug, self.gecko_prefs.as_deref()); + let mut state = self.mutable_state.lock().unwrap(); + self.end_initialize(db, writer, &mut state)?; + result + } + + pub fn unenroll_from_all_firefox_labs(&self) -> Result> { + let db = self.db()?; + let mut writer = db.write()?; + let result = unenroll_from_all_firefox_labs(db, &mut writer, self.gecko_prefs.as_deref()); + let mut state = self.mutable_state.lock().unwrap(); + self.end_initialize(db, writer, &mut state)?; + result + } + + #[cfg(test)] + pub fn get_experiment_enrollment(&self, slug: &str) -> Result> { + self.database_cache.get_experiment_enrollment(slug) + } } pub struct NimbusStringHelper { diff --git a/components/nimbus/src/tests/helpers.rs b/components/nimbus/src/tests/helpers.rs index ccc4a5d050..174426613f 100644 --- a/components/nimbus/src/tests/helpers.rs +++ b/components/nimbus/src/tests/helpers.rs @@ -734,7 +734,12 @@ pub(crate) fn get_experiment_with_published_date( } #[allow(unused)] -pub(crate) fn get_firefox_labs(slug: &str) -> Experiment { +pub(crate) fn get_firefox_lab(slug: &str) -> Experiment { + get_firefox_lab_with_feature(slug, "labs-feature") +} + +#[allow(unused)] +pub(crate) fn get_firefox_lab_with_feature(slug: &str, feature_id: &str) -> Experiment { serde_json::from_value(json!({ "schemaVersion": "1.0.0", "appName": "fenix", @@ -747,13 +752,13 @@ pub(crate) fn get_firefox_labs(slug: &str) -> Experiment { "ratio": 1, "features": [ { - "featureId": "labs-feature", + "featureId": feature_id, "value": {} } ] } ], - "featureIds": ["labs-feature"], + "featureIds": [feature_id], "isRollout": true, "isEnrollmentPaused": false, "isFirefoxLabsOptIn": true, @@ -763,6 +768,7 @@ pub(crate) fn get_firefox_labs(slug: &str) -> Experiment { "connect": "https://example.com/#connect", "learn-more": "https://example.com/#learn-more", }, + "requiresRestart": false, "userFacingName": "Test Firefox Labs", "userFacingDescription": "Test Firefox Labs", "bucketConfig": { diff --git a/components/nimbus/src/tests/stateful/test_gecko_prefs.rs b/components/nimbus/src/tests/stateful/test_gecko_prefs.rs index d13ff59584..11226647f2 100644 --- a/components/nimbus/src/tests/stateful/test_gecko_prefs.rs +++ b/components/nimbus/src/tests/stateful/test_gecko_prefs.rs @@ -49,6 +49,7 @@ fn test_gecko_pref_store_map_gecko_prefs_to_enrollment_slugs_and_update_store() user_facing_name: "".to_string(), user_facing_description: "".to_string(), branch_slug: experiment.branches[0].clone().slug, + is_rollout: false, }, )]); @@ -95,7 +96,7 @@ fn test_gecko_pref_store_map_gecko_prefs_to_enrollment_slugs_and_update_store_ex store.initialize()?; let rollout_slug = "rollout-1"; - let mut rollout = get_multi_feature_experiment( + let rollout = get_multi_feature_experiment( rollout_slug, vec![( "test_feature", @@ -103,8 +104,8 @@ fn test_gecko_pref_store_map_gecko_prefs_to_enrollment_slugs_and_update_store_ex "test_prop": "some-rollout-value" }), )], - ); - rollout.is_rollout = true; + ) + .patch(json!({ "isRollout": true })); let experiment_slug = "exp-1"; let experiment = get_multi_feature_experiment( @@ -130,6 +131,7 @@ fn test_gecko_pref_store_map_gecko_prefs_to_enrollment_slugs_and_update_store_ex user_facing_name: "".to_string(), user_facing_description: "".to_string(), branch_slug: rollout.branches[0].clone().slug, + is_rollout: true, }, ), ( @@ -140,6 +142,7 @@ fn test_gecko_pref_store_map_gecko_prefs_to_enrollment_slugs_and_update_store_ex user_facing_name: "".to_string(), user_facing_description: "".to_string(), branch_slug: experiment.branches[0].clone().slug, + is_rollout: false, }, ), ]); diff --git a/components/nimbus/src/tests/stateful/test_nimbus.rs b/components/nimbus/src/tests/stateful/test_nimbus.rs index de12e0f665..7e0b60801e 100644 --- a/components/nimbus/src/tests/stateful/test_nimbus.rs +++ b/components/nimbus/src/tests/stateful/test_nimbus.rs @@ -17,11 +17,15 @@ use crate::enrollment::{ }; use crate::error::{Result, info}; use crate::json::PrefValue; -use crate::metrics::MalformedFeatureConfigExtraDef; +use crate::metrics::{EnrollmentStatusExtraDef, MalformedFeatureConfigExtraDef}; use crate::schema::{Branch, FeatureConfig}; use crate::stateful::behavior::{ EventStore, Interval, IntervalConfig, IntervalData, MultiIntervalCounter, SingleIntervalCounter, }; +use crate::stateful::firefox_labs::{ + FirefoxLabsEnrollResult, FirefoxLabsEnrollStatus, FirefoxLabsMetadata, + FirefoxLabsUnenrollResult, FirefoxLabsUnenrollStatus, +}; use crate::stateful::gecko_prefs::{ GeckoPrefState, OriginalGeckoPref, PrefBranch, PrefEnrollmentData, PrefUnenrollReason, create_feature_prop_pref_map, @@ -30,10 +34,10 @@ use crate::stateful::persistence::{Database, StoreId}; use crate::stateful::targeting::RecordedContext; use crate::tests::helpers::{ TestGeckoPrefHandler, TestMetrics, TestRecordedContext, get_bucketed_rollout, - get_bucketed_rollout_with_feature, get_ios_rollout_experiment, get_multi_feature_experiment, - get_single_feature_experiment, get_single_feature_rollout, get_targeted_experiment, - get_targeted_experiment_with_feature, sorted_enrollment_change_events, - to_local_experiments_string, + get_bucketed_rollout_with_feature, get_firefox_lab, get_firefox_lab_with_feature, + get_ios_rollout_experiment, get_multi_feature_experiment, get_single_feature_experiment, + get_single_feature_rollout, get_targeted_experiment, get_targeted_experiment_with_feature, + sorted_enrollment_change_events, to_local_experiments_string, }; use crate::{ AppContext, DB_KEY_APP_VERSION, DB_KEY_UPDATE_DATE, Experiment, NimbusClient, @@ -2523,3 +2527,467 @@ fn test_opt_in_with_branch_events() -> Result<()> { Ok(()) } + +fn setup_firefox_labs_test( + recipes: &[Experiment], +) -> Result<(tempfile::TempDir, NimbusClient, Arc)> { + let temp_dir = tempfile::tempdir()?; + + let app_context = AppContext { + app_name: "fenix".to_string(), + app_id: "org.mozilla.fenix".to_string(), + channel: "nightly".to_string(), + ..Default::default() + }; + + let metrics = TestMetrics::new(); + + let mut client = NimbusClient::new( + app_context.clone(), + Default::default(), + Default::default(), + temp_dir.path(), + metrics.clone(), + None, + None, + )?; + client.with_targeting_attributes(TargetingAttributes { + app_context, + ..Default::default() + }); + + client.set_nimbus_id(&Uuid::from_str("00000000-0000-0000-0000-000000000004")?)?; + client.initialize()?; + + client.set_experiments_locally(to_local_experiments_string(recipes)?)?; + client.apply_pending_experiments()?; + + Ok((temp_dir, client, metrics)) +} + +fn assert_enrolled_experiment_slugs(client: &NimbusClient, expected_slugs: &[&str]) { + let mut slugs = client + .get_active_experiments() + .unwrap() + .iter() + .map(|e| e.slug.clone()) + .collect::>(); + + slugs.sort(); + + assert_eq!(&slugs, expected_slugs); +} + +fn assert_enrolled_reason(client: &NimbusClient, slug: &str, reason: EnrolledReason) { + assert!( + &client + .get_experiment_enrollment(slug) + .unwrap() + .unwrap() + .status + .is_enrolled_with_reason(reason), + ); +} + +fn assert_disqualified_reason( + client: &NimbusClient, + slug: &str, + expected_reason: DisqualifiedReason, +) { + let status = client + .get_experiment_enrollment(slug) + .unwrap() + .unwrap() + .status; + + assert!(matches!( + &status, + EnrollmentStatus::Disqualified { reason, .. } + if *reason == expected_reason + )); +} + +#[test] +fn test_firefox_labs_enroll_unenroll() -> Result<()> { + // This tests the basic cases for enrollment and unenrollment. + let (_temp_dir, client, metrics) = setup_firefox_labs_test(&[ + get_single_feature_experiment("experiment", "feature-id", json!({})), + get_single_feature_experiment("rollout", "feature-id", json!({})) + .patch(json!({ "isRollout": true })), + get_firefox_lab_with_feature("lab", "lab-feature-1"), + get_firefox_lab_with_feature("lab-requires-restart", "lab-feature-2") + .patch(json!({ "requiresRestart": true })), + get_firefox_lab_with_feature("lab-links", "lab-feature-3") + .patch(json!({ "firefoxLabsDescriptionLinks": { "connect": "https://example.com" } })), + get_firefox_lab_with_feature("lab-not-rollout", "lab-feature-4") + .patch(json!({ "isRollout": false })), + get_firefox_lab_with_feature("lab-no-title", "lab-feature-5") + .patch(json!({ "firefoxLabsTitle": null })), + get_firefox_lab_with_feature("lab-no-description", "lab-feature-6") + .patch(json!({ "firefoxLabsDescription": null })), + get_firefox_lab_with_feature("lab-different-channel", "lab-feature-7") + .patch(json!({ "channel": "mystery" })), + ])?; + + assert_eq!( + &client.get_available_firefox_labs()?, + &[ + FirefoxLabsMetadata { + slug: "lab".into(), + enrolled: false, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()) + }, + FirefoxLabsMetadata { + slug: "lab-links".into(), + enrolled: false, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com".into()) + }, + FirefoxLabsMetadata { + slug: "lab-requires-restart".into(), + enrolled: false, + requires_restart: true, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()) + } + ] + ); + + assert_eq!( + &metrics.get_enrollment_statuses(), + &[ + EnrollmentStatusExtraDef { + slug: Some("experiment".into()), + status: Some("Enrolled".into()), + reason: Some("Qualified".into()), + branch: Some("control".into()), + ..Default::default() + }, + EnrollmentStatusExtraDef { + slug: Some("lab".into()), + status: Some("NotEnrolled".into()), + reason: Some("FirefoxLabs".into()), + ..Default::default() + }, + EnrollmentStatusExtraDef { + slug: Some("lab-links".into()), + status: Some("NotEnrolled".into()), + reason: Some("FirefoxLabs".into()), + ..Default::default() + }, + EnrollmentStatusExtraDef { + slug: Some("lab-no-description".into()), + status: Some("NotEnrolled".into()), + reason: Some("FirefoxLabs".into()), + ..Default::default() + }, + EnrollmentStatusExtraDef { + slug: Some("lab-no-title".into()), + status: Some("NotEnrolled".into()), + reason: Some("FirefoxLabs".into()), + ..Default::default() + }, + EnrollmentStatusExtraDef { + slug: Some("lab-not-rollout".into()), + status: Some("NotEnrolled".into()), + reason: Some("FirefoxLabs".into()), + ..Default::default() + }, + EnrollmentStatusExtraDef { + slug: Some("lab-requires-restart".into()), + status: Some("NotEnrolled".into()), + reason: Some("FirefoxLabs".into()), + ..Default::default() + }, + EnrollmentStatusExtraDef { + slug: Some("rollout".into()), + status: Some("Enrolled".into()), + reason: Some("Qualified".into()), + branch: Some("control".into()), + ..Default::default() + }, + ], + ); + + metrics.clear(); + assert_enrolled_experiment_slugs(&client, &["experiment", "rollout"]); + + // Enroll in a lab and see the change reported in get_available_firefox_labs() + assert_eq!( + client.enroll_in_firefox_lab("lab")?, + FirefoxLabsEnrollResult { + status: FirefoxLabsEnrollStatus::Enrolled, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "lab".into(), + branch_slug: "control".into(), + reason: None, + change: EnrollmentChangeEventType::Enrollment, + feature_ids: vec!["lab-feature-1".into()] + }], + } + ); + + assert_enrolled_experiment_slugs(&client, &["experiment", "lab", "rollout"]); + assert_enrolled_reason(&client, "lab", EnrolledReason::FirefoxLabsOptIn); + + // Opt in does not trigger enrollment status. + assert_eq!(metrics.get_enrollment_statuses(), &[]); + + assert_eq!( + &client.get_available_firefox_labs()?, + &[ + FirefoxLabsMetadata { + slug: "lab".into(), + enrolled: true, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()) + }, + FirefoxLabsMetadata { + slug: "lab-links".into(), + enrolled: false, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com".into()) + }, + FirefoxLabsMetadata { + slug: "lab-requires-restart".into(), + enrolled: false, + requires_restart: true, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()) + } + ] + ); + + // Attempting to re-enroll does nothing. + assert_eq!( + client.enroll_in_firefox_lab("lab")?, + FirefoxLabsEnrollResult { + status: FirefoxLabsEnrollStatus::AlreadyEnrolled, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "lab".into(), + branch_slug: "N/A".into(), + reason: Some("already-enrolled".into()), + change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: vec![] + }], + } + ); + + // Unenrolling also should update get_available_firefox_labs() + assert_eq!( + client.unenroll_from_firefox_lab("lab")?, + FirefoxLabsUnenrollResult { + status: FirefoxLabsUnenrollStatus::Unenrolled, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "lab".into(), + branch_slug: "control".into(), + reason: Some("FirefoxLabsOptOut".into()), + change: EnrollmentChangeEventType::Disqualification, + feature_ids: vec!["lab-feature-1".into()] + }], + } + ); + + // Opt out does not trigger enrollment status. + assert_eq!(metrics.get_enrollment_statuses(), &[]); + + assert_enrolled_experiment_slugs(&client, &["experiment", "rollout"]); + assert_disqualified_reason(&client, "lab", DisqualifiedReason::FirefoxLabsOptOut); + + assert_eq!( + &client.get_available_firefox_labs()?, + &[ + FirefoxLabsMetadata { + slug: "lab".into(), + enrolled: false, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()) + }, + FirefoxLabsMetadata { + slug: "lab-links".into(), + enrolled: false, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com".into()) + }, + FirefoxLabsMetadata { + slug: "lab-requires-restart".into(), + enrolled: false, + requires_restart: true, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()) + } + ] + ); + + // Attempting to re-unenroll does nothing. + assert_eq!( + client.unenroll_from_firefox_lab("lab")?, + FirefoxLabsUnenrollResult { + status: FirefoxLabsUnenrollStatus::AlreadyUnenrolled, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "lab".into(), + branch_slug: "N/A".into(), + reason: Some("already-unenrolled".into()), + change: EnrollmentChangeEventType::UnenrollFailed, + feature_ids: vec![] + }], + } + ); + + // Attempting to enroll in a non-existant lab. + assert_eq!( + client.enroll_in_firefox_lab("unknown")?, + FirefoxLabsEnrollResult { + status: FirefoxLabsEnrollStatus::NoExperiment, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "unknown".into(), + branch_slug: "N/A".into(), + reason: Some("lab-does-not-exist".into()), + change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: vec![] + }], + } + ); + + // Attempting to enroll in a non-lab. + assert_eq!( + client.enroll_in_firefox_lab("experiment")?, + FirefoxLabsEnrollResult { + status: FirefoxLabsEnrollStatus::NotFirefoxLabsOptIn, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "experiment".into(), + branch_slug: "N/A".into(), + reason: Some("not-lab".into()), + change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: vec![] + }], + } + ); + + // Attempt to unenroll from a non-lab as a lab. + assert_eq!( + client.unenroll_from_firefox_lab("experiment")?, + FirefoxLabsUnenrollResult { + status: FirefoxLabsUnenrollStatus::NotFirefoxLabsOptIn, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "experiment".into(), + branch_slug: "N/A".into(), + reason: Some("not-lab".into()), + change: EnrollmentChangeEventType::UnenrollFailed, + feature_ids: vec![], + }], + } + ); + + Ok(()) +} + +#[test] +fn test_firefox_labs_feature_conflict() -> Result<()> { + // This tests the basic cases for enrollment and unenrollment. + let (_temp_dir, client, _) = setup_firefox_labs_test(&[ + get_single_feature_experiment("rollout", "labs-feature", json!({})) + .patch(json!({ "isRollout": true })), + get_firefox_lab("lab"), + ])?; + + assert_eq!(&client.get_available_firefox_labs()?, &[]); + + assert_eq!( + client.enroll_in_firefox_lab("lab")?, + FirefoxLabsEnrollResult { + status: FirefoxLabsEnrollStatus::FeatureConflict, + enrollment_change_events: vec![EnrollmentChangeEvent { + experiment_slug: "lab".into(), + branch_slug: "N/A".into(), + reason: Some("feature-conflict".into()), + change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: vec![], + }], + } + ); + + Ok(()) +} + +#[test] +fn test_firefox_labs_rollout_opt_out_does_not_unenroll() -> Result<()> { + let (_temp_dir, client, _) = setup_firefox_labs_test(&[get_firefox_lab("lab")])?; + + assert_eq!( + client.enroll_in_firefox_lab("lab")?.status, + FirefoxLabsEnrollStatus::Enrolled + ); + + let events = client.set_rollout_participation(false)?; + assert_eq!(&events, &[]); + + assert_enrolled_experiment_slugs(&client, &["lab"]); + + Ok(()) +} + +#[test] +fn test_firefox_labs_reset_telemetry_does_not_unenroll() -> Result<()> { + let (_temp_dir, client, _) = setup_firefox_labs_test(&[get_firefox_lab("lab")])?; + + assert_eq!( + client.enroll_in_firefox_lab("lab")?.status, + FirefoxLabsEnrollStatus::Enrolled + ); + + let events = client.reset_telemetry_identifiers()?; + assert_eq!(&events, &[]); + + assert_enrolled_experiment_slugs(&client, &["lab"]); + + Ok(()) +} + +#[test] +fn test_unenroll_from_all_firefox_labs() -> Result<()> { + let (_temp_dir, client, _) = setup_firefox_labs_test(&[ + get_single_feature_experiment("experiment", "feature-id", json!({})), + get_single_feature_experiment("rollout", "feature-id", json!({})) + .patch(json!({ "isRollout": true })), + get_firefox_lab_with_feature("lab-1", "feature-1"), + get_firefox_lab_with_feature("lab-2", "feature-2"), + get_firefox_lab_with_feature("lab-3", "feature-3"), + ])?; + + assert_enrolled_experiment_slugs(&client, &["experiment", "rollout"]); + + client.enroll_in_firefox_lab("lab-1")?; + client.enroll_in_firefox_lab("lab-2")?; + client.enroll_in_firefox_lab("lab-3")?; + + assert_enrolled_experiment_slugs( + &client, + &["experiment", "lab-1", "lab-2", "lab-3", "rollout"], + ); + + client.unenroll_from_all_firefox_labs()?; + + assert_enrolled_experiment_slugs(&client, &["experiment", "rollout"]); + assert_disqualified_reason(&client, "lab-1", DisqualifiedReason::FirefoxLabsOptOut); + assert_disqualified_reason(&client, "lab-2", DisqualifiedReason::FirefoxLabsOptOut); + assert_disqualified_reason(&client, "lab-3", DisqualifiedReason::FirefoxLabsOptOut); + + Ok(()) +} diff --git a/components/nimbus/src/tests/test_enrollment.rs b/components/nimbus/src/tests/test_enrollment.rs index 895629ca7e..d81a64ea8b 100644 --- a/components/nimbus/src/tests/test_enrollment.rs +++ b/components/nimbus/src/tests/test_enrollment.rs @@ -19,7 +19,7 @@ use crate::stateful::gecko_prefs::{ create_feature_prop_pref_map, }; #[cfg(feature = "stateful")] -use crate::tests::helpers::{TestGeckoPrefHandler, get_firefox_labs, get_ios_rollout_experiment}; +use crate::tests::helpers::{TestGeckoPrefHandler, get_firefox_lab, get_ios_rollout_experiment}; use crate::tests::helpers::{ get_bucketed_rollout, get_experiment_with_published_date, get_multi_feature_experiment, get_single_feature_experiment, get_test_experiments, no_coenrolling_features, @@ -533,7 +533,7 @@ fn test_evolver_new_experiment_not_enrolled() -> Result<()> { #[cfg(feature = "stateful")] #[test] fn test_evolve_new_labs_not_enrolled() -> Result<()> { - let recipe = get_firefox_labs("firefox-labs"); + let recipe = get_firefox_lab("firefox-labs"); let (_, ctx, aru) = local_ctx(); let mut targeting_helper = ctx.into(); let coenrolling_feature_ids = no_coenrolling_features(); @@ -633,7 +633,7 @@ fn test_evolve_new_rollout_globally_opted_out() -> Result<()> { #[cfg(feature = "stateful")] #[test] fn test_evolve_new_labs_globally_opted_out_from_rollouts() -> Result<()> { - let recipe = get_firefox_labs("firefox-labs"); + let recipe = get_firefox_lab("firefox-labs"); let (_, ctx, aru) = local_ctx(); let mut targeting_helper = ctx.into(); let coenrolling_feature_ids = no_coenrolling_features(); @@ -691,7 +691,7 @@ fn test_evolver_new_experiment_enrollment_paused() -> Result<()> { #[cfg(feature = "stateful")] #[test] fn test_evolver_new_labs_enrollment_paused() -> Result<()> { - let recipe = get_firefox_labs("firefox-labs").patch(json!({ "isEnrollmentPaused": true })); + let recipe = get_firefox_lab("firefox-labs").patch(json!({ "isEnrollmentPaused": true })); let (_, ctx, aru) = local_ctx(); let mut targeting_helper = ctx.into(); let coenrolling_feature_ids = no_coenrolling_features(); @@ -1074,7 +1074,7 @@ fn test_evolve_rollout_update_enrolled_then_opted_out() -> Result<()> { #[cfg(feature = "stateful")] #[test] fn test_evolve_labs_update_enrolled_then_opted_out() -> Result<()> { - let recipe = get_firefox_labs("firefox-labs"); + let recipe = get_firefox_lab("firefox-labs"); let (_, ctx, aru) = local_ctx(); let mut targeting_helper = ctx.into(); let coenrolling_feature_ids = no_coenrolling_features(); @@ -1215,7 +1215,7 @@ fn test_evolver_experiment_update_enrolled_then_targeting_changed() -> Result<() #[cfg(feature = "stateful")] #[test] fn test_evolver_labs_update_enrolled_then_targeting_changed() -> Result<()> { - let recipe = get_firefox_labs("firefox-labs").patch(json!({ "targeting": "false" })); + let recipe = get_firefox_lab("firefox-labs").patch(json!({ "targeting": "false" })); let (_, ctx, aru) = local_ctx(); let mut targeting_helper = ctx.into(); let coenrolling_feature_ids = no_coenrolling_features(); @@ -1320,7 +1320,7 @@ fn test_evolver_experiment_update_enrolled_then_bucketing_changed() -> Result<() #[cfg(feature = "stateful")] #[test] fn test_evolver_labs_update_enrolled_then_bucketing_changed() -> Result<()> { - let recipe = get_firefox_labs("firefox-labs").patch(json!({ + let recipe = get_firefox_lab("firefox-labs").patch(json!({ "bucketConfig": { "randomizationUnit": "nimbus_id", "start": 0, @@ -4131,7 +4131,12 @@ fn test_rollouts_end_to_end() -> Result<()> { fn test_enrollment_explicit_opt_in() -> Result<()> { let exp = get_test_experiments()[0].clone(); let mut events = vec![]; - let enrollment = ExperimentEnrollment::from_explicit_opt_in(&exp, "control", &mut events)?; + let enrollment = ExperimentEnrollment::from_explicit_opt_in( + &exp, + "control", + EnrolledReason::OptIn, + &mut events, + )?; assert!(matches!( enrollment.status, EnrollmentStatus::Enrolled { @@ -4158,7 +4163,12 @@ fn test_enrollment_explicit_opt_in() -> Result<()> { fn test_enrollment_explicit_opt_in_branch_unknown() { let exp = get_test_experiments()[0].clone(); let mut events = vec![]; - let res = ExperimentEnrollment::from_explicit_opt_in(&exp, "bobo", &mut events); + let res = ExperimentEnrollment::from_explicit_opt_in( + &exp, + "bobo", + EnrolledReason::OptIn, + &mut events, + ); assert!(res.is_err()); } @@ -4178,6 +4188,7 @@ fn test_enrollment_enrolled_explicit_opt_out() { let enrollment = existing_enrollment.on_explicit_opt_out( Some(&exp), &mut events, + DisqualifiedReason::OptOut, #[cfg(feature = "stateful")] None, ); @@ -4209,6 +4220,7 @@ fn test_enrollment_not_enrolled_explicit_opt_out() { let enrollment = existing_enrollment.on_explicit_opt_out( Some(&exp), &mut events, + DisqualifiedReason::OptOut, #[cfg(feature = "stateful")] None, ); @@ -4230,6 +4242,7 @@ fn test_enrollment_disqualified_explicit_opt_out() { let enrollment = existing_enrollment.on_explicit_opt_out( None, &mut events, + DisqualifiedReason::OptOut, #[cfg(feature = "stateful")] None, ); diff --git a/components/nimbus/src/tests/test_schema.rs b/components/nimbus/src/tests/test_schema.rs index 586700896d..6d09a2b9fe 100644 --- a/components/nimbus/src/tests/test_schema.rs +++ b/components/nimbus/src/tests/test_schema.rs @@ -59,3 +59,131 @@ fn test_deserialize_untyped_json() -> Result<()> { Ok(()) } + +#[cfg(feature = "stateful")] +mod stateful { + use serde_json::json; + + use crate::stateful::firefox_labs::*; + use crate::tests::helpers::get_firefox_lab; + + #[cfg(feature = "stateful")] + #[test] + fn test_get_firefox_labs_metadata() { + assert_eq!( + get_firefox_lab("slug") + .get_firefox_labs_metadata(false) + .unwrap(), + FirefoxLabsMetadata { + slug: "slug".into(), + enrolled: false, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()), + } + ); + + assert_eq!( + get_firefox_lab("slug") + .get_firefox_labs_metadata(true) + .unwrap(), + FirefoxLabsMetadata { + slug: "slug".into(), + enrolled: true, + requires_restart: false, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://example.com/#connect".into()), + } + ); + + assert_eq!( + get_firefox_lab("slug") + .patch(json!({ + "firefoxLabsDescriptionLinks": null, + "requiresRestart": true, + })) + .get_firefox_labs_metadata(false) + .unwrap(), + FirefoxLabsMetadata { + slug: "slug".into(), + enrolled: false, + requires_restart: true, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: None, + } + ); + + assert_eq!( + get_firefox_lab("slug") + .patch(json!({ + "firefoxLabsDescriptionLinks": { + "connect": "https://connect.example.com/", + }, + "requiresRestart": true, + })) + .get_firefox_labs_metadata(false) + .unwrap(), + FirefoxLabsMetadata { + slug: "slug".into(), + enrolled: false, + requires_restart: true, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: Some("https://connect.example.com/".into()), + } + ); + + assert_eq!( + get_firefox_lab("slug") + .patch(json!({ + "firefoxLabsDescriptionLinks": {}, + "requiresRestart": true, + })) + .get_firefox_labs_metadata(false) + .unwrap(), + FirefoxLabsMetadata { + slug: "slug".into(), + enrolled: false, + requires_restart: true, + title_string_id: "labs-title".into(), + description_string_id: "labs-description".into(), + connect_url: None, + } + ); + + // Requires isFirefoxLabsOptIn: true + assert!( + get_firefox_lab("slug") + .patch(json!({ "isFirefoxLabsOptIn": false })) + .get_firefox_labs_metadata(false) + .is_none() + ); + + // Requires isRollout: true + assert!( + get_firefox_lab("slug") + .patch(json!({ "isRollout": false })) + .get_firefox_labs_metadata(false) + .is_none() + ); + + // Requires firefoxLabsTitle + assert!( + get_firefox_lab("slug") + .patch(json!({ "firefoxLabsTitle": null })) + .get_firefox_labs_metadata(false) + .is_none() + ); + + // Requires firefoxLabsDescription + assert!( + get_firefox_lab("slug") + .patch(json!({ "firefoxLabsDescription": null })) + .get_firefox_labs_metadata(false) + .is_none() + ); + } +}