"use strict"; const { Sampling } = ChromeUtils.importESModule( "resource://gre/modules/components-utils/Sampling.sys.mjs" ); const { MatchStatus } = ChromeUtils.importESModule( "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs" ); const { NimbusTelemetry } = ChromeUtils.importESModule( "resource://nimbus/lib/Telemetry.sys.mjs" ); const { UnenrollmentCause } = ChromeUtils.importESModule( "resource://nimbus/lib/ExperimentManager.sys.mjs" ); const { ProfilesDatastoreService } = ChromeUtils.importESModule( "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs" ); /** * onStartup() * - should set call setExperimentActive for each active experiment */ add_task(async function test_onStartup_setExperimentActive_called() { let storePath; { const store = NimbusTestUtils.stubs.store(); await store.init(); store.addEnrollment(NimbusTestUtils.factories.experiment("foo")); store.addEnrollment(NimbusTestUtils.factories.rollout("bar")); store.addEnrollment( NimbusTestUtils.factories.experiment("baz", { active: false }) ); store.addEnrollment( NimbusTestUtils.factories.rollout("qux", { active: false }) ); storePath = await NimbusTestUtils.saveStore(store); } const { sandbox, manager, initExperimentAPI, cleanup } = await NimbusTestUtils.setupTest({ storePath, init: false }); sandbox.stub(NimbusTelemetry, "setExperimentActive"); await initExperimentAPI(); Assert.ok( NimbusTelemetry.setExperimentActive.calledWith(sinon.match({ slug: "foo" })) ); Assert.ok( NimbusTelemetry.setExperimentActive.calledWith(sinon.match({ slug: "bar" })) ); Assert.ok( !NimbusTelemetry.setExperimentActive.calledWith( sinon.match({ slug: "baz" }) ) ); Assert.ok( !NimbusTelemetry.setExperimentActive.calledWith( sinon.match({ slug: "qux" }) ) ); await manager.unenroll("foo"); await manager.unenroll("bar"); await cleanup(); }); add_task(async function test_startup_unenroll() { Services.prefs.setBoolPref("app.shield.optoutstudies.enabled", false); let storePath; { const store = NimbusTestUtils.stubs.store(); await store.init(); store.addEnrollment( NimbusTestUtils.factories.experiment("startup_unenroll") ); storePath = await NimbusTestUtils.saveStore(store); } const { sandbox, manager, initExperimentAPI, cleanup } = await NimbusTestUtils.setupTest({ storePath, init: false }); sandbox.spy(manager, "_unenroll"); await initExperimentAPI(); Assert.ok( manager._unenroll.calledOnceWith( sinon.match({ slug: "startup_unenroll" }), { reason: "studies-opt-out", } ), "Called unenroll for expected recipe" ); Services.prefs.clearUserPref("app.shield.optoutstudies.enabled"); await cleanup(); }); add_task(async function test_onRecipe_enroll() { const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest(); sandbox.stub(manager, "isInBucketAllocation").resolves(true); sandbox.stub(Sampling, "bucketSample").resolves(true); sandbox.spy(manager, "enroll"); sandbox.spy(manager, "updateEnrollment"); const recipe = NimbusTestUtils.factories.recipe("foo"); Assert.deepEqual( manager.store.getAllActiveExperiments(), [], "There should be no active experiments" ); await manager.onRecipe(recipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING, }); Assert.equal( manager.enroll.calledWith(recipe), true, "should call .enroll() the first time a recipe is seen" ); Assert.equal( manager.store.has("foo"), true, "should add recipe to the store" ); await manager.unenroll(recipe.slug); await cleanup(); }); add_task(async function test_onRecipe_update() { const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest(); sandbox.spy(manager, "enroll"); sandbox.spy(manager, "updateEnrollment"); const recipe = NimbusTestUtils.factories.recipe("foo"); await manager.store.init(); await manager.onStartup(); await manager.enroll(recipe, "test"); await manager.onRecipe(recipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING, }); Assert.equal( manager.updateEnrollment.calledWith( sinon.match({ slug: recipe.slug }), recipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING, } ), true, "should call .updateEnrollment() if the recipe has already been enrolled" ); await manager.unenroll(recipe.slug); await cleanup(); }); add_task(async function test_onRecipe_rollout_update() { const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest(); sandbox.spy(manager, "enroll"); sandbox.spy(manager, "_unenroll"); sandbox.spy(manager, "updateEnrollment"); const recipe = NimbusTestUtils.factories.recipe("foo", { isRollout: true }); await manager.enroll(recipe, "test"); await manager.onRecipe(recipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING, }); Assert.ok( manager.updateEnrollment.calledOnceWith( sinon.match({ slug: recipe.slug }), recipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING } ), "should call .updateEnrollment() if the recipe has already been enrolled" ); Assert.ok( manager.updateEnrollment.alwaysReturned(Promise.resolve(true)), "updateEnrollment will confirm the enrolled branch still exists in the recipe and exit" ); Assert.ok( manager._unenroll.notCalled, "Should not call if the branches did not change" ); manager.updateEnrollment.resetHistory(); const updatedRecipe = NimbusTestUtils.factories.recipe(recipe.slug, { isRollout: true, branches: [ { ...recipe.branches[0], slug: "control-v2", }, ], }); await manager.onRecipe(updatedRecipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING, }); Assert.ok( manager.updateEnrollment.calledOnceWith( sinon.match({ slug: recipe.slug }), updatedRecipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING } ), "should call .updateEnrollment() if the recipe has already been enrolled" ); Assert.ok( manager._unenroll.calledOnceWith(sinon.match({ slug: recipe.slug }), { reason: "branch-removed", }), "updateEnrollment will unenroll because the branch slug changed" ); await cleanup(); }); add_task(async function test_onRecipe_isFirefoxLabsOptin_recipe() { const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest(); sandbox.stub(manager, "enroll"); const optInRecipe = NimbusTestUtils.factories.recipe("opt-in", { isFirefoxLabsOptIn: true, firefoxLabsTitle: "title", firefoxLabsDescription: "description", firefoxLabsDescriptionLinks: null, firefoxLabsGroup: "group", requiresRestart: false, }); const recipe = NimbusTestUtils.factories.recipe("recipe"); await manager.onRecipe(optInRecipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING, }); await manager.onRecipe(recipe, "test", { ok: true, status: MatchStatus.TARGETING_AND_BUCKETING, }); Assert.equal( manager.optInRecipes.length, 1, "should only have one opt-in recipe" ); Assert.equal( manager.optInRecipes[0], optInRecipe, "should add the recipe to OptInRecipes list if recipe is firefox labs opt-in" ); Assert.equal( manager.enroll.calledOnceWith(recipe, "test"), true, "should try to enroll the fxLabsOptOutRecipe since it is a targetting match" ); await cleanup(); }); add_task(async function test_context_paramters() { const { manager, cleanup } = await NimbusTestUtils.setupTest(); const experiment = NimbusTestUtils.factories.recipe("experiment"); const rollout = NimbusTestUtils.factories.recipe("rollout", { isRollout: true, }); let targetingCtx = manager.createTargetingContext(); Assert.deepEqual(await targetingCtx.activeExperiments, []); Assert.deepEqual(await targetingCtx.activeRollouts, []); Assert.deepEqual(await targetingCtx.previousExperiments, []); Assert.deepEqual(await targetingCtx.previousRollouts, []); Assert.deepEqual(await targetingCtx.enrollments, []); await manager.enroll(experiment, "test"); await manager.enroll(rollout, "test"); targetingCtx = manager.createTargetingContext(); Assert.deepEqual(await targetingCtx.activeExperiments, ["experiment"]); Assert.deepEqual(await targetingCtx.activeRollouts, ["rollout"]); Assert.deepEqual(await targetingCtx.previousExperiments, []); Assert.deepEqual(await targetingCtx.previousRollouts, []); Assert.deepEqual([...(await targetingCtx.enrollments)].sort(), [ "experiment", "rollout", ]); await manager.unenroll(experiment.slug); await manager.unenroll(rollout.slug); targetingCtx = manager.createTargetingContext(); Assert.deepEqual(await targetingCtx.activeExperiments, []); Assert.deepEqual(await targetingCtx.activeRollouts, []); Assert.deepEqual(await targetingCtx.previousExperiments, ["experiment"]); Assert.deepEqual(await targetingCtx.previousRollouts, ["rollout"]); Assert.deepEqual([...(await targetingCtx.enrollments)].sort(), [ "experiment", "rollout", ]); await cleanup(); }); add_task(async function test_experimentStore_updateEvent() { const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest(); const stub = sandbox.stub(); manager.store.on("update", stub); await manager.enroll( NimbusTestUtils.factories.recipe("experiment"), "rs-loader" ); Assert.ok( stub.calledOnceWith("update", { slug: "experiment", active: true }) ); stub.resetHistory(); await manager.unenroll( "experiment", UnenrollmentCause.fromReason( NimbusTelemetry.UnenrollReason.INDIVIDUAL_OPT_OUT ) ); Assert.ok( stub.calledOnceWith("update", { slug: "experiment", active: false, unenrollReason: "individual-opt-out", }) ); await cleanup(); }); add_task(async function testDb() { const conn = await ProfilesDatastoreService.getConnection(); function processRow(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; } async function getEnrollment(slug) { const results = 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 WHERE slug = :slug AND profileId = :profileId; `, { slug, profileId: ExperimentAPI.profileId } ); Assert.equal( results.length, 1, `Exactly one enrollment should be returned for ${slug}` ); return processRow(results[0]); } async function getEnrollmentSlugs() { const result = await conn.execute( ` SELECT slug FROM NimbusEnrollments WHERE profileId = :profileId; `, { profileId: ExperimentAPI.profileId } ); return result.map(row => row.getResultByName("slug")).sort(); } const { manager, cleanup } = await NimbusTestUtils.setupTest(); const experimentRecipe = NimbusTestUtils.factories.recipe("experiment", { branches: [ { ratio: 1, slug: "control", features: [ { featureId: "no-feature-firefox-desktop", value: {}, }, ], }, { ratio: 0, // Force enrollment in control slug: "treatment", features: [ { featureId: "no-feature-firefox-desktop", value: {}, }, ], }, ], }); const rolloutRecipe = NimbusTestUtils.factories.recipe.withFeatureConfig( "rollout", { branchSlug: "rollout", featureId: "no-feature-firefox-desktop" } ); Assert.deepEqual( await getEnrollmentSlugs(), [], "There are no database entries" ); // Enroll in an experiment await manager.enroll(experimentRecipe, "test"); Assert.deepEqual( await getEnrollmentSlugs(), [experimentRecipe.slug], "There is one enrollment" ); let experimentEnrollment = await getEnrollment(experimentRecipe.slug); Assert.ok(experimentEnrollment.active, "experiment enrollment is active"); Assert.deepEqual( experimentEnrollment.recipe, experimentRecipe, "experiment enrollment has the correct recipe" ); Assert.equal( experimentEnrollment.branchSlug, manager.store.get(experimentRecipe.slug).branch.slug, "experiment branch slug matches" ); // Enroll in a rollout. await manager.enroll(rolloutRecipe, "test"); Assert.deepEqual( await getEnrollmentSlugs(), [experimentRecipe.slug, rolloutRecipe.slug].sort(), "There are two enrollments" ); let rolloutEnrollment = await getEnrollment(rolloutRecipe.slug); Assert.ok(rolloutEnrollment.active, "rollout enrollment is active"); Assert.deepEqual( rolloutEnrollment.recipe, rolloutRecipe, "rollout enrollment has the correct recipe" ); Assert.equal( rolloutEnrollment.branchSlug, manager.store.get(rolloutRecipe.slug).branch.slug, "rollout branch slug matches" ); // Unenroll from the rollout. await manager.unenroll(rolloutRecipe.slug, { reason: "recipe-not-seen" }); Assert.deepEqual( await getEnrollmentSlugs(), [experimentRecipe.slug, rolloutRecipe.slug].sort(), "There are two enrollments" ); rolloutEnrollment = await getEnrollment(rolloutRecipe.slug); Assert.ok(!rolloutEnrollment.active, "rollout enrollment is inactive"); Assert.equal( rolloutEnrollment.recipe, null, "rollout enrollment recipe is null" ); Assert.equal( rolloutEnrollment.unenrollReason, "recipe-not-seen", "rollout unenrollReason" ); Assert.equal( rolloutEnrollment.branchSlug, manager.store.get(rolloutRecipe.slug).branch.slug, "rollout branch slug matches" ); // Unenroll from the experiment. await manager.unenroll(experimentEnrollment.slug, { reason: "targeting" }); experimentEnrollment = await getEnrollment(experimentRecipe.slug); Assert.ok(!experimentEnrollment.active, "experiment enrollment is inactive"); Assert.equal( experimentEnrollment.recipe, null, "experiment enrollment recipe is null" ); Assert.equal( experimentEnrollment.unenrollReason, "targeting", "experiment unenrollReason" ); Assert.equal( experimentEnrollment.branchSlug, manager.store.get(experimentRecipe.slug).branch.slug, "experiment branch slug matches" ); await cleanup(); });