diff options
Diffstat (limited to 'toolkit/components/messaging-system/test')
18 files changed, 2571 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/test/MSTestUtils.jsm b/toolkit/components/messaging-system/test/MSTestUtils.jsm new file mode 100644 index 0000000000..6576319ef1 --- /dev/null +++ b/toolkit/components/messaging-system/test/MSTestUtils.jsm @@ -0,0 +1,145 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +Cu.importGlobalProperties(["fetch"]); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + _ExperimentManager: + "resource://messaging-system/experiments/ExperimentManager.jsm", + ExperimentStore: + "resource://messaging-system/experiments/ExperimentStore.jsm", + NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm", + FileTestUtils: "resource://testing-common/FileTestUtils.jsm", + _RemoteSettingsExperimentLoader: + "resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm", + Ajv: "resource://testing-common/ajv-4.1.1.js", +}); + +const PATH = FileTestUtils.getTempFile("shared-data-map").path; + +XPCOMUtils.defineLazyGetter(this, "fetchExperimentSchema", async () => { + const response = await fetch( + "resource://testing-common/NimbusExperiment.schema.json" + ); + const schema = await response.json(); + if (!schema) { + throw new Error("Failed to load NimbusSchema"); + } + return schema.definitions.NimbusExperiment; +}); + +const EXPORTED_SYMBOLS = ["ExperimentTestUtils", "ExperimentFakes"]; + +const ExperimentTestUtils = { + /** + * Checks if an experiment is valid acording to existing schema + * @param {NimbusExperiment} experiment + */ + async validateExperiment(experiment) { + const schema = await fetchExperimentSchema; + const ajv = new Ajv({ async: "co*", allErrors: true }); + const validator = ajv.compile(schema); + validator(experiment); + if (validator.errors?.length) { + throw new Error( + "Experiment not valid:" + JSON.stringify(validator.errors, undefined, 2) + ); + } + return experiment; + }, +}; + +const ExperimentFakes = { + manager(store) { + return new _ExperimentManager({ store: store || this.store() }); + }, + store() { + return new ExperimentStore("FakeStore", { path: PATH, isParent: true }); + }, + waitForExperimentUpdate(ExperimentAPI, options) { + if (!options) { + throw new Error("Must specify an expected recipe update"); + } + + return new Promise(resolve => ExperimentAPI.on("update", options, resolve)); + }, + childStore() { + return new ExperimentStore("FakeStore", { isParent: false }); + }, + rsLoader() { + const loader = new _RemoteSettingsExperimentLoader(); + // Replace RS client with a fake + Object.defineProperty(loader, "remoteSettingsClient", { + value: { get: () => Promise.resolve([]) }, + }); + // Replace xman with a fake + loader.manager = this.manager(); + + return loader; + }, + experiment(slug, props = {}) { + return { + slug, + active: true, + enrollmentId: NormandyUtils.generateUuid(), + branch: { + slug: "treatment", + feature: { + featureId: "aboutwelcome", + enabled: true, + value: { title: "hello" }, + }, + ...props, + }, + source: "test", + isEnrollmentPaused: true, + ...props, + }; + }, + recipe(slug = NormandyUtils.generateUuid(), props = {}) { + return { + // This field is required for populating remote settings + id: NormandyUtils.generateUuid(), + slug, + isEnrollmentPaused: false, + probeSets: [], + startDate: null, + endDate: null, + proposedEnrollment: 7, + referenceBranch: "control", + application: "firefox-desktop", + branches: [ + { + slug: "control", + ratio: 1, + feature: { featureId: "aboutwelcome", enabled: true, value: null }, + }, + { + slug: "treatment", + ratio: 1, + feature: { + featureId: "aboutwelcome", + enabled: true, + value: { title: "hello" }, + }, + }, + ], + bucketConfig: { + namespace: "mstest-utils", + randomizationUnit: "normandy_id", + start: 0, + count: 100, + total: 1000, + }, + userFacingName: "Messaging System recipe", + userFacingDescription: "Messaging System MSTestUtils recipe", + ...props, + }; + }, +}; diff --git a/toolkit/components/messaging-system/test/browser/browser.ini b/toolkit/components/messaging-system/test/browser/browser.ini new file mode 100644 index 0000000000..e1fbd0181a --- /dev/null +++ b/toolkit/components/messaging-system/test/browser/browser.ini @@ -0,0 +1,5 @@ +[DEFAULT] + +[browser_experimentstore_load.js] +[browser_remotesettings_experiment_enroll.js] +tags = remote-settings diff --git a/toolkit/components/messaging-system/test/browser/browser_experimentstore_load.js b/toolkit/components/messaging-system/test/browser/browser_experimentstore_load.js new file mode 100644 index 0000000000..50b3953241 --- /dev/null +++ b/toolkit/components/messaging-system/test/browser/browser_experimentstore_load.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentStore } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentStore.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "JSONFile", + "resource://gre/modules/JSONFile.jsm" +); + +function getPath() { + const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path; + // NOTE: If this test is failing because you have updated this path in `ExperimentStore`, + // users will lose their old experiment data. You should do something to migrate that data. + return PathUtils.join(profileDir, "ExperimentStoreData.json"); +} + +// Ensure that data persisted to disk is succesfully loaded by the store. +// We write data to the expected location in the user profile and +// instantiate an ExperimentStore that should then see the value. +add_task(async function test_loadFromFile() { + const previousSession = new JSONFile({ path: getPath() }); + await previousSession.load(); + previousSession.data.test = true; + previousSession.saveSoon(); + await previousSession.finalize(); + + // Create a store and expect to load data from previous session + const store = new ExperimentStore(); + await store.init(); + + Assert.ok( + store.get("test"), + "This should pass if the correct store path loaded successfully" + ); +}); diff --git a/toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js b/toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js new file mode 100644 index 0000000000..d344f7d9b4 --- /dev/null +++ b/toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js @@ -0,0 +1,111 @@ +"use strict"; + +const { RemoteSettings } = ChromeUtils.import( + "resource://services-settings/remote-settings.js" +); +const { RemoteSettingsExperimentLoader } = ChromeUtils.import( + "resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm" +); +const { ExperimentAPI } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentAPI.jsm" +); +const { ExperimentManager } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentManager.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); + +let rsClient; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["messaging-system.log", "all"], + ["app.shield.optoutstudies.enabled", true], + ], + }); + rsClient = RemoteSettings("nimbus-desktop-experiments"); + + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + await rsClient.db.clear(); + }); +}); + +add_task(async function test_experimentEnrollment() { + // Need to randomize the slug so subsequent test runs don't skip enrollment + // due to a conflicting slug + const recipe = ExperimentFakes.recipe("foo" + Date.now(), { + bucketConfig: { + start: 0, + // Make sure the experiment enrolls + count: 10000, + total: 10000, + namespace: "mochitest", + randomizationUnit: "normandy_id", + }, + }); + await rsClient.db.importChanges({}, 42, [recipe], { + clear: true, + }); + + let waitForExperimentEnrollment = ExperimentFakes.waitForExperimentUpdate( + ExperimentAPI, + { slug: recipe.slug } + ); + RemoteSettingsExperimentLoader.updateRecipes("mochitest"); + + await waitForExperimentEnrollment; + + let experiment = ExperimentAPI.getExperiment({ + slug: recipe.slug, + }); + + Assert.ok(experiment.active, "Should be enrolled in the experiment"); + + let waitForExperimentUnenrollment = ExperimentFakes.waitForExperimentUpdate( + ExperimentAPI, + { slug: recipe.slug } + ); + ExperimentManager.unenroll(recipe.slug, "mochitest-cleanup"); + + await waitForExperimentUnenrollment; + + experiment = ExperimentAPI.getExperiment({ + slug: recipe.slug, + }); + + Assert.ok(!experiment.active, "Experiment is no longer active"); +}); + +add_task(async function test_experimentEnrollment_startup() { + // Studies pref can turn the feature off but if the feature pref is off + // then it stays off. + await SpecialPowers.pushPrefEnv({ + set: [ + ["messaging-system.rsexperimentloader.enabled", false], + ["app.shield.optoutstudies.enabled", false], + ], + }); + + Assert.ok(!RemoteSettingsExperimentLoader.enabled, "Should be disabled"); + + await SpecialPowers.pushPrefEnv({ + set: [["app.shield.optoutstudies.enabled", true]], + }); + + Assert.ok( + !RemoteSettingsExperimentLoader.enabled, + "Should still be disabled (feature pref is off)" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["messaging-system.rsexperimentloader.enabled", true]], + }); + + Assert.ok( + RemoteSettingsExperimentLoader.enabled, + "Should finally be enabled" + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/head.js b/toolkit/components/messaging-system/test/unit/head.js new file mode 100644 index 0000000000..085386dc49 --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/head.js @@ -0,0 +1,5 @@ +"use strict"; +// Globals + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); diff --git a/toolkit/components/messaging-system/test/unit/reference_aboutwelcome_experiment_content.json b/toolkit/components/messaging-system/test/unit/reference_aboutwelcome_experiment_content.json new file mode 100644 index 0000000000..8966140c34 --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/reference_aboutwelcome_experiment_content.json @@ -0,0 +1,190 @@ +{ + "id": "msw-late-setdefault", + "template": "multistage", + "screens": [ + { + "id": "AW_GET_STARTED", + "order": 0, + "content": { + "zap": true, + "title": { + "string_id": "onboarding-multistage-welcome-header" + }, + "subtitle": { + "string_id": "onboarding-multistage-welcome-subtitle" + }, + "primary_button": { + "label": { + "string_id": "onboarding-multistage-welcome-primary-button-label" + }, + "action": { + "navigate": true + } + }, + "secondary_button": { + "text": { + "string_id": "onboarding-multistage-welcome-secondary-button-text" + }, + "label": { + "string_id": "onboarding-multistage-welcome-secondary-button-label" + }, + "position": "top", + "action": { + "type": "SHOW_FIREFOX_ACCOUNTS", + "addFlowParams": true, + "data": { + "entrypoint": "activity-stream-firstrun" + } + } + } + } + }, + { + "id": "AW_IMPORT_SETTINGS", + "order": 1, + "content": { + "zap": true, + "disclaimer": { + "string_id": "onboarding-import-sites-disclaimer" + }, + "title": { + "string_id": "onboarding-multistage-import-header" + }, + "subtitle": { + "string_id": "onboarding-multistage-import-subtitle" + }, + "tiles": { + "type": "topsites", + "showTitles": true + }, + "primary_button": { + "label": { + "string_id": "onboarding-multistage-import-primary-button-label" + }, + "action": { + "type": "SHOW_MIGRATION_WIZARD", + "navigate": true + } + }, + "secondary_button": { + "label": { + "string_id": "onboarding-multistage-import-secondary-button-label" + }, + "action": { + "navigate": true + } + } + } + }, + { + "id": "AW_CHOOSE_THEME", + "order": 2, + "content": { + "zap": true, + "title": { + "string_id": "onboarding-multistage-theme-header" + }, + "subtitle": { + "string_id": "onboarding-multistage-theme-subtitle" + }, + "tiles": { + "type": "theme", + "action": { + "theme": "<event>" + }, + "data": [ + { + "theme": "automatic", + "label": { + "string_id": "onboarding-multistage-theme-label-automatic" + }, + "tooltip": { + "string_id": "onboarding-multistage-theme-tooltip-automatic-2" + }, + "description": { + "string_id": "onboarding-multistage-theme-description-automatic-2" + } + }, + { + "theme": "light", + "label": { + "string_id": "onboarding-multistage-theme-label-light" + }, + "tooltip": { + "string_id": "onboarding-multistage-theme-tooltip-light-2" + }, + "description": { + "string_id": "onboarding-multistage-theme-description-light" + } + }, + { + "theme": "dark", + "label": { + "string_id": "onboarding-multistage-theme-label-dark" + }, + "tooltip": { + "string_id": "onboarding-multistage-theme-tooltip-dark-2" + }, + "description": { + "string_id": "onboarding-multistage-theme-description-dark" + } + }, + { + "theme": "alpenglow", + "label": { + "string_id": "onboarding-multistage-theme-label-alpenglow" + }, + "tooltip": { + "string_id": "onboarding-multistage-theme-tooltip-alpenglow-2" + }, + "description": { + "string_id": "onboarding-multistage-theme-description-alpenglow" + } + } + ] + }, + "primary_button": { + "label": { + "string_id": "onboarding-multistage-theme-primary-button-label" + }, + "action": { + "navigate": true + } + }, + "secondary_button": { + "label": { + "string_id": "onboarding-multistage-theme-secondary-button-label" + }, + "action": { + "theme": "automatic", + "navigate": true + } + } + } + }, + { + "id": "AW_SET_DEFAULT", + "order": 3, + "content": { + "zap": true, + "title": "Make Firefox your default browser", + "subtitle": "Speed, safety, and privacy every time you browse.", + "primary_button": { + "label": "Make Default", + "action": { + "navigate": true, + "type": "SET_DEFAULT_BROWSER" + } + }, + "secondary_button": { + "label": { + "string_id": "onboarding-multistage-import-secondary-button-label" + }, + "action": { + "navigate": true + } + } + } + } + ] +} diff --git a/toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js b/toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js new file mode 100644 index 0000000000..3847f8f0e6 --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js @@ -0,0 +1,374 @@ +"use strict"; + +const { ExperimentAPI } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentAPI.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); +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()); + + manager.store.addExperiment(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" + ); + + 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(); + + manager.store.addExperiment(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"); + + await manager.onStartup(); + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + await ExperimentAPI.ready(); + + manager.store.addExperiment(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" + ); + + 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", + value: { title: "hi" }, + feature: { featureId: "cfr", enabled: true }, + }, + }); + + await manager.onStartup(); + + sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore()); + + manager.store.addExperiment(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" + ); + + sandbox.restore(); +}); + +/** + * #getValue + */ +add_task(async function test_getValue() { + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const feature = { + featureId: "aboutwelcome", + enabled: true, + value: { title: "hi" }, + }; + const expected = ExperimentFakes.experiment("foo", { + branch: { slug: "treatment", feature }, + }); + + await manager.onStartup(); + + sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore()); + + manager.store.addExperiment(expected); + + await TestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ slug: "foo" }), + "Wait for child to sync" + ); + + Assert.deepEqual( + ExperimentAPI.getFeatureValue({ featureId: "aboutwelcome" }), + feature.value, + "should return a Branch by feature" + ); + + Assert.deepEqual( + ExperimentAPI.getFeatureBranch({ featureId: "aboutwelcome" }), + expected.branch, + "should return an experiment branch by feature" + ); + + Assert.equal( + ExperimentAPI.getFeatureBranch({ featureId: "doesnotexist" }), + undefined, + "should return undefined if the experiment is not found" + ); + + sandbox.restore(); +}); + +/** + * #isFeatureEnabled + */ + +add_task(async function test_isFeatureEnabledDefault() { + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const FEATURE_ENABLED_DEFAULT = true; + const expected = ExperimentFakes.experiment("foo"); + + await manager.onStartup(); + + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + manager.store.addExperiment(expected); + + Assert.deepEqual( + ExperimentAPI.isFeatureEnabled("aboutwelcome", FEATURE_ENABLED_DEFAULT), + FEATURE_ENABLED_DEFAULT, + "should return enabled true as default" + ); + sandbox.restore(); +}); + +add_task(async function test_isFeatureEnabled() { + const sandbox = sinon.createSandbox(); + const manager = ExperimentFakes.manager(); + const feature = { + featureId: "aboutwelcome", + enabled: false, + value: null, + }; + const expected = ExperimentFakes.experiment("foo", { + branch: { slug: "treatment", feature }, + }); + + await manager.onStartup(); + + sandbox.stub(ExperimentAPI, "_store").get(() => manager.store); + + manager.store.addExperiment(expected); + + Assert.deepEqual( + ExperimentAPI.isFeatureEnabled("aboutwelcome", true), + feature.enabled, + "should return feature as disabled" + ); + sandbox.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(); +}); + +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(); +}); + +/** + * #on + * #off + */ +add_task(async function test_addExperiment_eventEmit_add() { + const sandbox = sinon.createSandbox(); + const slugStub = sandbox.stub(); + const featureStub = sandbox.stub(); + const experiment = ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "purple", enabled: true }, + }, + }); + const store = ExperimentFakes.store(); + sandbox.stub(ExperimentAPI, "_store").get(() => store); + + await store.init(); + await ExperimentAPI.ready(); + + ExperimentAPI.on("update", { slug: "foo" }, slugStub); + ExperimentAPI.on("update", { featureId: "purple" }, featureStub); + + store.addExperiment(experiment); + + Assert.equal(slugStub.callCount, 1); + Assert.equal(slugStub.firstCall.args[1].slug, experiment.slug); + Assert.equal(featureStub.callCount, 1); + Assert.equal(featureStub.firstCall.args[1].slug, experiment.slug); +}); + +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", + feature: { featureId: "purple", enabled: true }, + }, + }); + const store = ExperimentFakes.store(); + sandbox.stub(ExperimentAPI, "_store").get(() => store); + + await store.init(); + await ExperimentAPI.ready(); + + store.addExperiment(experiment); + + ExperimentAPI.on("update", { slug: "foo" }, slugStub); + ExperimentAPI.on("update", { featureId: "purple" }, featureStub); + + store.updateExperiment(experiment.slug, experiment); + + await TestUtils.waitForCondition( + () => slugStub.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[1].slug, experiment.slug); +}); + +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", + feature: { featureId: "purple", enabled: true }, + }, + }); + const store = ExperimentFakes.store(); + sandbox.stub(ExperimentAPI, "_store").get(() => store); + + await store.init(); + await ExperimentAPI.ready(); + + ExperimentAPI.on("update", { slug: "foo" }, slugStub); + ExperimentAPI.on("update", { featureId: "purple" }, featureStub); + + store.addExperiment(experiment); + + ExperimentAPI.off("update:foo", slugStub); + ExperimentAPI.off("update: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`"); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_context.js b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_context.js new file mode 100644 index 0000000000..090009a654 --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_context.js @@ -0,0 +1,33 @@ +"use strict"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); + +const { FirstStartup } = ChromeUtils.import( + "resource://gre/modules/FirstStartup.jsm" +); + +add_task(async function test_createTargetingContext() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + const recipe = ExperimentFakes.recipe("foo"); + sandbox.stub(manager.store, "ready").resolves(); + sandbox.stub(manager.store, "getAllActive").returns([recipe]); + + let context = manager.createTargetingContext(); + const activeSlugs = await context.activeExperiments; + + Assert.ok(!context.isFirstStartup, "should not set the first startup flag"); + Assert.deepEqual( + activeSlugs, + ["foo"], + "should return slugs for all the active experiment" + ); + + // Pretend to be in the first startup + FirstStartup._state = FirstStartup.IN_PROGRESS; + context = manager.createTargetingContext(); + + Assert.ok(context.isFirstStartup, "should set the first startup flag"); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js new file mode 100644 index 0000000000..bd7601215f --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js @@ -0,0 +1,233 @@ +"use strict"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); +const { NormandyTestUtils } = ChromeUtils.import( + "resource://testing-common/NormandyTestUtils.jsm" +); +const { Sampling } = ChromeUtils.import( + "resource://gre/modules/components-utils/Sampling.jsm" +); +const { ClientEnvironment } = ChromeUtils.import( + "resource://normandy/lib/ClientEnvironment.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +/** + * The normal case: Enrollment of a new experiment + */ +add_task(async function test_add_to_store() { + const manager = ExperimentFakes.manager(); + const recipe = ExperimentFakes.recipe("foo"); + + await manager.onStartup(); + + await manager.enroll(recipe); + const experiment = manager.store.get("foo"); + + Assert.ok(experiment, "should add an experiment with slug foo"); + Assert.ok( + recipe.branches.includes(experiment.branch), + "should choose a branch from the recipe.branches" + ); + Assert.equal(experiment.active, true, "should set .active = true"); + Assert.ok( + NormandyTestUtils.isUuid(experiment.enrollmentId), + "should add a valid enrollmentId" + ); +}); + +add_task( + async function test_setExperimentActive_sendEnrollmentTelemetry_called() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.spy(manager, "setExperimentActive"); + sandbox.spy(manager, "sendEnrollmentTelemetry"); + + await manager.onStartup(); + + await manager.onStartup(); + + await manager.enroll(ExperimentFakes.recipe("foo")); + const experiment = manager.store.get("foo"); + + Assert.equal( + manager.setExperimentActive.calledWith(experiment), + true, + "should call setExperimentActive after an enrollment" + ); + + Assert.equal( + manager.sendEnrollmentTelemetry.calledWith(experiment), + true, + "should call sendEnrollmentTelemetry after an enrollment" + ); + } +); + +/** + * Failure cases: + * - slug conflict + * - group conflict + */ +add_task(async function test_failure_name_conflict() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.spy(manager, "sendFailureTelemetry"); + + await manager.onStartup(); + + // simulate adding a previouly enrolled experiment + manager.store.addExperiment(ExperimentFakes.experiment("foo")); + + await Assert.rejects( + manager.enroll(ExperimentFakes.recipe("foo")), + /An experiment with the slug "foo" already exists/, + "should throw if a conflicting experiment exists" + ); + + Assert.equal( + manager.sendFailureTelemetry.calledWith( + "enrollFailed", + "foo", + "name-conflict" + ), + true, + "should send failure telemetry if a conflicting experiment exists" + ); +}); + +add_task(async function test_failure_group_conflict() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.spy(manager, "sendFailureTelemetry"); + + await manager.onStartup(); + + // Two conflicting branches that both have the group "pink" + // These should not be allowed to exist simultaneously. + const existingBranch = { + slug: "treatment", + feature: { featureId: "pink", enabled: true }, + }; + const newBranch = { + slug: "treatment", + feature: { featureId: "pink", enabled: true }, + }; + + // simulate adding an experiment with a conflicting group "pink" + manager.store.addExperiment( + ExperimentFakes.experiment("foo", { + branch: existingBranch, + }) + ); + + // ensure .enroll chooses the special branch with the conflict + sandbox.stub(manager, "chooseBranch").returns(newBranch); + await Assert.rejects( + manager.enroll(ExperimentFakes.recipe("bar", { branches: [newBranch] })), + /An experiment with a conflicting feature already exists/, + "should throw if there is a feature conflict" + ); + + Assert.equal( + manager.sendFailureTelemetry.calledWith( + "enrollFailed", + "bar", + "feature-conflict" + ), + true, + "should send failure telemetry if a feature conflict exists" + ); +}); + +add_task(async function test_sampling_check() { + const manager = ExperimentFakes.manager(); + let recipe = ExperimentFakes.recipe("foo", { bucketConfig: null }); + const sandbox = sinon.createSandbox(); + sandbox.stub(Sampling, "bucketSample").resolves(true); + sandbox.replaceGetter(ClientEnvironment, "userId", () => 42); + + Assert.ok( + !manager.isInBucketAllocation(recipe.bucketConfig), + "fails for no bucket config" + ); + + recipe = ExperimentFakes.recipe("foo2", { + bucketConfig: { randomizationUnit: "foo" }, + }); + + Assert.ok( + !manager.isInBucketAllocation(recipe.bucketConfig), + "fails for unknown randomizationUnit" + ); + + recipe = ExperimentFakes.recipe("foo3"); + + const result = await manager.isInBucketAllocation(recipe.bucketConfig); + + Assert.equal( + Sampling.bucketSample.callCount, + 1, + "it should call bucketSample" + ); + Assert.ok(result, "result should be true"); + const { args } = Sampling.bucketSample.firstCall; + Assert.equal(args[0][0], 42, "called with expected randomization id"); + Assert.equal( + args[0][1], + recipe.bucketConfig.namespace, + "called with expected namespace" + ); + Assert.equal( + args[1], + recipe.bucketConfig.start, + "called with expected start" + ); + Assert.equal( + args[2], + recipe.bucketConfig.count, + "called with expected count" + ); + Assert.equal( + args[3], + recipe.bucketConfig.total, + "called with expected total" + ); + + sandbox.reset(); +}); + +add_task(async function enroll_in_reference_aw_experiment() { + const SYNC_DATA_PREF = "messaging-system.syncdatastore.data"; + Services.prefs.clearUserPref(SYNC_DATA_PREF); + let dir = await OS.File.getCurrentDirectory(); + let src = OS.Path.join(dir, "reference_aboutwelcome_experiment_content.json"); + let bytes = await OS.File.read(src); + const decoder = new TextDecoder(); + const content = JSON.parse(decoder.decode(bytes)); + // Create two dummy branches with the content from disk + const branches = ["treatment-a", "treatment-b"].map(slug => ({ + slug, + ratio: 1, + feature: { value: content, enabled: true, featureId: "aboutwelcome" }, + })); + let recipe = ExperimentFakes.recipe("reference-aw", { branches }); + // Ensure we get enrolled + recipe.bucketConfig.count = recipe.bucketConfig.total; + + const manager = ExperimentFakes.manager(); + await manager.onStartup(); + await manager.enroll(recipe); + + Assert.ok(manager.store.get("reference-aw"), "Successful onboarding"); + let prefValue = Services.prefs.getStringPref(SYNC_DATA_PREF); + Assert.ok( + prefValue, + "aboutwelcome experiment enrollment should be stored to prefs" + ); + // In case some regression causes us to store a significant amount of data + // in prefs. + Assert.ok(prefValue.length < 3498, "Make sure we don't bloat the prefs"); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_generateTestIds.js b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_generateTestIds.js new file mode 100644 index 0000000000..43d481ad6a --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_generateTestIds.js @@ -0,0 +1,111 @@ +"use strict"; +const { ExperimentManager } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentManager.jsm" +); + +const TEST_CONFIG = { + slug: "test-experiment", + branches: [ + { + slug: "control", + ratio: 1, + }, + { + slug: "branchA", + ratio: 1, + }, + { + slug: "branchB", + ratio: 1, + }, + ], + namespace: "test-namespace", + start: 0, + count: 2000, + total: 10000, +}; +add_task(async function test_generateTestIds() { + let result = await ExperimentManager.generateTestIds(TEST_CONFIG); + + Assert.ok(result, "should return object"); + Assert.ok(result.notInExperiment, "should have a id for no experiment"); + Assert.ok(result.control, "should have id for control"); + Assert.ok(result.branchA, "should have id for branchA"); + Assert.ok(result.branchB, "should have id for branchB"); +}); + +add_task(async function test_generateTestIds_input_errors() { + const { slug, branches, namespace, start, count, total } = TEST_CONFIG; + await Assert.rejects( + ExperimentManager.generateTestIds({ + branches, + namespace, + start, + count, + total, + }), + /slug, namespace not in expected format/, + "should throw because of missing slug" + ); + + await Assert.rejects( + ExperimentManager.generateTestIds({ slug, branches, start, count, total }), + /slug, namespace not in expected format/, + "should throw because of missing namespace" + ); + + await Assert.rejects( + ExperimentManager.generateTestIds({ + slug, + branches, + namespace, + count, + total, + }), + /Must include start, count, and total as integers/, + "should throw beause of missing start" + ); + + await Assert.rejects( + ExperimentManager.generateTestIds({ + slug, + branches, + namespace, + start, + total, + }), + /Must include start, count, and total as integers/, + "should throw beause of missing count" + ); + + await Assert.rejects( + ExperimentManager.generateTestIds({ + slug, + branches, + namespace, + count, + start, + }), + /Must include start, count, and total as integers/, + "should throw beause of missing total" + ); + + // Intentionally misspelled slug + let invalidBranches = [ + { slug: "a", ratio: 1 }, + { slugG: "b", ratio: 1 }, + ]; + + await Assert.rejects( + ExperimentManager.generateTestIds({ + slug, + branches: invalidBranches, + namespace, + start, + count, + total, + }), + /branches parameter not in expected format/, + "should throw because of invalid format for branches" + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js new file mode 100644 index 0000000000..7443a861cc --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js @@ -0,0 +1,254 @@ +"use strict"; + +const { _ExperimentManager } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentManager.jsm" +); +const { ExperimentStore } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentStore.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); +const { Sampling } = ChromeUtils.import( + "resource://gre/modules/components-utils/Sampling.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +/** + * onStartup() + * - should set call setExperimentActive for each active experiment + */ +add_task(async function test_onStartup_setExperimentActive_called() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + const experiments = []; + sandbox.stub(manager, "setExperimentActive"); + sandbox.stub(manager.store, "init").resolves(); + sandbox.stub(manager.store, "getAll").returns(experiments); + + const active = ["foo", "bar"].map(ExperimentFakes.experiment); + + const inactive = ["baz", "qux"].map(slug => + ExperimentFakes.experiment(slug, { active: false }) + ); + + [...active, ...inactive].forEach(exp => experiments.push(exp)); + + await manager.onStartup(); + + active.forEach(exp => + Assert.equal( + manager.setExperimentActive.calledWith(exp), + true, + `should call setExperimentActive for active experiment: ${exp.slug}` + ) + ); + + inactive.forEach(exp => + Assert.equal( + manager.setExperimentActive.calledWith(exp), + false, + `should not call setExperimentActive for inactive experiment: ${exp.slug}` + ) + ); +}); + +/** + * onRecipe() + * - should add recipe slug to .session[source] + * - should call .enroll() if the recipe hasn't been seen before; + * - should call .update() if the Enrollment already exists in the store; + * - should skip enrollment if recipe.isEnrollmentPaused is true + */ +add_task(async function test_onRecipe_track_slug() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.spy(manager, "enroll"); + sandbox.spy(manager, "updateEnrollment"); + + const fooRecipe = ExperimentFakes.recipe("foo"); + + await manager.onStartup(); + // The first time a recipe has seen; + await manager.onRecipe(fooRecipe, "test"); + + Assert.equal( + manager.sessions.get("test").has("foo"), + true, + "should add slug to sessions[test]" + ); +}); + +add_task(async function test_onRecipe_enroll() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.stub(manager, "isInBucketAllocation").resolves(true); + sandbox.stub(Sampling, "bucketSample").resolves(true); + sandbox.spy(manager, "enroll"); + sandbox.spy(manager, "updateEnrollment"); + + const fooRecipe = ExperimentFakes.recipe("foo"); + const experimentUpdate = new Promise(resolve => + manager.store.on(`update:${fooRecipe.slug}`, resolve) + ); + await manager.onStartup(); + await manager.onRecipe(fooRecipe, "test"); + + Assert.equal( + manager.enroll.calledWith(fooRecipe), + true, + "should call .enroll() the first time a recipe is seen" + ); + await experimentUpdate; + Assert.equal( + manager.store.has("foo"), + true, + "should add recipe to the store" + ); +}); + +add_task(async function test_onRecipe_update() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.spy(manager, "enroll"); + sandbox.spy(manager, "updateEnrollment"); + sandbox.stub(manager, "isInBucketAllocation").resolves(true); + + const fooRecipe = ExperimentFakes.recipe("foo"); + const experimentUpdate = new Promise(resolve => + manager.store.on(`update:${fooRecipe.slug}`, resolve) + ); + + await manager.onStartup(); + await manager.onRecipe(fooRecipe, "test"); + // onRecipe calls enroll which saves the experiment in the store + // but none of them wait on disk operations to finish + await experimentUpdate; + // Call again after recipe has already been enrolled + await manager.onRecipe(fooRecipe, "test"); + + Assert.equal( + manager.updateEnrollment.calledWith(fooRecipe), + true, + "should call .updateEnrollment() if the recipe has already been enrolled" + ); +}); + +add_task(async function test_onRecipe_isEnrollmentPaused() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.spy(manager, "enroll"); + sandbox.spy(manager, "updateEnrollment"); + + await manager.onStartup(); + + const pausedRecipe = ExperimentFakes.recipe("xyz", { + isEnrollmentPaused: true, + }); + await manager.onRecipe(pausedRecipe, "test"); + Assert.equal( + manager.enroll.calledWith(pausedRecipe), + false, + "should skip enrollment for recipes that are paused" + ); + Assert.equal( + manager.store.has("xyz"), + false, + "should not add recipe to the store" + ); + + const fooRecipe = ExperimentFakes.recipe("foo"); + const updatedRecipe = ExperimentFakes.recipe("foo", { + isEnrollmentPaused: true, + }); + await manager.enroll(fooRecipe); + await manager.onRecipe(updatedRecipe, "test"); + Assert.equal( + manager.updateEnrollment.calledWith(updatedRecipe), + true, + "should still update existing recipes, even if enrollment is paused" + ); +}); + +/** + * onFinalize() + * - should unenroll experiments that weren't seen in the current session + */ + +add_task(async function test_onFinalize_unenroll() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + sandbox.spy(manager, "unenroll"); + + await manager.onStartup(); + + // Add an experiment to the store without calling .onRecipe + // This simulates an enrollment having happened in the past. + manager.store.addExperiment(ExperimentFakes.experiment("foo")); + + // Simulate adding some other recipes + await manager.onStartup(); + const recipe1 = ExperimentFakes.recipe("bar"); + // Unique features to prevent overlap + recipe1.branches[0].feature.featureId = "red"; + recipe1.branches[1].feature.featureId = "red"; + await manager.onRecipe(recipe1, "test"); + const recipe2 = ExperimentFakes.recipe("baz"); + recipe2.branches[0].feature.featureId = "green"; + recipe2.branches[1].feature.featureId = "green"; + await manager.onRecipe(recipe2, "test"); + + // Finalize + manager.onFinalize("test"); + + Assert.equal( + manager.unenroll.callCount, + 1, + "should only call unenroll for the unseen recipe" + ); + Assert.equal( + manager.unenroll.calledWith("foo", "recipe-not-seen"), + true, + "should unenroll a experiment whose recipe wasn't seen in the current session" + ); + Assert.equal( + manager.sessions.has("test"), + false, + "should clear sessions[test]" + ); +}); + +add_task(async function test_onExposureEvent() { + const manager = ExperimentFakes.manager(); + const fooExperiment = ExperimentFakes.experiment("foo"); + + await manager.onStartup(); + manager.store.addExperiment(fooExperiment); + + let updateEv = new Promise(resolve => + manager.store.on(`update:${fooExperiment.slug}`, resolve) + ); + + manager.store._emitExperimentExposure({ + branchSlug: fooExperiment.branch.slug, + experimentSlug: fooExperiment.slug, + featureId: "cfr", + }); + + await updateEv; + + Assert.equal( + manager.store.get(fooExperiment.slug).exposurePingSent, + true, + "Experiment state updated" + ); + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "telemetry.event_counts", + "normandy#expose#feature_study", + 1 + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js new file mode 100644 index 0000000000..72b3ea296b --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js @@ -0,0 +1,143 @@ +"use strict"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); +const { NormandyTestUtils } = ChromeUtils.import( + "resource://testing-common/NormandyTestUtils.jsm" +); +const { TelemetryEvents } = ChromeUtils.import( + "resource://normandy/lib/TelemetryEvents.jsm" +); +const { TelemetryEnvironment } = ChromeUtils.import( + "resource://gre/modules/TelemetryEnvironment.jsm" +); +const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; + +const globalSandbox = sinon.createSandbox(); +globalSandbox.spy(TelemetryEnvironment, "setExperimentInactive"); +globalSandbox.spy(TelemetryEvents, "sendEvent"); +registerCleanupFunction(() => { + globalSandbox.restore(); +}); + +/** + * Normal unenrollment: + * - set .active to false + * - set experiment inactive in telemetry + * - send unrollment event + */ +add_task(async function test_set_inactive() { + const manager = ExperimentFakes.manager(); + + await manager.onStartup(); + manager.store.addExperiment(ExperimentFakes.experiment("foo")); + + manager.unenroll("foo", "some-reason"); + + Assert.equal( + manager.store.get("foo").active, + false, + "should set .active to false" + ); +}); + +add_task(async function test_unenroll_opt_out() { + globalSandbox.reset(); + Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); + const manager = ExperimentFakes.manager(); + const experiment = ExperimentFakes.experiment("foo"); + + await manager.onStartup(); + manager.store.addExperiment(experiment); + + Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, false); + + Assert.equal( + manager.store.get(experiment.slug).active, + false, + "should set .active to false" + ); + Assert.ok(TelemetryEvents.sendEvent.calledOnce); + Assert.deepEqual( + TelemetryEvents.sendEvent.firstCall.args, + [ + "unenroll", + "preference_study", + experiment.slug, + { + reason: "studies-opt-out", + branch: experiment.branch.slug, + enrollmentId: experiment.enrollmentId, + }, + ], + "should send an unenrollment ping with the slug, reason, branch slug, and enrollmentId" + ); + // reset pref + Services.prefs.clearUserPref(STUDIES_OPT_OUT_PREF); +}); + +add_task(async function test_setExperimentInactive_called() { + globalSandbox.reset(); + const manager = ExperimentFakes.manager(); + const experiment = ExperimentFakes.experiment("foo"); + + await manager.onStartup(); + manager.store.addExperiment(experiment); + + manager.unenroll("foo", "some-reason"); + + Assert.ok( + TelemetryEnvironment.setExperimentInactive.calledWith("foo"), + "should call TelemetryEnvironment.setExperimentInactive with slug" + ); +}); + +add_task(async function test_send_unenroll_event() { + globalSandbox.reset(); + const manager = ExperimentFakes.manager(); + const experiment = ExperimentFakes.experiment("foo"); + + await manager.onStartup(); + manager.store.addExperiment(experiment); + + manager.unenroll("foo", "some-reason"); + + Assert.ok(TelemetryEvents.sendEvent.calledOnce); + Assert.deepEqual( + TelemetryEvents.sendEvent.firstCall.args, + [ + "unenroll", + "preference_study", // This needs to be updated eventually + "foo", // slug + { + reason: "some-reason", + branch: experiment.branch.slug, + enrollmentId: experiment.enrollmentId, + }, + ], + "should send an unenrollment ping with the slug, reason, branch slug, and enrollmentId" + ); +}); + +add_task(async function test_undefined_reason() { + globalSandbox.reset(); + const manager = ExperimentFakes.manager(); + const experiment = ExperimentFakes.experiment("foo"); + + await manager.onStartup(); + manager.store.addExperiment(experiment); + + manager.unenroll("foo"); + + const options = TelemetryEvents.sendEvent.firstCall?.args[3]; + Assert.ok( + "reason" in options, + "options object with .reason should be the fourth param" + ); + Assert.equal( + options.reason, + "unknown", + "should include unknown as the reason if none was supplied" + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_ExperimentStore.js b/toolkit/components/messaging-system/test/unit/test_ExperimentStore.js new file mode 100644 index 0000000000..fada9a66bd --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_ExperimentStore.js @@ -0,0 +1,449 @@ +"use strict"; + +const SYNC_DATA_PREF = "messaging-system.syncdatastore.data"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); +const { ExperimentStore } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentStore.jsm" +); + +add_task(async function test_sharedDataMap_key() { + const store = new ExperimentStore(); + + // Outside of tests we use sharedDataKey for the profile dir filepath + // where we store experiments + Assert.ok(store._sharedDataKey, "Make sure it's defined"); +}); + +add_task(async function test_usageBeforeInitialization() { + const store = ExperimentFakes.store(); + const experiment = ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "purple", enabled: true }, + }, + }); + + Assert.equal(store.getAll().length, 0, "It should not fail"); + + await store.init(); + store.addExperiment(experiment); + + Assert.equal( + store.getExperimentForFeature("purple"), + experiment, + "should return a matching experiment for the given feature" + ); +}); + +add_task(async function test_event_add_experiment() { + const sandbox = sinon.createSandbox(); + const store = ExperimentFakes.store(); + const expected = ExperimentFakes.experiment("foo"); + const updateEventCbStub = sandbox.stub(); + + // Setup ExperimentManager and child store for ExperimentAPI + await store.init(); + + // Set update cb + store.on("update:foo", updateEventCbStub); + + // Add some data + store.addExperiment(expected); + + Assert.equal(updateEventCbStub.callCount, 1, "Called once for add"); +}); + +add_task(async function test_event_updates_main() { + const sandbox = sinon.createSandbox(); + const store = ExperimentFakes.store(); + const experiment = ExperimentFakes.experiment("foo"); + const updateEventCbStub = sandbox.stub(); + + // Setup ExperimentManager and child store for ExperimentAPI + await store.init(); + + // Set update cb + store.on("update:aboutwelcome", updateEventCbStub); + + store.addExperiment(experiment); + store.updateExperiment("foo", { active: false }); + + Assert.equal( + updateEventCbStub.callCount, + 2, + "Should be called twice: add, update" + ); + Assert.equal( + updateEventCbStub.secondCall.args[1].active, + false, + "Should be called with updated experiment status" + ); +}); + +add_task(async function test_getExperimentForGroup() { + const store = ExperimentFakes.store(); + const experiment = ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "purple", enabled: true }, + }, + }); + + await store.init(); + store.addExperiment(ExperimentFakes.experiment("bar")); + store.addExperiment(experiment); + + Assert.equal( + store.getExperimentForFeature("purple"), + experiment, + "should return a matching experiment for the given feature" + ); +}); + +add_task(async function test_recordExposureEvent() { + const manager = ExperimentFakes.manager(); + const experiment = ExperimentFakes.experiment("foo"); + const experimentData = { + experimentSlug: experiment.slug, + branchSlug: experiment.branch.slug, + featureId: experiment.branch.feature.featureId, + }; + await manager.onStartup(); + + let exposureEvEmit = new Promise(resolve => + manager.store.on("exposure", (ev, data) => resolve(data)) + ); + + manager.store.addExperiment(experiment); + manager.store._emitExperimentExposure(experimentData); + + let result = await exposureEvEmit; + + Assert.deepEqual( + result, + experimentData, + "should return the same data as sent" + ); +}); + +add_task(async function test_activateBranch() { + const store = ExperimentFakes.store(); + const experiment = ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "green", enabled: true }, + }, + }); + + await store.init(); + store.addExperiment(experiment); + + Assert.deepEqual( + store.activateBranch({ featureId: "green" }), + experiment.branch, + "Should return feature of active experiment" + ); +}); + +add_task(async function test_activateBranch_activationEvent() { + const store = ExperimentFakes.store(); + const sandbox = sinon.createSandbox(); + const experiment = ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "green", enabled: true }, + }, + }); + + await store.init(); + store.addExperiment(experiment); + // Adding stub later because `addExperiment` emits update events + const stub = sandbox.stub(store, "emit"); + // Call activateBranch to trigger an activation event + store.activateBranch({ featureId: "green" }); + + Assert.equal(stub.callCount, 1, "Called by doing activateBranch"); + Assert.equal(stub.firstCall.args[0], "exposure", "Has correct event name"); + Assert.equal( + stub.firstCall.args[1].experimentSlug, + experiment.slug, + "Has correct payload" + ); +}); + +add_task(async function test_activateBranch_storeFailure() { + const store = ExperimentFakes.store(); + const sandbox = sinon.createSandbox(); + const experiment = ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "green", enabled: true }, + }, + }); + + await store.init(); + store.addExperiment(experiment); + // Adding stub later because `addExperiment` emits update events + const stub = sandbox.stub(store, "emit"); + // Call activateBranch to trigger an activation event + sandbox.stub(store, "getAllActive").throws(); + try { + store.activateBranch({ featureId: "green" }); + } catch (e) { + /* This is expected */ + } + + Assert.equal(stub.callCount, 0, "Not called if store somehow fails"); +}); + +add_task(async function test_activateBranch_noActivationEvent() { + const store = ExperimentFakes.store(); + const sandbox = sinon.createSandbox(); + const experiment = ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "green", enabled: true }, + }, + }); + + await store.init(); + store.addExperiment(experiment); + // Adding stub later because `addExperiment` emits update events + const stub = sandbox.stub(store, "emit"); + // Call activateBranch to trigger an activation event + store.activateBranch({ featureId: "green", sendExposurePing: false }); + + Assert.equal(stub.callCount, 0, "Not called: sendExposurePing is false"); +}); + +add_task(async function test_hasExperimentForFeature() { + const store = ExperimentFakes.store(); + + await store.init(); + store.addExperiment( + ExperimentFakes.experiment("foo", { + branch: { + slug: "variant", + feature: { featureId: "green", enabled: true }, + }, + }) + ); + store.addExperiment( + ExperimentFakes.experiment("foo2", { + branch: { + slug: "variant", + feature: { featureId: "yellow", enabled: true }, + }, + }) + ); + store.addExperiment( + ExperimentFakes.experiment("bar_expired", { + active: false, + branch: { + slug: "variant", + feature: { featureId: "purple", enabled: true }, + }, + }) + ); + Assert.equal( + store.hasExperimentForFeature(), + false, + "should return false if the input is empty" + ); + + Assert.equal( + store.hasExperimentForFeature(undefined), + false, + "should return false if the input is undefined" + ); + + Assert.equal( + store.hasExperimentForFeature("green"), + true, + "should return true if there is an experiment with any of the given groups" + ); + + Assert.equal( + store.hasExperimentForFeature("purple"), + false, + "should return false if there is a non-active experiment with the given groups" + ); +}); + +add_task(async function test_getAll_getAllActive() { + const store = ExperimentFakes.store(); + + await store.init(); + ["foo", "bar", "baz"].forEach(slug => + store.addExperiment(ExperimentFakes.experiment(slug, { active: false })) + ); + store.addExperiment(ExperimentFakes.experiment("qux", { active: true })); + + Assert.deepEqual( + store.getAll().map(e => e.slug), + ["foo", "bar", "baz", "qux"], + ".getAll() should return all experiments" + ); + Assert.deepEqual( + store.getAllActive().map(e => e.slug), + ["qux"], + ".getAllActive() should return all experiments that are active" + ); +}); + +add_task(async function test_addExperiment() { + const store = ExperimentFakes.store(); + const exp = ExperimentFakes.experiment("foo"); + + await store.init(); + store.addExperiment(exp); + + Assert.equal(store.get("foo"), exp, "should save experiment by slug"); +}); + +add_task(async function test_updateExperiment() { + const feature = { featureId: "cfr", enabled: true }; + const experiment = Object.freeze( + ExperimentFakes.experiment("foo", { feature, active: true }) + ); + const store = ExperimentFakes.store(); + + await store.init(); + store.addExperiment(experiment); + store.updateExperiment("foo", { active: false }); + + const actual = store.get("foo"); + Assert.equal(actual.active, false, "should change updated props"); + Assert.deepEqual( + actual.branch.feature, + feature, + "should not update other props" + ); +}); + +add_task(async function test_sync_access_before_init() { + Services.prefs.clearUserPref(SYNC_DATA_PREF); + let store = ExperimentFakes.store(); + + Assert.equal(store.getAll().length, 0, "Start with an empty store"); + + const syncAccessExp = ExperimentFakes.experiment("foo", { + feature: { featureId: "newtab", enabled: "true" }, + }); + await store.init(); + store.addExperiment(syncAccessExp); + + let prefValue = JSON.parse(Services.prefs.getStringPref(SYNC_DATA_PREF)); + + Assert.ok(Object.keys(prefValue).length === 1, "Parsed stored experiment"); + Assert.equal( + prefValue.foo.slug, + syncAccessExp.slug, + "Got back the experiment" + ); + + // New un-initialized store that should read the pref value + store = ExperimentFakes.store(); + + Assert.equal(store.getAll().length, 1, "Returns experiment from pref"); +}); + +add_task(async function test_sync_access_update() { + Services.prefs.clearUserPref(SYNC_DATA_PREF); + let store = ExperimentFakes.store(); + let experiment = ExperimentFakes.experiment("foo", { + feature: { featureId: "aboutwelcome", enabled: true }, + }); + + await store.init(); + + store.addExperiment(experiment); + store.updateExperiment("foo", { + branch: { + ...experiment.branch, + feature: { featureId: "aboutwelcome", enabled: true, value: "bar" }, + }, + }); + + store = ExperimentFakes.store(); + let experiments = store.getAll(); + + Assert.equal(experiments.length, 1, "Got back 1 experiment"); + Assert.equal(experiments[0].branch.feature.value, "bar", "Got updated value"); +}); + +add_task(async function test_sync_features_only() { + Services.prefs.clearUserPref(SYNC_DATA_PREF); + let store = ExperimentFakes.store(); + let experiment = ExperimentFakes.experiment("foo", { + feature: { featureId: "cfr", enabled: true }, + }); + + await store.init(); + + store.addExperiment(experiment); + store = ExperimentFakes.store(); + + Assert.equal(store.getAll().length, 0, "cfr is not a sync access experiment"); +}); + +add_task(async function test_sync_access_unenroll() { + Services.prefs.clearUserPref(SYNC_DATA_PREF); + let store = ExperimentFakes.store(); + let experiment = ExperimentFakes.experiment("foo", { + feature: { featureId: "aboutwelcome", enabled: true }, + active: true, + }); + + await store.init(); + + store.addExperiment(experiment); + store.updateExperiment("foo", { active: false }); + + store = ExperimentFakes.store(); + let experiments = store.getAll(); + + Assert.equal(experiments.length, 0, "Unenrolled experiment is deleted"); +}); + +add_task(async function test_sync_access_unenroll_2() { + Services.prefs.clearUserPref(SYNC_DATA_PREF); + let store = ExperimentFakes.store(); + let experiment1 = ExperimentFakes.experiment("foo", { + feature: { featureId: "aboutwelcome", enabled: true }, + }); + let experiment2 = ExperimentFakes.experiment("bar", { + feature: { featureId: "aboutwelcome", enabled: true }, + }); + + await store.init(); + + store.addExperiment(experiment1); + store.addExperiment(experiment2); + + Assert.equal(store.getAll().length, 2, "2/2 experiments"); + + store.updateExperiment("bar", { active: false }); + let other_store = ExperimentFakes.store(); + Assert.equal( + other_store.getAll().length, + 1, + "Unenrolled from 1/2 experiments" + ); + + store.updateExperiment("foo", { active: false }); + Assert.equal( + other_store.getAll().length, + 0, + "Unenrolled from 2/2 experiments" + ); + + Assert.equal( + Services.prefs.getStringPref(SYNC_DATA_PREF), + "{}", + "Empty store" + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_MSTestUtils.js b/toolkit/components/messaging-system/test/unit/test_MSTestUtils.js new file mode 100644 index 0000000000..9f843675ce --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_MSTestUtils.js @@ -0,0 +1,13 @@ +"use strict"; + +const { ExperimentFakes, ExperimentTestUtils } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); + +add_task(async function test_recipe_fake_validates() { + const recipe = ExperimentFakes.recipe("foo"); + Assert.ok( + await ExperimentTestUtils.validateExperiment(recipe), + "should produce a valid experiment recipe" + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js b/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js new file mode 100644 index 0000000000..ccc05c8184 --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js @@ -0,0 +1,209 @@ +"use strict"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); +const { CleanupManager } = ChromeUtils.import( + "resource://normandy/lib/CleanupManager.jsm" +); + +const { ExperimentManager } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentManager.jsm" +); + +const { RemoteSettingsExperimentLoader } = ChromeUtils.import( + "resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm" +); + +const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled"; +const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds"; +const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; + +add_task(async function test_real_exp_manager() { + equal( + RemoteSettingsExperimentLoader.manager, + ExperimentManager, + "should reference ExperimentManager singleton by default" + ); +}); + +add_task(async function test_lazy_pref_getters() { + const loader = ExperimentFakes.rsLoader(); + sinon.stub(loader, "updateRecipes").resolves(); + + Services.prefs.setIntPref(RUN_INTERVAL_PREF, 123456); + equal( + loader.intervalInSeconds, + 123456, + `should set intervalInSeconds to the value of ${RUN_INTERVAL_PREF}` + ); + + Services.prefs.setBoolPref(ENABLED_PREF, true); + equal( + loader.enabled, + true, + `should set enabled to the value of ${ENABLED_PREF}` + ); + Services.prefs.setBoolPref(ENABLED_PREF, false); + equal(loader.enabled, false); + + Services.prefs.clearUserPref(RUN_INTERVAL_PREF); + Services.prefs.clearUserPref(ENABLED_PREF); +}); + +add_task(async function test_init() { + const loader = ExperimentFakes.rsLoader(); + sinon.stub(loader, "setTimer"); + sinon.stub(loader, "updateRecipes").resolves(); + + Services.prefs.setBoolPref(ENABLED_PREF, false); + await loader.init(); + equal( + loader.setTimer.callCount, + 0, + `should not initialize if ${ENABLED_PREF} pref is false` + ); + + Services.prefs.setBoolPref(ENABLED_PREF, true); + await loader.init(); + ok(loader.setTimer.calledOnce, "should call .setTimer"); + ok(loader.updateRecipes.calledOnce, "should call .updateRecipes"); +}); + +add_task(async function test_init_with_opt_in() { + const loader = ExperimentFakes.rsLoader(); + sinon.stub(loader, "setTimer"); + sinon.stub(loader, "updateRecipes").resolves(); + + Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, false); + await loader.init(); + equal( + loader.setTimer.callCount, + 0, + `should not initialize if ${STUDIES_OPT_OUT_PREF} pref is false` + ); + + Services.prefs.setBoolPref(ENABLED_PREF, false); + await loader.init(); + equal( + loader.setTimer.callCount, + 0, + `should not initialize if ${ENABLED_PREF} pref is false` + ); + + Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); + Services.prefs.setBoolPref(ENABLED_PREF, true); + await loader.init(); + ok(loader.setTimer.calledOnce, "should call .setTimer"); + ok(loader.updateRecipes.calledOnce, "should call .updateRecipes"); +}); + +add_task(async function test_updateRecipes() { + const loader = ExperimentFakes.rsLoader(); + + const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", { + targeting: "true", + }); + const FAIL_FILTER_RECIPE = ExperimentFakes.recipe("foo", { + targeting: "false", + }); + sinon.stub(loader, "setTimer"); + sinon.spy(loader, "updateRecipes"); + + sinon + .stub(loader.remoteSettingsClient, "get") + .resolves([PASS_FILTER_RECIPE, FAIL_FILTER_RECIPE]); + sinon.stub(loader.manager, "onRecipe").resolves(); + sinon.stub(loader.manager, "onFinalize"); + + Services.prefs.setBoolPref(ENABLED_PREF, true); + await loader.init(); + ok(loader.updateRecipes.calledOnce, "should call .updateRecipes"); + equal( + loader.manager.onRecipe.callCount, + 1, + "should call .onRecipe only for recipes that pass" + ); + ok( + loader.manager.onRecipe.calledWith(PASS_FILTER_RECIPE, "rs-loader"), + "should call .onRecipe with argument data" + ); +}); + +add_task(async function test_updateRecipes_forFirstStartup() { + const loader = ExperimentFakes.rsLoader(); + const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", { + targeting: "isFirstStartup", + }); + sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]); + sinon.stub(loader.manager, "onRecipe").resolves(); + sinon.stub(loader.manager, "onFinalize"); + sinon + .stub(loader.manager, "createTargetingContext") + .returns({ isFirstStartup: true }); + + Services.prefs.setBoolPref(ENABLED_PREF, true); + await loader.init({ isFirstStartup: true }); + + ok(loader.manager.onRecipe.calledOnce, "should pass the targeting filter"); +}); + +add_task(async function test_updateRecipes_forNoneFirstStartup() { + const loader = ExperimentFakes.rsLoader(); + const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", { + targeting: "isFirstStartup", + }); + sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]); + sinon.stub(loader.manager, "onRecipe").resolves(); + sinon.stub(loader.manager, "onFinalize"); + sinon + .stub(loader.manager, "createTargetingContext") + .returns({ isFirstStartup: false }); + + Services.prefs.setBoolPref(ENABLED_PREF, true); + await loader.init({ isFirstStartup: true }); + + ok(loader.manager.onRecipe.notCalled, "should not pass the targeting filter"); +}); + +add_task(async function test_checkTargeting() { + const loader = ExperimentFakes.rsLoader(); + equal( + await loader.checkTargeting({}), + true, + "should return true if .targeting is not defined" + ); + equal( + await loader.checkTargeting({ targeting: "'foo'" }), + true, + "should return true for truthy expression" + ); + equal( + await loader.checkTargeting({ targeting: "aPropertyThatDoesNotExist" }), + false, + "should return false for falsey expression" + ); +}); + +add_task(async function test_checkExperimentSelfReference() { + const loader = ExperimentFakes.rsLoader(); + const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", { + targeting: + "experiment.slug == 'foo' && experiment.branches[0].slug == 'control'", + }); + + const FAIL_FILTER_RECIPE = ExperimentFakes.recipe("foo", { + targeting: "experiment.slug == 'bar'", + }); + + equal( + await loader.checkTargeting(PASS_FILTER_RECIPE), + true, + "Should return true for matching on slug name and branch" + ); + equal( + await loader.checkTargeting(FAIL_FILTER_RECIPE), + false, + "Should fail targeting" + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js b/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js new file mode 100644 index 0000000000..7aa9d2a343 --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js @@ -0,0 +1,55 @@ +"use strict"; + +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); +const { CleanupManager } = ChromeUtils.import( + "resource://normandy/lib/CleanupManager.jsm" +); +const { ExperimentManager } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentManager.jsm" +); +const { RemoteSettingsExperimentLoader } = ChromeUtils.import( + "resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm" +); +const { FirstStartup } = ChromeUtils.import( + "resource://gre/modules/FirstStartup.jsm" +); + +add_task(async function test_updateRecipes_activeExperiments() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + const recipe = ExperimentFakes.recipe("foo"); + const loader = ExperimentFakes.rsLoader(); + loader.manager = manager; + const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", { + targeting: `"${recipe.slug}" in activeExperiments`, + }); + const onRecipe = sandbox.stub(manager, "onRecipe"); + sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]); + sandbox.stub(manager.store, "ready").resolves(); + sandbox.stub(manager.store, "getAllActive").returns([recipe]); + + await loader.init(); + + ok(onRecipe.calledOnce, "Should match active experiments"); +}); + +add_task(async function test_updateRecipes_isFirstRun() { + const manager = ExperimentFakes.manager(); + const sandbox = sinon.createSandbox(); + const recipe = ExperimentFakes.recipe("foo"); + const loader = ExperimentFakes.rsLoader(); + loader.manager = manager; + const PASS_FILTER_RECIPE = { ...recipe, targeting: "isFirstStartup" }; + const onRecipe = sandbox.stub(manager, "onRecipe"); + sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]); + sandbox.stub(manager.store, "ready").resolves(); + sandbox.stub(manager.store, "getAllActive").returns([recipe]); + + // Pretend to be in the first startup + FirstStartup._state = FirstStartup.IN_PROGRESS; + await loader.init(); + + Assert.ok(onRecipe.calledOnce, "Should match first run"); +}); diff --git a/toolkit/components/messaging-system/test/unit/test_SharedDataMap.js b/toolkit/components/messaging-system/test/unit/test_SharedDataMap.js new file mode 100644 index 0000000000..895aefc91c --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/test_SharedDataMap.js @@ -0,0 +1,183 @@ +const { SharedDataMap } = ChromeUtils.import( + "resource://messaging-system/lib/SharedDataMap.jsm" +); +const { FileTestUtils } = ChromeUtils.import( + "resource://testing-common/FileTestUtils.jsm" +); +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); +const { ExperimentFakes } = ChromeUtils.import( + "resource://testing-common/MSTestUtils.jsm" +); + +const PATH = FileTestUtils.getTempFile("shared-data-map").path; + +function with_sharedDataMap(test) { + let testTask = async () => { + const sandbox = sinon.createSandbox(); + const instance = new SharedDataMap("xpcshell", { + path: PATH, + isParent: true, + }); + try { + await test({ instance, sandbox }); + } finally { + sandbox.restore(); + } + }; + + // Copy the name of the test function to identify the test + Object.defineProperty(testTask, "name", { value: test.name }); + add_task(testTask); +} + +with_sharedDataMap(async function test_set_notify({ instance, sandbox }) { + await instance.init(); + let updateStub = sandbox.stub(); + + instance.on("parent-store-update:foo", updateStub); + instance.set("foo", "bar"); + + Assert.equal(updateStub.callCount, 1, "Update event sent"); + Assert.equal(updateStub.firstCall.args[1], "bar", "Update event sent value"); +}); + +with_sharedDataMap(async function test_set_child_notify({ instance, sandbox }) { + await instance.init(); + + let updateStub = sandbox.stub(); + const childInstance = new SharedDataMap("xpcshell", { + path: PATH, + isParent: false, + }); + + childInstance.on("child-store-update:foo", updateStub); + let childStoreUpdate = new Promise(resolve => + childInstance.on("child-store-update:foo", resolve) + ); + instance.set("foo", "bar"); + + await childStoreUpdate; + + Assert.equal(updateStub.callCount, 1, "Update event sent"); + Assert.equal(updateStub.firstCall.args[1], "bar", "Update event sent value"); +}); + +with_sharedDataMap(async function test_async({ instance, sandbox }) { + const spy = sandbox.spy(instance._store, "load"); + await instance.init(); + + instance.set("foo", "bar"); + + Assert.equal(spy.callCount, 1, "Should init async"); + Assert.equal(instance.get("foo"), "bar", "It should retrieve a string value"); +}); + +with_sharedDataMap(async function test_saveSoon({ instance, sandbox }) { + await instance.init(); + const stub = sandbox.stub(instance._store, "saveSoon"); + + instance.set("foo", "bar"); + + Assert.equal(stub.callCount, 1, "Should call save soon when setting a value"); +}); + +with_sharedDataMap(async function test_childInit({ instance, sandbox }) { + sandbox.stub(instance, "isParent").get(() => false); + const stubA = sandbox.stub(instance._store, "ensureDataReady"); + const stubB = sandbox.stub(instance._store, "load"); + + await instance.init(); + + Assert.equal( + stubA.callCount, + 0, + "It should not try to initialize sync from child" + ); + Assert.equal( + stubB.callCount, + 0, + "It should not try to initialize async from child" + ); +}); + +with_sharedDataMap(async function test_parentChildSync_synchronously({ + instance: parentInstance, + sandbox, +}) { + await parentInstance.init(); + parentInstance.set("foo", { bar: 1 }); + + const childInstance = new SharedDataMap("xpcshell", { + path: PATH, + isParent: false, + }); + + await parentInstance.ready(); + await childInstance.ready(); + + await TestUtils.waitForCondition( + () => childInstance.get("foo"), + "Wait for child to sync" + ); + + Assert.deepEqual( + childInstance.get("foo"), + parentInstance.get("foo"), + "Parent and child should be in sync" + ); +}); + +with_sharedDataMap(async function test_parentChildSync_async({ + instance: parentInstance, + sandbox, +}) { + const childInstance = new SharedDataMap("xpcshell", { + path: PATH, + isParent: false, + }); + + await parentInstance.init(); + parentInstance.set("foo", { bar: 1 }); + + await parentInstance.ready(); + await childInstance.ready(); + + await TestUtils.waitForCondition( + () => childInstance.get("foo"), + "Wait for child to sync" + ); + + Assert.deepEqual( + childInstance.get("foo"), + parentInstance.get("foo"), + "Parent and child should be in sync" + ); +}); + +with_sharedDataMap(async function test_earlyChildSync({ + instance: parentInstance, + sandbox, +}) { + const childInstance = new SharedDataMap("xpcshell", { + path: PATH, + isParent: false, + }); + + Assert.equal(childInstance.has("baz"), false, "Should not fail"); + + await parentInstance.init(); + parentInstance.set("baz", { bar: 1 }); + + await TestUtils.waitForCondition( + () => childInstance.get("baz"), + "Wait for child to sync" + ); + + Assert.deepEqual( + childInstance.get("baz"), + parentInstance.get("baz"), + "Parent and child should be in sync" + ); +}); diff --git a/toolkit/components/messaging-system/test/unit/xpcshell.ini b/toolkit/components/messaging-system/test/unit/xpcshell.ini new file mode 100644 index 0000000000..af752c4eb9 --- /dev/null +++ b/toolkit/components/messaging-system/test/unit/xpcshell.ini @@ -0,0 +1,18 @@ +[DEFAULT] +head = head.js +tags = messaging-system +firefox-appdir = browser +support-files = + reference_aboutwelcome_experiment_content.json + +[test_ExperimentManager_context.js] +[test_ExperimentManager_enroll.js] +[test_ExperimentManager_lifecycle.js] +[test_ExperimentManager_unenroll.js] +[test_ExperimentManager_generateTestIds.js] +[test_ExperimentStore.js] +[test_MSTestUtils.js] +[test_SharedDataMap.js] +[test_ExperimentAPI.js] +[test_RemoteSettingsExperimentLoader.js] +[test_RemoteSettingsExperimentLoader_updateRecipes.js] |