summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system/test
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/messaging-system/test
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/messaging-system/test')
-rw-r--r--toolkit/components/messaging-system/test/MSTestUtils.jsm145
-rw-r--r--toolkit/components/messaging-system/test/browser/browser.ini5
-rw-r--r--toolkit/components/messaging-system/test/browser/browser_experimentstore_load.js40
-rw-r--r--toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js111
-rw-r--r--toolkit/components/messaging-system/test/unit/head.js5
-rw-r--r--toolkit/components/messaging-system/test/unit/reference_aboutwelcome_experiment_content.json190
-rw-r--r--toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js374
-rw-r--r--toolkit/components/messaging-system/test/unit/test_ExperimentManager_context.js33
-rw-r--r--toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js233
-rw-r--r--toolkit/components/messaging-system/test/unit/test_ExperimentManager_generateTestIds.js111
-rw-r--r--toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js254
-rw-r--r--toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js143
-rw-r--r--toolkit/components/messaging-system/test/unit/test_ExperimentStore.js449
-rw-r--r--toolkit/components/messaging-system/test/unit/test_MSTestUtils.js13
-rw-r--r--toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js209
-rw-r--r--toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js55
-rw-r--r--toolkit/components/messaging-system/test/unit/test_SharedDataMap.js183
-rw-r--r--toolkit/components/messaging-system/test/unit/xpcshell.ini18
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]