"use strict"; const { ExperimentAPI } = ChromeUtils.importESModule( "resource://nimbus/ExperimentAPI.sys.mjs" ); const { ExperimentFakes } = ChromeUtils.importESModule( "resource://testing-common/NimbusTestUtils.sys.mjs" ); const { TestUtils } = ChromeUtils.importESModule( "resource://testing-common/TestUtils.sys.mjs" ); const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id"; /** * #getExperiment */ add_task(async function test_getExperiment_fromChild_slug() { const sandbox = sinon.createSandbox(); const manager = ExperimentFakes.manager(); const expected = ExperimentFakes.experiment("foo"); await manager.onStartup(); sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore()); await manager.store.addEnrollment(expected); // Wait to sync to child await TestUtils.waitForCondition( () => ExperimentAPI.getExperiment({ slug: "foo" }), "Wait for child to sync" ); Assert.equal( ExperimentAPI.getExperiment({ slug: "foo" }).slug, expected.slug, "should return an experiment by slug" ); Assert.deepEqual( ExperimentAPI.getExperiment({ slug: "foo" }).branch, expected.branch, "should return the right branch by slug" ); sandbox.restore(); }); add_task(async function test_getExperiment_fromParent_slug() { const sandbox = sinon.createSandbox(); const manager = ExperimentFakes.manager(); const expected = ExperimentFakes.experiment("foo"); await manager.onStartup(); sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); await ExperimentAPI.ready(); await manager.store.addEnrollment(expected); Assert.equal( ExperimentAPI.getExperiment({ slug: "foo" }).slug, expected.slug, "should return an experiment by slug" ); sandbox.restore(); }); add_task(async function test_getExperimentMetaData() { const sandbox = sinon.createSandbox(); const manager = ExperimentFakes.manager(); const expected = ExperimentFakes.experiment("foo"); let exposureStub = sandbox.stub(ExperimentAPI, "recordExposureEvent"); await manager.onStartup(); sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); await ExperimentAPI.ready(); await manager.store.addEnrollment(expected); let metadata = ExperimentAPI.getExperimentMetaData({ slug: expected.slug }); Assert.equal( Object.keys(metadata.branch).length, 1, "Should only expose one property" ); Assert.equal( metadata.branch.slug, expected.branch.slug, "Should have the slug prop" ); Assert.ok(exposureStub.notCalled, "Not called for this method"); sandbox.restore(); }); add_task(async function test_getRolloutMetaData() { const sandbox = sinon.createSandbox(); const manager = ExperimentFakes.manager(); const expected = ExperimentFakes.rollout("foo"); let exposureStub = sandbox.stub(ExperimentAPI, "recordExposureEvent"); await manager.onStartup(); sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); await ExperimentAPI.ready(); await manager.store.addEnrollment(expected); let metadata = ExperimentAPI.getExperimentMetaData({ slug: expected.slug }); Assert.equal( Object.keys(metadata.branch).length, 1, "Should only expose one property" ); Assert.equal( metadata.branch.slug, expected.branch.slug, "Should have the slug prop" ); Assert.ok(exposureStub.notCalled, "Not called for this method"); sandbox.restore(); }); add_task(function test_getExperimentMetaData_safe() { const sandbox = sinon.createSandbox(); let exposureStub = sandbox.stub(ExperimentAPI, "recordExposureEvent"); sandbox.stub(ExperimentAPI._store, "get").throws(); sandbox.stub(ExperimentAPI._store, "getExperimentForFeature").throws(); try { let metadata = ExperimentAPI.getExperimentMetaData({ slug: "foo" }); Assert.equal(metadata, null, "Should not throw"); } catch (e) { Assert.ok(false, "Error should be caught in ExperimentAPI"); } Assert.ok(ExperimentAPI._store.get.calledOnce, "Sanity check"); try { let metadata = ExperimentAPI.getExperimentMetaData({ featureId: "foo" }); Assert.equal(metadata, null, "Should not throw"); } catch (e) { Assert.ok(false, "Error should be caught in ExperimentAPI"); } Assert.ok( ExperimentAPI._store.getExperimentForFeature.calledOnce, "Sanity check" ); Assert.ok(exposureStub.notCalled, "Not called for this feature"); sandbox.restore(); }); add_task(async function test_getExperiment_feature() { const sandbox = sinon.createSandbox(); const manager = ExperimentFakes.manager(); const expected = ExperimentFakes.experiment("foo", { branch: { slug: "treatment", features: [{ featureId: "cfr", value: null }], feature: { featureId: "unused-feature-id-for-legacy-support", enabled: false, value: {}, }, }, }); await manager.onStartup(); sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore()); let exposureStub = sandbox.stub(ExperimentAPI, "recordExposureEvent"); await manager.store.addEnrollment(expected); // Wait to sync to child await TestUtils.waitForCondition( () => ExperimentAPI.getExperiment({ featureId: "cfr" }), "Wait for child to sync" ); Assert.equal( ExperimentAPI.getExperiment({ featureId: "cfr" }).slug, expected.slug, "should return an experiment by featureId" ); Assert.deepEqual( ExperimentAPI.getExperiment({ featureId: "cfr" }).branch, expected.branch, "should return the right branch by featureId" ); Assert.ok(exposureStub.notCalled, "Not called by default"); sandbox.restore(); }); add_task(async function test_getExperiment_safe() { const sandbox = sinon.createSandbox(); sandbox.stub(ExperimentAPI._store, "getExperimentForFeature").throws(); try { Assert.equal( ExperimentAPI.getExperiment({ featureId: "foo" }), null, "It should not fail even when it throws." ); } catch (e) { Assert.ok(false, "Error should be caught by ExperimentAPI"); } sandbox.restore(); }); add_task(async function test_getExperiment_featureAccess() { const sandbox = sinon.createSandbox(); const expected = ExperimentFakes.experiment("foo", { branch: { slug: "treatment", value: { title: "hi" }, features: [{ featureId: "cfr", value: { message: "content" } }], }, }); const stub = sandbox .stub(ExperimentAPI._store, "getExperimentForFeature") .returns(expected); let { branch } = ExperimentAPI.getExperiment({ featureId: "cfr" }); Assert.equal(branch.slug, "treatment"); let feature = branch.cfr; Assert.ok(feature, "Should allow to access by featureId"); Assert.equal(feature.value.message, "content"); stub.restore(); }); add_task(async function test_getExperiment_featureAccess_backwardsCompat() { const sandbox = sinon.createSandbox(); const expected = ExperimentFakes.experiment("foo", { branch: { slug: "treatment", feature: { featureId: "cfr", value: { message: "content" } }, }, }); const stub = sandbox .stub(ExperimentAPI._store, "getExperimentForFeature") .returns(expected); let { branch } = ExperimentAPI.getExperiment({ featureId: "cfr" }); Assert.equal(branch.slug, "treatment"); let feature = branch.cfr; Assert.ok(feature, "Should allow to access by featureId"); Assert.equal(feature.value.message, "content"); stub.restore(); }); /** * #getRecipe */ add_task(async function test_getRecipe() { const sandbox = sinon.createSandbox(); const RECIPE = ExperimentFakes.recipe("foo"); const collectionName = Services.prefs.getStringPref(COLLECTION_ID_PREF); sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]); const recipe = await ExperimentAPI.getRecipe("foo"); Assert.deepEqual( recipe, RECIPE, "should return an experiment recipe if found" ); Assert.equal( ExperimentAPI._remoteSettingsClient.collectionName, collectionName, "Loaded the expected collection" ); sandbox.restore(); }); add_task(async function test_getRecipe_Failure() { const sandbox = sinon.createSandbox(); sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").throws(); const recipe = await ExperimentAPI.getRecipe("foo"); Assert.equal(recipe, undefined, "should return undefined if RS throws"); sandbox.restore(); }); /** * #getAllBranches */ add_task(async function test_getAllBranches() { const sandbox = sinon.createSandbox(); const RECIPE = ExperimentFakes.recipe("foo"); sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]); const branches = await ExperimentAPI.getAllBranches("foo"); Assert.deepEqual( branches, RECIPE.branches, "should return all branches if found a recipe" ); sandbox.restore(); }); // API used by Messaging System add_task(async function test_getAllBranches_featureIdAccessor() { const sandbox = sinon.createSandbox(); const RECIPE = ExperimentFakes.recipe("foo"); sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]); const branches = await ExperimentAPI.getAllBranches("foo"); Assert.deepEqual( branches, RECIPE.branches, "should return all branches if found a recipe" ); branches.forEach(branch => { Assert.equal( branch.testFeature.featureId, "testFeature", "Should use the experimentBranchAccessor proxy getter" ); }); sandbox.restore(); }); // For schema version before 1.6.2 branch.feature was accessed // instead of branch.features add_task(async function test_getAllBranches_backwardsCompat() { const sandbox = sinon.createSandbox(); const RECIPE = ExperimentFakes.recipe("foo"); delete RECIPE.branches[0].features; delete RECIPE.branches[1].features; let feature = { featureId: "backwardsCompat", value: { enabled: true, }, }; RECIPE.branches[0].feature = feature; RECIPE.branches[1].feature = feature; sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]); const branches = await ExperimentAPI.getAllBranches("foo"); Assert.deepEqual( branches, RECIPE.branches, "should return all branches if found a recipe" ); branches.forEach(branch => { Assert.equal( branch.backwardsCompat.featureId, "backwardsCompat", "Should use the experimentBranchAccessor proxy getter" ); }); sandbox.restore(); }); add_task(async function test_getAllBranches_Failure() { const sandbox = sinon.createSandbox(); sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").throws(); const branches = await ExperimentAPI.getAllBranches("foo"); Assert.equal(branches, undefined, "should return undefined if RS throws"); sandbox.restore(); }); /** * Store events */ add_task(async function test_addEnrollment_eventEmit_add() { const sandbox = sinon.createSandbox(); const slugStub = sandbox.stub(); const featureStub = sandbox.stub(); const experiment = ExperimentFakes.experiment("foo", { branch: { slug: "variant", features: [{ featureId: "purple", value: null }], }, }); const store = ExperimentFakes.store(); sandbox.stub(ExperimentAPI, "_store").get(() => store); await store.init(); await ExperimentAPI.ready(); store.on("update:foo", slugStub); store.on("featureUpdate:purple", featureStub); await store.addEnrollment(experiment); Assert.equal( slugStub.callCount, 1, "should call 'update' callback for slug when experiment is added" ); Assert.equal(slugStub.firstCall.args[1].slug, experiment.slug); Assert.equal( featureStub.callCount, 1, "should call 'featureUpdate' callback for featureId when an experiment is added" ); Assert.equal(featureStub.firstCall.args[0], "featureUpdate:purple"); Assert.equal(featureStub.firstCall.args[1], "experiment-updated"); store.off("update:foo", slugStub); store.off("featureUpdate:purple", featureStub); sandbox.restore(); }); add_task(async function test_updateExperiment_eventEmit_add_and_update() { const sandbox = sinon.createSandbox(); const slugStub = sandbox.stub(); const featureStub = sandbox.stub(); const experiment = ExperimentFakes.experiment("foo", { branch: { slug: "variant", features: [{ featureId: "purple", value: null }], }, }); const store = ExperimentFakes.store(); sandbox.stub(ExperimentAPI, "_store").get(() => store); await store.init(); await ExperimentAPI.ready(); await store.addEnrollment(experiment); store.on("update:foo", slugStub); store._onFeatureUpdate("purple", featureStub); store.updateExperiment(experiment.slug, experiment); await TestUtils.waitForCondition( () => featureStub.callCount == 2, "Wait for `on` method to notify callback about the `add` event." ); // Called twice, once when attaching the event listener (because there is an // existing experiment with that name) and 2nd time for the update event Assert.equal(slugStub.firstCall.args[1].slug, experiment.slug); Assert.equal(featureStub.callCount, 2, "Called twice for feature"); Assert.equal(featureStub.firstCall.args[0], "featureUpdate:purple"); Assert.equal(featureStub.firstCall.args[1], "experiment-updated"); store.off("update:foo", slugStub); store._offFeatureUpdate("featureUpdate:purple", featureStub); }); add_task(async function test_updateExperiment_eventEmit_off() { const sandbox = sinon.createSandbox(); const slugStub = sandbox.stub(); const featureStub = sandbox.stub(); const experiment = ExperimentFakes.experiment("foo", { branch: { slug: "variant", features: [{ featureId: "purple", value: null }], }, }); const store = ExperimentFakes.store(); sandbox.stub(ExperimentAPI, "_store").get(() => store); await store.init(); await ExperimentAPI.ready(); store.on("update:foo", slugStub); store.on("featureUpdate:purple", featureStub); await store.addEnrollment(experiment); store.off("update:foo", slugStub); store.off("featureUpdate:purple", featureStub); store.updateExperiment(experiment.slug, experiment); Assert.equal(slugStub.callCount, 1, "Called only once before `off`"); Assert.equal(featureStub.callCount, 1, "Called only once before `off`"); sandbox.restore(); }); add_task(async function test_getActiveBranch() { const sandbox = sinon.createSandbox(); const store = ExperimentFakes.store(); sandbox.stub(ExperimentAPI, "_store").get(() => store); const experiment = ExperimentFakes.experiment("foo", { branch: { slug: "variant", features: [{ featureId: "green", value: null }], }, }); await store.init(); await store.addEnrollment(experiment); Assert.deepEqual( ExperimentAPI.getActiveBranch({ featureId: "green" }), experiment.branch, "Should return feature of active experiment" ); sandbox.restore(); }); add_task(async function test_getActiveBranch_safe() { const sandbox = sinon.createSandbox(); sandbox.stub(ExperimentAPI._store, "getAllActiveExperiments").throws(); try { Assert.equal( ExperimentAPI.getActiveBranch({ featureId: "green" }), null, "Should not throw" ); } catch (e) { Assert.ok(false, "Should catch error in ExperimentAPI"); } sandbox.restore(); }); add_task(async function test_getActiveBranch_storeFailure() { const store = ExperimentFakes.store(); const sandbox = sinon.createSandbox(); sandbox.stub(ExperimentAPI, "_store").get(() => store); const experiment = ExperimentFakes.experiment("foo", { branch: { slug: "variant", features: [{ featureId: "green" }], }, }); await store.init(); await store.addEnrollment(experiment); // Adding stub later because `addEnrollment` emits update events const stub = sandbox.stub(store, "emit"); // Call getActiveBranch to trigger an activation event sandbox.stub(store, "getAllActiveExperiments").throws(); try { ExperimentAPI.getActiveBranch({ featureId: "green" }); } catch (e) { /* This is expected */ } Assert.equal(stub.callCount, 0, "Not called if store somehow fails"); sandbox.restore(); }); add_task(async function test_getActiveBranch_noActivationEvent() { const store = ExperimentFakes.store(); const sandbox = sinon.createSandbox(); sandbox.stub(ExperimentAPI, "_store").get(() => store); const experiment = ExperimentFakes.experiment("foo", { branch: { slug: "variant", features: [{ featureId: "green" }], }, }); await store.init(); await store.addEnrollment(experiment); // Adding stub later because `addEnrollment` emits update events const stub = sandbox.stub(store, "emit"); // Call getActiveBranch to trigger an activation event ExperimentAPI.getActiveBranch({ featureId: "green" }); Assert.equal(stub.callCount, 0, "Not called: sendExposureEvent is false"); sandbox.restore(); });