/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ const { LABS_MIGRATION_FEATURE_MAP, LEGACY_NIMBUS_MIGRATION_PREF, MigrationError, NIMBUS_MIGRATION_PREFS, NimbusMigrations, } = ChromeUtils.importESModule("resource://nimbus/lib/Migrations.sys.mjs"); const { NimbusTelemetry } = ChromeUtils.importESModule( "resource://nimbus/lib/Telemetry.sys.mjs" ); const { ProfilesDatastoreService } = ChromeUtils.importESModule( "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs" ); /** @typedef {import("../../lib/Migrations.sys.mjs").Migration} Migration */ /** @typedef {import("../../lib/Migrations.sys.mjs").Phase} Phase */ function mockLabsRecipes(targeting = "true") { return Object.entries(LABS_MIGRATION_FEATURE_MAP).map(([featureId, slug]) => NimbusTestUtils.factories.recipe.withFeatureConfig( slug, { featureId, value: { enabled: true } }, { isRollout: true, isFirefoxLabsOptIn: true, firefoxLabsTitle: `${featureId}-placeholder-title`, firefoxLabsDescription: `${featureId}-placeholder-desc`, firefoxLabsDescriptionLinks: null, firefoxLabsGroup: "placeholder", targeting, } ) ); } function getEnabledPrefForFeature(featureId) { return NimbusFeatures[featureId].manifest.variables.enabled.setPref.pref; } add_setup(async function setup() { Services.fog.initializeFOG(); }); /** * Setup a test environment. * * @param {object} options * @param {number?} options.legacyMigrationState * The value of the legacy migration pref. * @param {Record?} options.migrationState * The value that should be set for the Nimbus migration prefs. If * not provided, the pref will be unset. * @param {Record} options.migrations * An array of migrations that will replace the regular set of migrations * for the duration of the test. * @param {object[]} options.recipes * An array of experiment recipes that will be returned by the * RemoteSettingsExperimentLoader for the duration of the test. * @param {object} options.args * Options to pass to to NimbusTestutils.setupNimbusTest. */ async function setupTest({ legacyMigrationState, migrationState, migrations, init = true, ...args } = {}) { Assert.ok( !Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF), `legacy migration pref should be unset` ); for (const [phase, pref] of Object.keys(NIMBUS_MIGRATION_PREFS)) { Assert.ok( !Services.prefs.prefHasUserValue(pref), `${phase} migration pref should be unset` ); } const { initExperimentAPI, ...ctx } = await NimbusTestUtils.setupTest({ init: false, clearTelemetry: true, ...args, }); const { sandbox } = ctx; if (migrationState) { for (const [phase, value] of Object.entries(migrationState)) { Services.prefs.setIntPref(NIMBUS_MIGRATION_PREFS[phase], value); } } if (typeof legacyMigrationState !== "undefined") { Services.prefs.setIntPref( LEGACY_NIMBUS_MIGRATION_PREF, legacyMigrationState ); } if (migrations) { // If the test only specifies some of the phases ensure that there are // placeholders so that NimbusMigrations doesn't get mad. const migrationsStub = Object.assign( Object.fromEntries( Object.values(NimbusMigrations.Phase).map(phase => [phase, []]) ), migrations ); sandbox.stub(NimbusMigrations, "MIGRATIONS").get(() => migrationsStub); } if (init) { await initExperimentAPI(); } else { ctx.initExperimentAPI = initExperimentAPI; } return ctx; } function makeMigrations(phase, count) { const migrations = []; for (let i = 0; i < count; i++) { migrations.push( NimbusMigrations.migration(`test-migration-${phase}-${i}`, sinon.stub()) ); } return migrations; } add_task(async function test_migration_unset() { info("Testing NimbusMigrations with no migration pref set"); const startupMigrations = makeMigrations( NimbusMigrations.Phase.INIT_STARTED, 2 ); const storeMigrations = makeMigrations( NimbusMigrations.Phase.AFTER_STORE_INITIALIZED, 2 ); const updateMigrations = makeMigrations( NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE, 1 ); const { cleanup } = await setupTest({ migrations: { [NimbusMigrations.Phase.INIT_STARTED]: startupMigrations, [NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations, [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations, }, }); Assert.ok( startupMigrations[0].fn.calledOnce, `${startupMigrations[0].name} should be called once` ); Assert.ok( startupMigrations[1].fn.calledOnce, `${startupMigrations[1].name} should be called once` ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED] ), 1, "Migration pref should be updated" ); Assert.ok( storeMigrations[0].fn.calledOnce, `${storeMigrations[0].name} should be called once` ); Assert.ok( storeMigrations[1].fn.calledOnce, `${storeMigrations[1].name} should be called once` ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED] ), 1, "Migration pref should be updated" ); Assert.ok( updateMigrations[0].fn.calledOnce, `${updateMigrations[0].name} should be called once` ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ] ), 0, "Migration pref should be updated" ); Assert.deepEqual( Glean.nimbusEvents.migration.testGetValue().map(event => event.extra), [ { success: "true", migration_id: startupMigrations[0].name, }, { success: "true", migration_id: startupMigrations[1].name, }, { success: "true", migration_id: storeMigrations[0].name, }, { success: "true", migration_id: storeMigrations[1].name, }, { success: "true", migration_id: updateMigrations[0].name, }, ] ); await cleanup(); }); add_task(async function test_migration_partially_done() { info("Testing NimbusMigrations with some migrations completed"); const startupMigrations = makeMigrations( NimbusMigrations.Phase.INIT_STARTED, 2 ); const storeMigrations = makeMigrations( NimbusMigrations.Phase.AFTER_STORE_INITIALIZED, 2 ); const updateMigrations = makeMigrations( NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE, 2 ); const { cleanup } = await setupTest({ migrationState: { [NimbusMigrations.Phase.INIT_STARTED]: 0, [NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: 0, [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: 0, }, migrations: { [NimbusMigrations.Phase.INIT_STARTED]: startupMigrations, [NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations, [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations, }, }); Assert.ok( startupMigrations[0].fn.notCalled, `${startupMigrations[0].name} should not be called` ); Assert.ok( startupMigrations[1].fn.calledOnce, `${startupMigrations[1].name} should be called once` ); Assert.ok( storeMigrations[0].fn.notCalled, `${updateMigrations[0].name} should not be called` ); Assert.ok( storeMigrations[1].fn.calledOnce, `${updateMigrations[1].name} should be called once` ); Assert.ok( updateMigrations[0].fn.notCalled, `${updateMigrations[0].name} should not be called` ); Assert.ok( updateMigrations[1].fn.calledOnce, `${updateMigrations[1].name} should be called once` ); Assert.deepEqual( Glean.nimbusEvents.migration.testGetValue().map(event => event.extra), [ { success: "true", migration_id: startupMigrations[1].name, }, { success: "true", migration_id: storeMigrations[1].name, }, { success: "true", migration_id: updateMigrations[1].name, }, ] ); await cleanup(); }); add_task(async function test_migration_throws() { info( "Testing NimbusMigrations with a migration that throws an unknown error" ); const startupMigrations = makeMigrations( NimbusMigrations.Phase.INIT_STARTED, 3 ); startupMigrations[1].fn.throws( new Error(`${startupMigrations[1].name} failed`) ); const storeMigrations = makeMigrations( NimbusMigrations.Phase.INIT_STARTED, 3 ); storeMigrations[1].fn.throws(new Error(`${storeMigrations[1].name} failed`)); const updateMigrations = makeMigrations( NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE, 3 ); updateMigrations[1].fn.throws( new Error(`${updateMigrations[1].name} failed`) ); const { cleanup } = await setupTest({ migrations: { [NimbusMigrations.Phase.INIT_STARTED]: startupMigrations, [NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations, [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations, }, }); Assert.ok( startupMigrations[0].fn.calledOnce, `${startupMigrations[0].name} should be called once` ); Assert.ok( startupMigrations[1].fn.calledOnce, `${startupMigrations[1].name} should be called once` ); Assert.ok( startupMigrations[2].fn.notCalled, `${startupMigrations[2].name} should not be called` ); Assert.ok( storeMigrations[0].fn.calledOnce, `${storeMigrations[0].name} should be called once` ); Assert.ok( storeMigrations[1].fn.calledOnce, `${storeMigrations[1].name} should be called once` ); Assert.ok( storeMigrations[2].fn.notCalled, `${storeMigrations[2].name} should not be called` ); Assert.ok( updateMigrations[0].fn.calledOnce, `${updateMigrations[0].name} should be called once` ); Assert.ok( updateMigrations[1].fn.calledOnce, `${updateMigrations[1].name} should be called once` ); Assert.ok( updateMigrations[2].fn.notCalled, `${updateMigrations[2].name} should not be called` ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED] ), 0, "Migration pref should only be set to 0" ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED] ), 0, "Migration pref should only be set to 0" ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ] ), 0, "Migration pref should only be set to 0" ); Assert.deepEqual( Glean.nimbusEvents.migration.testGetValue().map(event => event.extra), [ { success: "true", migration_id: startupMigrations[0].name, }, { success: "false", migration_id: startupMigrations[1].name, error_reason: MigrationError.Reason.UNKNOWN, }, { success: "true", migration_id: storeMigrations[0].name, }, { success: "false", migration_id: storeMigrations[1].name, error_reason: MigrationError.Reason.UNKNOWN, }, { success: "true", migration_id: updateMigrations[0].name, }, { success: "false", migration_id: updateMigrations[1].name, error_reason: MigrationError.Reason.UNKNOWN, }, ] ); await cleanup(); }); add_task(async function test_migration_throws_MigrationError() { info( "Testing NimbusMigrations with a migration that throws a MigrationError" ); const startupMigrations = makeMigrations( NimbusMigrations.Phase.INIT_STARTED, 3 ); startupMigrations[1].fn.throws(new MigrationError("bogus")); const storeMigrations = makeMigrations( NimbusMigrations.Phase.AFTER_STORE_INITIALIZED, 3 ); storeMigrations[1].fn.throws(new MigrationError("bogus")); const updateMigrations = makeMigrations( NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE, 3 ); updateMigrations[1].fn.throws(new MigrationError("bogus")); const { cleanup } = await setupTest({ migrations: { [NimbusMigrations.Phase.INIT_STARTED]: startupMigrations, [NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: storeMigrations, [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: updateMigrations, }, }); Assert.ok( startupMigrations[0].fn.calledOnce, `${startupMigrations[0].name} should be called once` ); Assert.ok( startupMigrations[1].fn.calledOnce, `${startupMigrations[1].name} should be called once` ); Assert.ok( startupMigrations[2].fn.notCalled, `${startupMigrations[2].name} should not be called` ); Assert.ok( storeMigrations[0].fn.calledOnce, `${storeMigrations[0].name} should be called once` ); Assert.ok( storeMigrations[1].fn.calledOnce, `${storeMigrations[1].name} should be called once` ); Assert.ok( storeMigrations[2].fn.notCalled, `${storeMigrations[2].name} should not be called` ); Assert.ok( updateMigrations[0].fn.calledOnce, `${updateMigrations[0].name} should be called once` ); Assert.ok( updateMigrations[1].fn.calledOnce, `${updateMigrations[1].name} should be called once` ); Assert.ok( updateMigrations[2].fn.notCalled, `${updateMigrations[2].name} should not be called` ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED] ), 0, "Migration pref should only be set to 0" ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.AFTER_STORE_INITIALIZED] ), 0, "Migration pref should only be set to 0" ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ] ), 0, "Migration pref should only be set to 0" ); Assert.deepEqual( Glean.nimbusEvents.migration.testGetValue().map(event => event.extra), [ { success: "true", migration_id: startupMigrations[0].name, }, { success: "false", migration_id: startupMigrations[1].name, error_reason: "bogus", }, { success: "true", migration_id: storeMigrations[0].name, }, { success: "false", migration_id: storeMigrations[1].name, error_reason: "bogus", }, { success: "true", migration_id: updateMigrations[0].name, }, { success: "false", migration_id: updateMigrations[1].name, error_reason: "bogus", }, ] ); await cleanup(); }); const LEGACY_TO_MULTIPHASE_MIGRATION = NimbusMigrations.MIGRATIONS[NimbusMigrations.Phase.INIT_STARTED][0]; add_task(async function test_migration_legacyToMultiphase_unset() { const migrations = makeMigrations(NimbusMigrations.Phase.INIT_STARTED, 2); const { cleanup } = await setupTest({ migrations: { [NimbusMigrations.Phase.INIT_STARTED]: [LEGACY_TO_MULTIPHASE_MIGRATION], [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: migrations, }, }); Assert.ok( migrations[0].fn.calledOnce, `${migrations[0].name} should be called` ); Assert.ok( migrations[1].fn.calledOnce, `${migrations[1].name} should be called` ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED] ), 0, "before-manager-startup phase pref should be set" ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ] ), 1, "after-remote-setttings-update phase pref should be set" ); Assert.ok( !Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF), "legacy phase pref is unset" ); await cleanup(); }); add_task(async function test_migration_legacyToMultiphase_partial() { const migrations = makeMigrations( NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE, 3 ); const { cleanup } = await setupTest({ legacyMigrationState: 1, migrations: { [NimbusMigrations.Phase.INIT_STARTED]: [LEGACY_TO_MULTIPHASE_MIGRATION], [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: migrations, }, }); Assert.ok( migrations[0].fn.notCalled, `${migrations[0].name} should not be called` ); Assert.ok( migrations[1].fn.notCalled, `${migrations[1].name} should not be called` ); Assert.ok( migrations[2].fn.calledOnce, `${migrations[2].name} should be called` ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED] ), 0, "before-manager-startup phase pref should be set" ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ] ), 2, "after-remote-setttings-update phase pref should be set" ); Assert.ok( !Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF), "legacy phase pref is unset" ); await cleanup(); }); add_task(async function test_migration_legacyToMultiphase_complete() { const migrations = makeMigrations( NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE, 2 ); const { cleanup } = await setupTest({ legacyMigrationState: 1, migrations: { [NimbusMigrations.Phase.INIT_STARTED]: [LEGACY_TO_MULTIPHASE_MIGRATION], [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: migrations, }, }); Assert.ok(migrations[0].fn.notCalled, `${migrations[0].name} not called`); Assert.ok(migrations[1].fn.notCalled, `${migrations[1].name} not called`); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[NimbusMigrations.Phase.INIT_STARTED] ), 0, "before-manager-startup phase pref should be set" ); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ] ), 1, "after-remote-setttings-update phase pref should be set" ); Assert.ok( !Services.prefs.prefHasUserValue(LEGACY_NIMBUS_MIGRATION_PREF), "legacy phase pref is unset" ); await cleanup(); }); const FIREFOX_LABS_MIGRATION = NimbusMigrations.MIGRATIONS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ][0]; add_task(async function test_migration_firefoxLabsEnrollments() { async function doTest(features) { info( `Testing NimbusMigrations migrates Firefox Labs features ${JSON.stringify(features)}` ); const prefs = features.map(getEnabledPrefForFeature); for (const pref of prefs) { Services.prefs.setBoolPref(pref, true); } const { manager, cleanup } = await setupTest({ experiments: mockLabsRecipes("true"), migrations: { [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: [ FIREFOX_LABS_MIGRATION, ], }, }); Assert.deepEqual( await manager .getAllOptInRecipes() .then(recipes => recipes.map(recipe => recipe.slug).toSorted()), Object.values(LABS_MIGRATION_FEATURE_MAP).toSorted(), "The labs recipes should be available" ); for (const [feature, slug] of Object.entries(LABS_MIGRATION_FEATURE_MAP)) { const enrollmentExpected = features.includes(feature); const enrollment = manager.store.get(slug); if (enrollmentExpected) { Assert.ok( !!enrollment, `There should be an enrollment for slug ${slug}` ); const pref = getEnabledPrefForFeature(feature); Assert.equal( Services.prefs.getBoolPref(pref), true, `Pref ${pref} should be set after enrollment` ); await manager.unenroll(slug); Assert.equal( Services.prefs.getBoolPref(pref), false, `Pref ${pref} should be unset after unenrollment` ); Assert.ok( !Services.prefs.prefHasUserValue(pref), `Pref ${pref} should not be set on the user branch` ); } else { Assert.ok( !enrollment, `There should not be an enrollment for slug ${slug}` ); } } Assert.deepEqual( Glean.nimbusEvents.migration.testGetValue().map(event => event.extra), [ { success: "true", migration_id: "firefox-labs-enrollments", }, ] ); await cleanup(); } await doTest([]); for (const feature of Object.keys(LABS_MIGRATION_FEATURE_MAP)) { await doTest([feature]); } await doTest(Object.keys(LABS_MIGRATION_FEATURE_MAP)); }); add_task(async function test_migration_firefoxLabsEnrollments_falseTargeting() { // Some of the features will be limited to specific channels. // We don't need to test that targeting evaluation itself works, so we'll just // test with hardcoded targeting. info( `Testing NimbusMigration does not migrate Firefox Labs features when targeting is false` ); const prefs = Object.keys(LABS_MIGRATION_FEATURE_MAP).map( getEnabledPrefForFeature ); for (const pref of prefs) { Services.prefs.setBoolPref(pref, true); } const { manager, cleanup } = await setupTest({ experiments: mockLabsRecipes("false"), migrations: { [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: [ FIREFOX_LABS_MIGRATION, ], }, }); Assert.deepEqual( await manager.getAllOptInRecipes(), [], "There should be no opt-in recipes" ); for (const pref of prefs) { Assert.ok( Services.prefs.getBoolPref(pref), `Pref ${pref} should be unchanged` ); Services.prefs.clearUserPref(pref); } for (const slug of Object.values(LABS_MIGRATION_FEATURE_MAP)) { Assert.ok( typeof manager.store.get(slug) === "undefined", `There should be no store entry for ${slug}` ); } Assert.deepEqual( Glean.nimbusEvents.migration.testGetValue().map(event => event.extra), [ { success: "true", migration_id: "firefox-labs-enrollments", }, ] ); await cleanup(); }); add_task(async function test_migration_firefoxLabsEnrollments_idempotent() { info("Testing the firefox-labs-enrollments migration is idempotent"); const prefs = Object.keys(LABS_MIGRATION_FEATURE_MAP).map( getEnabledPrefForFeature ); for (const pref of prefs) { Services.prefs.setBoolPref(pref, true); } const recipes = mockLabsRecipes("true"); // Get the store into a partially migrated state (i.e., we have enrolled in at least one // experiment but the migration pref has not updated). { const manager = NimbusTestUtils.stubs.manager(); await manager.store.init(); await manager.onStartup(); manager.enroll(recipes[0], "rs-loader", { branchSlug: "control" }); await NimbusTestUtils.saveStore(manager.store); removePrefObservers(manager); assertNoObservers(manager); } const { manager, cleanup } = await setupTest({ experiments: recipes, migrations: { [NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE]: [ FIREFOX_LABS_MIGRATION, ], }, }); Assert.equal( Services.prefs.getIntPref( NIMBUS_MIGRATION_PREFS[ NimbusMigrations.Phase.AFTER_REMOTE_SETTINGS_UPDATE ] ), 0, "Migration pref updated" ); Assert.deepEqual( Glean.nimbusEvents.migration.testGetValue().map(ev => ev.extra), [ { migration_id: "firefox-labs-enrollments", success: "true", }, ] ); for (const { slug } of recipes) { await manager.unenroll(slug); } await cleanup(); for (const pref of prefs) { Services.prefs.clearUserPref(pref); } }); const IMPORT_TO_SQL_MIGRATION = NimbusMigrations.MIGRATIONS[ NimbusMigrations.Phase.AFTER_STORE_INITIALIZED ].find(m => m.name === "import-enrollments-to-sql"); add_task(async function testMigrateEnrollmentsToSql() { const PREFFLIPS_EXPERIMENT_VALUE = { prefs: { "foo.bar.baz": { branch: "default", value: "prefFlips-experiment-value", }, }, }; let storePath; const experiments = [ NimbusTestUtils.factories.recipe.withFeatureConfig( "experiment-1", { branchSlug: "experiment-1", featureId: "no-feature-firefox-desktop", }, { bogus: "foobar", } ), NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", { branchSlug: "experiment-2", featureId: "no-feature-firefox-desktop", }), NimbusTestUtils.factories.recipe.withFeatureConfig( "rollout-1", { featureId: "no-feature-firefox-desktop", }, { isRollout: true, isFirefoxLabsOptIn: true, firefoxLabsTitle: "title", firefoxLabsDescription: "description", firefoxLabsDescriptionLinks: { foo: "https://example.com", }, firefoxLabsGroup: "group", requiresRestart: true, } ), NimbusTestUtils.factories.recipe.withFeatureConfig( "rollout-2", { featureId: "no-feature-firefox-desktop", }, { isRollout: true } ), NimbusTestUtils.factories.recipe.withFeatureConfig("setPref-experiment", { featureId: "nimbus-qa-1", value: { value: "qa-1", }, }), NimbusTestUtils.factories.recipe.withFeatureConfig( "setPref-rollout", { featureId: "nimbus-qa-2", value: { value: "qa-2", }, }, { isRollout: true } ), ]; const secureExperiments = [ NimbusTestUtils.factories.recipe.withFeatureConfig("prefFlips-experiment", { featureId: "prefFlips", value: PREFFLIPS_EXPERIMENT_VALUE, }), NimbusTestUtils.factories.recipe.withFeatureConfig( "prefFlips-rollout", { featureId: "prefFlips", value: { prefs: {} } }, { isRollout: true } ), ]; { const store = NimbusTestUtils.stubs.store(); await store.init(); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "inactive-1", { featureId: "no-feature-firefox-desktop" }, { active: false, unenrollReason: "reason-1", source: NimbusTelemetry.EnrollmentSource.RS_LOADER, } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "inactive-2", { branchSlug: "treatment-a", featureId: "no-feature-firefox-desktop" }, { active: false, unenrollReason: "reason-2", source: NimbusTelemetry.EnrollmentSource.RS_LOADER, } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "expired-but-active", { featureId: "no-feature-firefox-desktop" }, { source: NimbusTelemetry.EnrollmentSource.RS_LOADER } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "experiment-1", { branchSlug: "experiment-1", featureId: "no-feature-firefox-desktop", }, { source: NimbusTelemetry.EnrollmentSource.RS_LOADER } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "rollout-1", { featureId: "no-feature-firefox-desktop" }, { isRollout: true, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, isFirefoxLabsOptIn: true, firefoxLabsTitle: "title", firefoxLabsDescription: "description", firefoxLabsDescriptionLinks: { foo: "https://example.com", }, firefoxLabsGroup: "group", requiresRestart: true, } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "prefFlips-experiment", { featureId: "prefFlips", value: PREFFLIPS_EXPERIMENT_VALUE, }, { source: NimbusTelemetry.EnrollmentSource.RS_LOADER, prefFlips: { originalValues: { "foo.bar.baz": "original-value", }, }, } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "setPref-experiment", { featureId: "nimbus-qa-1", value: { value: "qa-1" }, }, { source: NimbusTelemetry.EnrollmentSource.RS_LOADER, prefs: [ { name: "nimbus.qa.pref-1", branch: "default", featureId: "nimbus-qa-1", variable: "value", originalValue: "original-value", }, ], } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "devtools", { branchSlug: "devtools", featureId: "no-feature-firefox-desktop", }, { source: "nimbus-devtools" } ) ); store.addEnrollment( NimbusTestUtils.factories.experiment.withFeatureConfig( "optin", { branchSlug: "force-enroll", featureId: "no-feature-firefox-desktop", }, { source: NimbusTelemetry.EnrollmentSource.FORCE_ENROLLMENT, localizations: { "en-US": { foo: "foo", }, }, } ) ); storePath = await NimbusTestUtils.saveStore(store); } let importMigrationError = null; // We need to run our test *directly after* the migration completes, before // the rest of Nimbus has had a chance to initialize, so we know that any // changes to the database were from the migration and not, e.g., // updateRecipes(). async function testImportMigration() { try { const conn = await ProfilesDatastoreService.getConnection(); const result = await conn.execute(` SELECT profileId, slug, branchSlug, json(recipe) AS recipe, active, unenrollReason, lastSeen, json(setPrefs) AS setPrefs, json(prefFlips) AS prefFlips, source FROM NimbusEnrollments `); const dbEnrollments = Object.fromEntries( result.map(row => { const fields = [ "profileId", "slug", "branchSlug", "recipe", "active", "unenrollReason", "lastSeen", "setPrefs", "prefFlips", "source", ]; const processed = {}; for (const field of fields) { processed[field] = row.getResultByName(field); } processed.recipe = JSON.parse(processed.recipe); processed.setPrefs = JSON.parse(processed.setPrefs); processed.prefFlips = JSON.parse(processed.prefFlips); return [processed.slug, processed]; }) ); Assert.deepEqual( Object.keys(dbEnrollments).sort(), [ "inactive-1", "inactive-2", "expired-but-active", "experiment-1", "rollout-1", "setPref-experiment", "prefFlips-experiment", "devtools", "optin", ].sort(), "Should have rows for the expected enrollments" ); // The profileId is the same for every enrollment. const profileId = ExperimentAPI.profileId; function assertEnrollment(expected) { const enrollment = dbEnrollments[expected.slug]; const { slug } = expected; function msg(s) { return `${slug}: ${s}`; } Assert.equal(enrollment.slug, slug, "slug matches"); Assert.equal(enrollment.profileId, profileId, msg("profileId")); Assert.equal(enrollment.active, expected.active, msg("active")); Assert.equal( enrollment.unenrollReason, expected.unenrollReason, msg("unenrollReason") ); Assert.deepEqual( enrollment.setPrefs, expected.setPrefs, msg("setPrefs") ); Assert.deepEqual( enrollment.prefFlips, expected.prefFlips, msg("prefFlips") ); Assert.equal(enrollment.source, expected.source, msg("source")); Assert.ok( typeof enrollment.lastSeen === "string", msg("lastSeen serialized as string") ); Assert.ok( typeof enrollment.recipe === "object" && enrollment.recipe !== null, msg("recipe is object") ); const requiredRecipeFields = [ "slug", "userFacingName", "userFacingDescription", "featureIds", "isRollout", "localizations", "isFirefoxLabsOptIn", "firefoxLabsDescription", "firefoxLabsDescriptionLinks", "firefoxLabsGroup", "requiresRestart", "branches", ]; for (const recipeField of requiredRecipeFields) { Assert.ok( Object.hasOwn(enrollment.recipe, recipeField), msg(`recipe has ${recipeField} field`) ); } const storeEnrollment = ExperimentAPI.manager.store.get(slug); Assert.equal(enrollment.recipe.slug, slug, msg("recipe.slug")); Assert.equal( enrollment.recipe.isRollout, storeEnrollment.isRollout, msg("recipe.isRollout") ); Assert.ok( enrollment.recipe.branches.find( b => b.slug === enrollment.branchSlug ), msg("recipe has branch matching branchSlug") ); for (const [i, branch] of enrollment.recipe.branches.entries()) { Assert.ok( typeof branch.ratio === "number", msg(`recipe.branches[${i}].ratio is a number`) ); Assert.ok( typeof branch.features === "object" && branch.features !== null, msg(`recipe.branches[${i}].features is an object`) ); for (const featureId of enrollment.recipe.featureIds) { const idx = branch.features.findIndex( fc => fc.featureId === featureId ); Assert.ok( idx !== -1, msg( `recipe.branches[${i}].features[${idx}].featureId = ${featureId}` ) ); const featureConfig = branch.features[idx]; Assert.ok( typeof featureConfig.value === "object" && featureConfig.value !== null, msg(`recipe.branches[${i}].features[${idx}].value is an object`) ); } } } assertEnrollment({ slug: "inactive-1", branchSlug: "control", active: false, unenrollReason: "reason-1", setPrefs: null, prefFlips: null, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, }); assertEnrollment({ slug: "inactive-2", branchSlug: "treatment-a", active: false, unenrollReason: "reason-2", setPrefs: null, prefFlips: null, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, }); assertEnrollment({ slug: "expired-but-active", branchSlug: "control", active: true, unenrollReason: null, setPrefs: null, prefFlips: null, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, }); assertEnrollment({ slug: "experiment-1", branchSlug: "experiment-1", active: true, unenrollReason: null, setPrefs: null, prefFlips: null, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, }); Assert.equal( dbEnrollments["experiment-1"].recipe.bogus, "foobar", `experiment-1: entire recipe from Remote Settings is captured` ); assertEnrollment({ slug: "rollout-1", branchSlug: "control", active: true, unenrollReason: null, setPrefs: null, prefFlips: null, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, }); Assert.ok( dbEnrollments["rollout-1"].recipe.isFirefoxLabsOptIn, `rollout-1: recipe.isFirefoxLabsOptIn` ); Assert.equal( dbEnrollments["rollout-1"].recipe.firefoxLabsTitle, "title", `rollout-1: recipe.firefoxLabsTitle` ); Assert.equal( dbEnrollments["rollout-1"].recipe.firefoxLabsDescription, "description", `rollout-1: recipe.firefoxLabsDescription` ); Assert.deepEqual( dbEnrollments["rollout-1"].recipe.firefoxLabsDescriptionLinks, { foo: "https://example.com", }, `rollout-1: recipe.firefoxLabsDescriptionLinks` ); Assert.equal( dbEnrollments["rollout-1"].recipe.firefoxLabsGroup, "group", `rollout-1: recipe.firefoxLabsGroup` ); Assert.ok( dbEnrollments["rollout-1"].recipe.requiresRestart, `rollout-1: recipe.requiresRestart` ); assertEnrollment({ slug: "prefFlips-experiment", branchSlug: "control", active: true, unenrollReason: null, setPrefs: null, prefFlips: { originalValues: { "foo.bar.baz": "original-value", }, }, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, }); assertEnrollment({ slug: "setPref-experiment", branchSlug: "control", active: true, unenrollReason: null, setPrefs: [ { name: "nimbus.qa.pref-1", branch: "default", featureId: "nimbus-qa-1", variable: "value", originalValue: "original-value", }, ], prefFlips: null, source: NimbusTelemetry.EnrollmentSource.RS_LOADER, }); assertEnrollment({ slug: "devtools", branchSlug: "devtools", active: true, unenrollReason: null, setPrefs: null, prefFlips: null, source: "nimbus-devtools", }); assertEnrollment({ slug: "optin", branchSlug: "force-enroll", active: true, unenrollReason: null, setPrefs: null, prefFlips: null, source: NimbusTelemetry.EnrollmentSource.FORCE_ENROLLMENT, }); Assert.deepEqual( dbEnrollments.optin.recipe.localizations, { "en-US": { foo: "foo", }, }, "optin: localizations is captured in recipe" ); } catch (e) { importMigrationError = e; } } const { cleanup } = await setupTest({ storePath, experiments, secureExperiments, migrations: { [NimbusMigrations.Phase.AFTER_STORE_INITIALIZED]: [ IMPORT_TO_SQL_MIGRATION, { name: "test-import-migration", fn: testImportMigration }, ], }, }); // NimbusMigrations swallows errors if (importMigrationError) { throw importMigrationError; } await NimbusTestUtils.cleanupManager([ "experiment-1", "experiment-2", "rollout-1", "rollout-2", "prefFlips-experiment", "prefFlips-rollout", "setPref-experiment", "setPref-rollout", "devtools", "optin", ]); await cleanup(); // On unenroll, we should 'reset' foo.bar.baz, nimbus.qa.pref-1, and nimbus.qa.pref-2. Assert.equal( Services.prefs.getStringPref("foo.bar.baz"), "original-value", "foo.bar.baz restored" ); Assert.equal( Services.prefs.getStringPref("nimbus.qa.pref-1"), "original-value", "nimbus.qs.pref-1 restored" ); Assert.ok( !Services.prefs.prefHasUserValue("nimbus.qa.pref-2"), "nimbus.qa.pref-2 restored" ); Services.prefs.deleteBranch("foo.bar.baz"); Services.prefs.deleteBranch("nimbus.qa.pref-1"); Services.prefs.deleteBranch("nimbus.qa.pref-2"); });