summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system
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
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')
-rw-r--r--toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts49
-rw-r--r--toolkit/components/messaging-system/experiments/ExperimentAPI.jsm266
-rw-r--r--toolkit/components/messaging-system/experiments/ExperimentManager.jsm476
-rw-r--r--toolkit/components/messaging-system/experiments/ExperimentStore.jsm196
-rw-r--r--toolkit/components/messaging-system/jar.mn9
-rw-r--r--toolkit/components/messaging-system/lib/Logger.jsm22
-rw-r--r--toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm229
-rw-r--r--toolkit/components/messaging-system/lib/SharedDataMap.jsm163
-rw-r--r--toolkit/components/messaging-system/lib/SpecialMessageActions.jsm301
-rw-r--r--toolkit/components/messaging-system/moz.build28
-rw-r--r--toolkit/components/messaging-system/schemas/NimbusExperiment.schema.json187
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json396
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md243
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini23
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js21
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js17
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js14
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js31
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js141
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js22
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js28
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js30
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js34
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js9
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js17
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js22
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js29
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js33
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js14
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js46
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js51
-rw-r--r--toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js60
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json192
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md121
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini6
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js66
-rw-r--r--toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js507
-rw-r--r--toolkit/components/messaging-system/schemas/index.rst18
-rw-r--r--toolkit/components/messaging-system/targeting/Targeting.jsm216
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/head.js5
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/test_targeting.js245
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini6
-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
60 files changed, 7160 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts b/toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts
new file mode 100644
index 0000000000..ee41deccb1
--- /dev/null
+++ b/toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts
@@ -0,0 +1,49 @@
+export interface FeatureConfig {
+ featureId: "cfr" | "aboutwelcome";
+ enabled: boolean;
+ value: { [key: string]: any } | null;
+}
+
+export interface Branch {
+ slug: string;
+ ratio: number;
+ feature: FeatureConfig;
+}
+
+interface BucketConfig {
+ namespace: string;
+ randomizationUnit: string;
+ start: number;
+ count: number;
+ total: number;
+}
+
+export interface RecipeArgs {
+ slug: string;
+ isEnrollmentPaused: boolean;
+ experimentType?: string;
+ branches: Branch[];
+ bucketConfig: BucketConfig;
+}
+
+export interface Recipe {
+ id: string;
+ // Processed by Remote Settings, Normandy
+ filter_expression?: string;
+ // Processed by RemoteSettingsExperimentLoader
+ targeting?: string;
+ arguments: RecipeArgs;
+}
+
+export interface Enrollment {
+ slug: string;
+ enrollmentId: string;
+ branch: Branch;
+ active: boolean;
+ experimentType: string;
+ source: string;
+ // Shown in about:studies
+ userFacingName: string;
+ userFacingDescription: string;
+ lastSeen: string;
+}
diff --git a/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm b/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
new file mode 100644
index 0000000000..8d2172fd47
--- /dev/null
+++ b/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
@@ -0,0 +1,266 @@
+/* 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";
+
+/**
+ * @typedef {import("./@types/ExperimentManager").Enrollment} Enrollment
+ * @typedef {import("./@types/ExperimentManager").FeatureConfig} FeatureConfig
+ */
+
+const EXPORTED_SYMBOLS = ["ExperimentAPI"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ExperimentStore:
+ "resource://messaging-system/experiments/ExperimentStore.jsm",
+ ExperimentManager:
+ "resource://messaging-system/experiments/ExperimentManager.jsm",
+ RemoteSettings: "resource://services-settings/remote-settings.js",
+});
+
+const IS_MAIN_PROCESS =
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id";
+const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments";
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "COLLECTION_ID",
+ COLLECTION_ID_PREF,
+ COLLECTION_ID_FALLBACK
+);
+
+const ExperimentAPI = {
+ /**
+ * @returns {Promise} Resolves when the API has synchronized to the main store
+ */
+ ready() {
+ return this._store.ready();
+ },
+
+ /**
+ * Returns an experiment, including all its metadata
+ * Sends exposure ping
+ *
+ * @param {{slug?: string, featureId?: string}} options slug = An experiment identifier
+ * or feature = a stable identifier for a type of experiment
+ * @returns {{slug: string, active: bool, exposurePingSent: bool}} A matching experiment if one is found.
+ */
+ getExperiment({ slug, featureId, sendExposurePing } = {}) {
+ if (!slug && !featureId) {
+ throw new Error(
+ "getExperiment(options) must include a slug or a feature."
+ );
+ }
+ let experimentData;
+ if (slug) {
+ experimentData = this._store.get(slug);
+ } else if (featureId) {
+ experimentData = this._store.getExperimentForFeature(featureId);
+ }
+ if (experimentData) {
+ return {
+ slug: experimentData.slug,
+ active: experimentData.active,
+ exposurePingSent: experimentData.exposurePingSent,
+ branch: this.getFeatureBranch({ featureId, sendExposurePing }),
+ };
+ }
+
+ return null;
+ },
+
+ /**
+ * Return experiment slug its status and the enrolled branch slug
+ * Does NOT send exposure ping because you only have access to the slugs
+ */
+ getExperimentMetaData({ slug, featureId }) {
+ if (!slug && !featureId) {
+ throw new Error(
+ "getExperiment(options) must include a slug or a feature."
+ );
+ }
+
+ let experimentData;
+ if (slug) {
+ experimentData = this._store.get(slug);
+ } else if (featureId) {
+ experimentData = this._store.getExperimentForFeature(featureId);
+ }
+ if (experimentData) {
+ return {
+ slug: experimentData.slug,
+ active: experimentData.active,
+ exposurePingSent: experimentData.exposurePingSent,
+ branch: { slug: experimentData.branch.slug },
+ };
+ }
+
+ return null;
+ },
+
+ /**
+ * Lookup feature in active experiments and return status.
+ * Sends exposure ping
+ * @param {string} featureId Feature to lookup
+ * @param {boolean} defaultValue
+ * @returns {boolean}
+ */
+ isFeatureEnabled(featureId, defaultValue) {
+ const branch = this.getFeatureBranch({ featureId });
+ if (branch?.feature.enabled !== undefined) {
+ return branch.feature.enabled;
+ }
+ return defaultValue;
+ },
+
+ /**
+ * Lookup feature in active experiments and return value.
+ * By default, this will send an exposure event.
+ * @param {{featureId: string, sendExposurePing: boolean}} options
+ * @returns {obj} The feature value
+ */
+ getFeatureValue(options) {
+ return this._store.activateBranch(options)?.feature.value;
+ },
+
+ /**
+ * Lookup feature in active experiments and returns the entire branch.
+ * By default, this will send an exposure event.
+ * @param {{featureId: string, sendExposurePing: boolean}} options
+ * @returns {Branch}
+ */
+ getFeatureBranch(options) {
+ return this._store.activateBranch(options);
+ },
+
+ /**
+ * Registers an event listener.
+ * The following event names are used:
+ * `update` - an experiment is updated, for example it is no longer active
+ *
+ * @param {string} eventName must follow the pattern `event:slug-name`
+ * @param {{slug?: string, featureId: string?}} options
+ * @param {function} callback
+
+ * @returns {void}
+ */
+ on(eventName, options, callback) {
+ if (!options) {
+ throw new Error("Please include an experiment slug or featureId");
+ }
+ let fullEventName = `${eventName}:${options.slug || options.featureId}`;
+
+ // The update event will always fire after the event listener is added, either
+ // immediately if it is already ready, or on ready
+ this._store.ready().then(() => {
+ let experiment = this.getExperiment(options);
+ // Only if we have an experiment that matches what the caller requested
+ if (experiment) {
+ // If the store already has the experiment in the store then we should
+ // notify. This covers the startup scenario or cases where listeners
+ // are attached later than the `update` events.
+ callback(fullEventName, experiment);
+ }
+ });
+
+ this._store.on(fullEventName, callback);
+ },
+
+ /**
+ * Deregisters an event listener.
+ * @param {string} eventName
+ * @param {function} callback
+ */
+ off(eventName, callback) {
+ this._store.off(eventName, callback);
+ },
+
+ /**
+ * Returns the recipe for a given experiment slug
+ *
+ * This should noly be called from the main process.
+ *
+ * Note that the recipe is directly fetched from RemoteSettings, which has
+ * all the recipe metadata available without relying on the `this._store`.
+ * Therefore, calling this function does not require to call `this.ready()` first.
+ *
+ * @param slug {String} An experiment identifier
+ * @returns {Recipe|undefined} A matching experiment recipe if one is found
+ */
+ async getRecipe(slug) {
+ if (!IS_MAIN_PROCESS) {
+ throw new Error(
+ "getRecipe() should only be called from the main process"
+ );
+ }
+
+ let recipe;
+
+ try {
+ [recipe] = await this._remoteSettingsClient.get({
+ // Do not sync the RS store, let RemoteSettingsExperimentLoader do that
+ syncIfEmpty: false,
+ filters: { slug },
+ });
+ } catch (e) {
+ Cu.reportError(e);
+ recipe = undefined;
+ }
+
+ return recipe;
+ },
+
+ /**
+ * Returns all the branches for a given experiment slug
+ *
+ * This should only be called from the main process. Like `getRecipe()`,
+ * calling this function does not require to call `this.ready()` first.
+ *
+ * @param slug {String} An experiment identifier
+ * @returns {[Branches]|undefined} An array of branches for the given slug
+ */
+ async getAllBranches(slug) {
+ if (!IS_MAIN_PROCESS) {
+ throw new Error(
+ "getAllBranches() should only be called from the main process"
+ );
+ }
+
+ const recipe = await this.getRecipe(slug);
+ return recipe?.branches;
+ },
+
+ recordExposureEvent(name, { sent, experimentSlug, branchSlug }) {
+ if (!IS_MAIN_PROCESS) {
+ Cu.reportError("Need to call from Parent process");
+ return false;
+ }
+ if (sent) {
+ return false;
+ }
+
+ // Notify listener to record that the ping was sent
+ this._store._emitExperimentExposure({
+ featureId: name,
+ experimentSlug,
+ branchSlug,
+ });
+
+ return true;
+ },
+};
+
+XPCOMUtils.defineLazyGetter(ExperimentAPI, "_store", function() {
+ return IS_MAIN_PROCESS ? ExperimentManager.store : new ExperimentStore();
+});
+
+XPCOMUtils.defineLazyGetter(ExperimentAPI, "_remoteSettingsClient", function() {
+ return RemoteSettings(COLLECTION_ID);
+});
diff --git a/toolkit/components/messaging-system/experiments/ExperimentManager.jsm b/toolkit/components/messaging-system/experiments/ExperimentManager.jsm
new file mode 100644
index 0000000000..5c3eb02251
--- /dev/null
+++ b/toolkit/components/messaging-system/experiments/ExperimentManager.jsm
@@ -0,0 +1,476 @@
+/* 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";
+
+/**
+ * @typedef {import("./@types/ExperimentManager").RecipeArgs} RecipeArgs
+ * @typedef {import("./@types/ExperimentManager").Enrollment} Enrollment
+ * @typedef {import("./@types/ExperimentManager").Branch} Branch
+ */
+
+const EXPORTED_SYMBOLS = ["ExperimentManager", "_ExperimentManager"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
+ ExperimentStore:
+ "resource://messaging-system/experiments/ExperimentStore.jsm",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
+ Sampling: "resource://gre/modules/components-utils/Sampling.jsm",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
+ FirstStartup: "resource://gre/modules/FirstStartup.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ const { Logger } = ChromeUtils.import(
+ "resource://messaging-system/lib/Logger.jsm"
+ );
+ return new Logger("ExperimentManager");
+});
+
+// This is included with event telemetry e.g. "enroll"
+// TODO: Add a new type called "messaging_study"
+const EVENT_TELEMETRY_STUDY_TYPE = "preference_study";
+// This is used by Telemetry.setExperimentActive
+const TELEMETRY_EXPERIMENT_TYPE_PREFIX = "normandy-";
+// Also included in telemetry
+const DEFAULT_EXPERIMENT_TYPE = "messaging_experiment";
+const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
+const EXPOSURE_EVENT_CATEGORY = "normandy";
+const EXPOSURE_EVENT_METHOD = "expose";
+
+/**
+ * A module for processes Experiment recipes, choosing and storing enrollment state,
+ * and sending experiment-related Telemetry.
+ */
+class _ExperimentManager {
+ constructor({ id = "experimentmanager", store } = {}) {
+ this.id = id;
+ this.store = store || new ExperimentStore();
+ this.sessions = new Map();
+ this._onExposureEvent = this._onExposureEvent.bind(this);
+ Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this);
+ }
+
+ /**
+ * Creates a targeting context with following filters:
+ *
+ * * `activeExperiments`: an array of slugs of all the active experiments
+ * * `isFirstStartup`: a boolean indicating whether or not the current enrollment
+ * is performed during the first startup
+ *
+ * @returns {Object} A context object
+ * @memberof _ExperimentManager
+ */
+ createTargetingContext() {
+ let context = {
+ isFirstStartup: FirstStartup.state === FirstStartup.IN_PROGRESS,
+ };
+ Object.defineProperty(context, "activeExperiments", {
+ get: async () => {
+ await this.store.ready();
+ return this.store.getAllActive().map(exp => exp.slug);
+ },
+ });
+ return context;
+ }
+
+ /**
+ * Runs on startup, including before first run
+ */
+ async onStartup() {
+ await this.store.init();
+ this.store.on("exposure", this._onExposureEvent);
+ const restoredExperiments = this.store.getAllActive();
+
+ for (const experiment of restoredExperiments) {
+ this.setExperimentActive(experiment);
+ }
+ }
+
+ /**
+ * Runs every time a Recipe is updated or seen for the first time.
+ * @param {RecipeArgs} recipe
+ * @param {string} source
+ */
+ async onRecipe(recipe, source) {
+ const { slug, isEnrollmentPaused } = recipe;
+
+ if (!source) {
+ throw new Error("When calling onRecipe, you must specify a source.");
+ }
+
+ if (!this.sessions.has(source)) {
+ this.sessions.set(source, new Set());
+ }
+ this.sessions.get(source).add(slug);
+
+ if (this.store.has(slug)) {
+ this.updateEnrollment(recipe);
+ } else if (isEnrollmentPaused) {
+ log.debug(`Enrollment is paused for "${slug}"`);
+ } else if (!(await this.isInBucketAllocation(recipe.bucketConfig))) {
+ log.debug("Client was not enrolled because of the bucket sampling");
+ } else {
+ await this.enroll(recipe, source);
+ }
+ }
+
+ /**
+ * Runs when the all recipes been processed during an update, including at first run.
+ * @param {string} sourceToCheck
+ */
+ onFinalize(sourceToCheck) {
+ if (!sourceToCheck) {
+ throw new Error("When calling onFinalize, you must specify a source.");
+ }
+ const activeExperiments = this.store.getAllActive();
+
+ for (const experiment of activeExperiments) {
+ const { slug, source } = experiment;
+ if (sourceToCheck !== source) {
+ continue;
+ }
+ if (!this.sessions.get(source)?.has(slug)) {
+ log.debug(`Stopping study for recipe ${slug}`);
+ try {
+ this.unenroll(slug, "recipe-not-seen");
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ }
+ }
+
+ this.sessions.delete(sourceToCheck);
+ }
+
+ /**
+ * Bucket configuration specifies a specific percentage of clients that can
+ * be enrolled.
+ * @param {BucketConfig} bucketConfig
+ * @returns {Promise<boolean>}
+ */
+ isInBucketAllocation(bucketConfig) {
+ if (!bucketConfig) {
+ log.debug("Cannot enroll if recipe bucketConfig is not set.");
+ return false;
+ }
+
+ let id;
+ if (bucketConfig.randomizationUnit === "normandy_id") {
+ id = ClientEnvironment.userId;
+ } else {
+ // Others not currently supported.
+ log.debug(`Invalid randomizationUnit: ${bucketConfig.randomizationUnit}`);
+ return false;
+ }
+
+ return Sampling.bucketSample(
+ [id, bucketConfig.namespace],
+ bucketConfig.start,
+ bucketConfig.count,
+ bucketConfig.total
+ );
+ }
+
+ /**
+ * Start a new experiment by enrolling the users
+ *
+ * @param {RecipeArgs} recipe
+ * @param {string} source
+ * @returns {Promise<Enrollment>} The experiment object stored in the data store
+ * @rejects {Error}
+ * @memberof _ExperimentManager
+ */
+ async enroll(
+ {
+ slug,
+ branches,
+ experimentType = DEFAULT_EXPERIMENT_TYPE,
+ userFacingName,
+ userFacingDescription,
+ },
+ source
+ ) {
+ if (this.store.has(slug)) {
+ this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
+ throw new Error(`An experiment with the slug "${slug}" already exists.`);
+ }
+
+ const enrollmentId = NormandyUtils.generateUuid();
+ const branch = await this.chooseBranch(slug, branches);
+
+ if (
+ this.store.hasExperimentForFeature(
+ // Extract out only the feature names from the branch
+ branch.feature?.featureId
+ )
+ ) {
+ log.debug(
+ `Skipping enrollment for "${slug}" because there is an existing experiment for its feature.`
+ );
+ this.sendFailureTelemetry("enrollFailed", slug, "feature-conflict");
+ throw new Error(
+ `An experiment with a conflicting feature already exists.`
+ );
+ }
+
+ /** @type {Enrollment} */
+ const experiment = {
+ slug,
+ branch,
+ active: true,
+ // Sent first time feature value is used
+ exposurePingSent: false,
+ enrollmentId,
+ experimentType,
+ source,
+ userFacingName,
+ userFacingDescription,
+ lastSeen: new Date().toJSON(),
+ };
+
+ this.store.addExperiment(experiment);
+ this.setExperimentActive(experiment);
+ this.sendEnrollmentTelemetry(experiment);
+
+ log.debug(`New experiment started: ${slug}, ${branch.slug}`);
+
+ return experiment;
+ }
+
+ /**
+ * Update an enrollment that was already set
+ *
+ * @param {RecipeArgs} recipe
+ */
+ updateEnrollment(recipe) {
+ /** @type Enrollment */
+ const experiment = this.store.get(recipe.slug);
+
+ // Don't update experiments that were already unenrolled.
+ if (experiment.active === false) {
+ log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
+ return;
+ }
+
+ // Stay in the same branch, don't re-sample every time.
+ const branch = recipe.branches.find(
+ branch => branch.slug === experiment.branch.slug
+ );
+
+ if (!branch) {
+ // Our branch has been removed. Unenroll.
+ this.unenroll(recipe.slug, "branch-removed");
+ }
+ }
+
+ /**
+ * Stop an experiment that is currently active
+ *
+ * @param {string} slug
+ * @param {string} reason
+ */
+ unenroll(slug, reason = "unknown") {
+ const experiment = this.store.get(slug);
+ if (!experiment) {
+ this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
+ throw new Error(`Could not find an experiment with the slug "${slug}"`);
+ }
+
+ if (!experiment.active) {
+ this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled");
+ throw new Error(
+ `Cannot stop experiment "${slug}" because it is already expired`
+ );
+ }
+
+ this.store.updateExperiment(slug, { active: false });
+
+ TelemetryEnvironment.setExperimentInactive(slug);
+ TelemetryEvents.sendEvent("unenroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
+ reason,
+ branch: experiment.branch.slug,
+ enrollmentId:
+ experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+
+ log.debug(`Experiment unenrolled: ${slug}`);
+ }
+
+ /**
+ * Unenroll from all active studies if user opts out.
+ */
+ observe(aSubject, aTopic, aPrefName) {
+ if (Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF)) {
+ return;
+ }
+ for (const { slug } of this.store.getAllActive()) {
+ this.unenroll(slug, "studies-opt-out");
+ }
+ }
+
+ /**
+ * Send Telemetry for undesired event
+ *
+ * @param {string} eventName
+ * @param {string} slug
+ * @param {string} reason
+ */
+ sendFailureTelemetry(eventName, slug, reason) {
+ TelemetryEvents.sendEvent(eventName, EVENT_TELEMETRY_STUDY_TYPE, slug, {
+ reason,
+ });
+ }
+
+ /**
+ *
+ * @param {Enrollment} experiment
+ */
+ sendEnrollmentTelemetry({ slug, branch, experimentType, enrollmentId }) {
+ TelemetryEvents.sendEvent("enroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
+ experimentType,
+ branch: branch.slug,
+ enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ async _onExposureEvent(event, experimentData) {
+ await this.store.ready();
+ this.store.updateExperiment(experimentData.experimentSlug, {
+ exposurePingSent: true,
+ });
+ // featureId is not validated and might be rejected by recordEvent if not
+ // properly defined in Events.yaml. Saving experiment state regardless of
+ // the result, no use retrying.
+ try {
+ Services.telemetry.recordEvent(
+ EXPOSURE_EVENT_CATEGORY,
+ EXPOSURE_EVENT_METHOD,
+ "feature_study",
+ experimentData.experimentSlug,
+ {
+ branchSlug: experimentData.branchSlug,
+ featureId: experimentData.featureId,
+ }
+ );
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ log.debug(`Experiment exposure: ${experimentData.experimentSlug}`);
+ }
+
+ /**
+ * Sets Telemetry when activating an experiment.
+ *
+ * @param {Enrollment} experiment
+ * @memberof _ExperimentManager
+ */
+ setExperimentActive(experiment) {
+ TelemetryEnvironment.setExperimentActive(
+ experiment.slug,
+ experiment.branch.slug,
+ {
+ type: `${TELEMETRY_EXPERIMENT_TYPE_PREFIX}${experiment.experimentType}`,
+ enrollmentId:
+ experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ }
+
+ /**
+ * Generate Normandy UserId respective to a branch
+ * for a given experiment.
+ *
+ * @param {string} slug
+ * @param {Array<{slug: string; ratio: number}>} branches
+ * @param {string} namespace
+ * @param {number} start
+ * @param {number} count
+ * @param {number} total
+ * @returns {Promise<{[branchName: string]: string}>} An object where
+ * the keys are branch names and the values are user IDs that will enroll
+ * a user for that particular branch. Also includes a `notInExperiment` value
+ * that will not enroll the user in the experiment
+ */
+ async generateTestIds({ slug, branches, namespace, start, count, total }) {
+ const branchValues = {};
+
+ if (!slug || !namespace) {
+ throw new Error(`slug, namespace not in expected format`);
+ }
+
+ if (!(start < total && count < total)) {
+ throw new Error("Must include start, count, and total as integers");
+ }
+
+ if (
+ !Array.isArray(branches) ||
+ branches.filter(branch => branch.slug && branch.ratio).length !==
+ branches.length
+ ) {
+ throw new Error("branches parameter not in expected format");
+ }
+
+ while (Object.keys(branchValues).length < branches.length + 1) {
+ const id = NormandyUtils.generateUuid();
+ const enrolls = await Sampling.bucketSample(
+ [id, namespace],
+ start,
+ count,
+ total
+ );
+ // Does this id enroll the user in the experiment
+ if (enrolls) {
+ // Choose a random branch
+ const { slug: pickedBranch } = await this.chooseBranch(
+ slug,
+ branches,
+ id
+ );
+
+ if (!Object.keys(branchValues).includes(pickedBranch)) {
+ branchValues[pickedBranch] = id;
+ log.debug(`Found a value for "${pickedBranch}"`);
+ }
+ } else if (!branchValues.notInExperiment) {
+ branchValues.notInExperiment = id;
+ }
+ }
+ return branchValues;
+ }
+
+ /**
+ * Choose a branch randomly.
+ *
+ * @param {string} slug
+ * @param {Branch[]} branches
+ * @returns {Promise<Branch>}
+ * @memberof _ExperimentManager
+ */
+ async chooseBranch(slug, branches, userId = ClientEnvironment.userId) {
+ const ratios = branches.map(({ ratio = 1 }) => ratio);
+
+ // It's important that the input be:
+ // - Unique per-user (no one is bucketed alike)
+ // - Unique per-experiment (bucketing differs across multiple experiments)
+ // - Differs from the input used for sampling the recipe (otherwise only
+ // branches that contain the same buckets as the recipe sampling will
+ // receive users)
+ const input = `${this.id}-${userId}-${slug}-branch`;
+
+ const index = await Sampling.ratioSample(input, ratios);
+ return branches[index];
+ }
+}
+
+const ExperimentManager = new _ExperimentManager();
diff --git a/toolkit/components/messaging-system/experiments/ExperimentStore.jsm b/toolkit/components/messaging-system/experiments/ExperimentStore.jsm
new file mode 100644
index 0000000000..9e1e8cb0e8
--- /dev/null
+++ b/toolkit/components/messaging-system/experiments/ExperimentStore.jsm
@@ -0,0 +1,196 @@
+/* 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";
+
+/**
+ * @typedef {import("./@types/ExperimentManager").Enrollment} Enrollment
+ * @typedef {import("./@types/ExperimentManager").FeatureConfig} FeatureConfig
+ */
+
+const EXPORTED_SYMBOLS = ["ExperimentStore"];
+
+const { SharedDataMap } = ChromeUtils.import(
+ "resource://messaging-system/lib/SharedDataMap.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const IS_MAIN_PROCESS =
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+const SYNC_DATA_PREF = "messaging-system.syncdatastore.data";
+let tryJSONParse = data => {
+ try {
+ return JSON.parse(data);
+ } catch (e) {}
+
+ return {};
+};
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "syncDataStore",
+ SYNC_DATA_PREF,
+ {},
+ // aOnUpdate
+ (data, prev, latest) => tryJSONParse(latest),
+ // aTransform
+ tryJSONParse
+);
+
+const DEFAULT_STORE_ID = "ExperimentStoreData";
+// Experiment feature configs that should be saved to prefs for
+// fast access on startup.
+const SYNC_ACCESS_FEATURES = ["newtab", "aboutwelcome"];
+
+class ExperimentStore extends SharedDataMap {
+ constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) {
+ super(sharedDataKey || DEFAULT_STORE_ID, options);
+ }
+
+ /**
+ * Given a feature identifier, find an active experiment that matches that feature identifier.
+ * This assumes, for now, that there is only one active experiment per feature per browser.
+ *
+ * @param {string} featureId
+ * @returns {Enrollment|undefined} An active experiment if it exists
+ * @memberof ExperimentStore
+ */
+ getExperimentForFeature(featureId) {
+ return this.getAllActive().find(
+ experiment => experiment.branch.feature?.featureId === featureId
+ );
+ }
+
+ /**
+ * Return FeatureConfig from first active experiment where it can be found
+ * @param {{slug: string, featureId: string, sendExposurePing: bool}}
+ * @returns {Branch | null}
+ */
+ activateBranch({ slug, featureId, sendExposurePing = true }) {
+ for (let experiment of this.getAllActive()) {
+ if (
+ experiment?.branch.feature.featureId === featureId ||
+ experiment.slug === slug
+ ) {
+ if (sendExposurePing) {
+ this._emitExperimentExposure({
+ experimentSlug: experiment.slug,
+ branchSlug: experiment.branch.slug,
+ featureId,
+ });
+ }
+ // Default to null for feature-less experiments where we're only
+ // interested in exposure.
+ return experiment?.branch || null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if an active experiment already exists for a feature
+ *
+ * @param {string} featureId
+ * @returns {boolean} Does an active experiment exist for that feature?
+ * @memberof ExperimentStore
+ */
+ hasExperimentForFeature(featureId) {
+ if (!featureId) {
+ return false;
+ }
+ if (this.activateBranch({ featureId })?.feature.featureId === featureId) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * @returns {Enrollment[]}
+ */
+ getAll() {
+ if (!this._data) {
+ return Object.values(syncDataStore);
+ }
+
+ return Object.values(this._data);
+ }
+
+ /**
+ * @returns {Enrollment[]}
+ */
+ getAllActive() {
+ return this.getAll().filter(experiment => experiment.active);
+ }
+
+ _emitExperimentUpdates(experiment) {
+ this.emit(`update:${experiment.slug}`, experiment);
+ if (experiment.branch.feature) {
+ this.emit(`update:${experiment.branch.feature.featureId}`, experiment);
+ }
+ }
+
+ /**
+ * @param {{featureId: string, experimentSlug: string, branchSlug: string}} experimentData
+ */
+ _emitExperimentExposure(experimentData) {
+ this.emit("exposure", experimentData);
+ }
+
+ /**
+ * @param {Enrollment} experiment
+ */
+ _updateSyncStore(experiment) {
+ if (SYNC_ACCESS_FEATURES.includes(experiment.branch.feature?.featureId)) {
+ if (!experiment.active) {
+ // Remove experiments on un-enroll, otherwise nothing to do
+ if (syncDataStore[experiment.slug]) {
+ delete syncDataStore[experiment.slug];
+ }
+ } else {
+ syncDataStore[experiment.slug] = experiment;
+ }
+ Services.prefs.setStringPref(
+ SYNC_DATA_PREF,
+ JSON.stringify(syncDataStore)
+ );
+ }
+ }
+
+ /**
+ * Add an experiment. Short form for .set(slug, experiment)
+ * @param {Enrollment} experiment
+ */
+ addExperiment(experiment) {
+ if (!experiment || !experiment.slug) {
+ throw new Error(
+ `Tried to add an experiment but it didn't have a .slug property.`
+ );
+ }
+ this.set(experiment.slug, experiment);
+ this._emitExperimentUpdates(experiment);
+ this._updateSyncStore(experiment);
+ }
+
+ /**
+ * Merge new properties into the properties of an existing experiment
+ * @param {string} slug
+ * @param {Partial<Enrollment>} newProperties
+ */
+ updateExperiment(slug, newProperties) {
+ const oldProperties = this.get(slug);
+ if (!oldProperties) {
+ throw new Error(
+ `Tried to update experiment ${slug} bug it doesn't exist`
+ );
+ }
+ const updatedExperiment = { ...oldProperties, ...newProperties };
+ this.set(slug, updatedExperiment);
+ this._emitExperimentUpdates(updatedExperiment);
+ this._updateSyncStore(updatedExperiment);
+ }
+}
diff --git a/toolkit/components/messaging-system/jar.mn b/toolkit/components/messaging-system/jar.mn
new file mode 100644
index 0000000000..3ed3c08d19
--- /dev/null
+++ b/toolkit/components/messaging-system/jar.mn
@@ -0,0 +1,9 @@
+# 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/.
+
+toolkit.jar:
+% resource messaging-system %res/messaging-system/
+ res/messaging-system/experiments/ (./experiments/*)
+ res/messaging-system/lib/ (./lib/*)
+ res/messaging-system/targeting/Targeting.jsm (./targeting/Targeting.jsm)
diff --git a/toolkit/components/messaging-system/lib/Logger.jsm b/toolkit/components/messaging-system/lib/Logger.jsm
new file mode 100644
index 0000000000..2afc3aa526
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/Logger.jsm
@@ -0,0 +1,22 @@
+/* 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";
+
+const EXPORTED_SYMBOLS = ["Logger"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm");
+
+const LOGGING_PREF = "messaging-system.log";
+
+class Logger extends ConsoleAPI {
+ constructor(name) {
+ let consoleOptions = {
+ prefix: name,
+ maxLogLevel: Services.prefs.getCharPref(LOGGING_PREF, "warn"),
+ maxLogLevelPref: LOGGING_PREF,
+ };
+ super(consoleOptions);
+ }
+}
diff --git a/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm b/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
new file mode 100644
index 0000000000..8a6ff55911
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
@@ -0,0 +1,229 @@
+/* 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";
+
+/**
+ * @typedef {import("../experiments/@types/ExperimentManager").Recipe} Recipe
+ */
+
+const EXPORTED_SYMBOLS = [
+ "_RemoteSettingsExperimentLoader",
+ "RemoteSettingsExperimentLoader",
+];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.jsm",
+ ExperimentManager:
+ "resource://messaging-system/experiments/ExperimentManager.jsm",
+ RemoteSettings: "resource://services-settings/remote-settings.js",
+ CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ const { Logger } = ChromeUtils.import(
+ "resource://messaging-system/lib/Logger.jsm"
+ );
+ return new Logger("RSLoader");
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "timerManager",
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager"
+);
+
+const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id";
+const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments";
+const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled";
+const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
+
+const TIMER_NAME = "rs-experiment-loader-timer";
+const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`;
+// Use the same update interval as normandy
+const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "COLLECTION_ID",
+ COLLECTION_ID_PREF,
+ COLLECTION_ID_FALLBACK
+);
+
+class _RemoteSettingsExperimentLoader {
+ constructor() {
+ // Has the timer been set?
+ this._initialized = false;
+ // Are we in the middle of updating recipes already?
+ this._updating = false;
+
+ // Make it possible to override for testing
+ this.manager = ExperimentManager;
+
+ XPCOMUtils.defineLazyGetter(this, "remoteSettingsClient", () => {
+ return RemoteSettings(COLLECTION_ID);
+ });
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "enabled",
+ ENABLED_PREF,
+ false,
+ this.onEnabledPrefChange.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "studiesEnabled",
+ STUDIES_OPT_OUT_PREF,
+ false,
+ this.onEnabledPrefChange.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "intervalInSeconds",
+ RUN_INTERVAL_PREF,
+ 21600,
+ () => this.setTimer()
+ );
+ }
+
+ async init() {
+ if (this._initialized || !this.enabled || !this.studiesEnabled) {
+ return;
+ }
+
+ this.setTimer();
+ CleanupManager.addCleanupHandler(() => this.uninit());
+ this._initialized = true;
+
+ await this.updateRecipes();
+ }
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+ timerManager.unregisterTimer(TIMER_NAME);
+ this._initialized = false;
+ }
+
+ /**
+ * Checks targeting of a recipe if it is defined
+ * @param {Recipe} recipe
+ * @param {{[key: string]: any}} customContext A custom filter context
+ * @returns {Promise<boolean>} Should we process the recipe?
+ */
+ async checkTargeting(recipe, customContext = {}) {
+ const context = TargetingContext.combineContexts(
+ { experiment: recipe },
+ customContext,
+ ASRouterTargeting.Environment
+ );
+ const { targeting } = recipe;
+ if (!targeting) {
+ log.debug("No targeting for recipe, so it matches automatically");
+ return true;
+ }
+ log.debug("Testing targeting expression:", targeting);
+ const targetingContext = new TargetingContext(context);
+ let result = false;
+ try {
+ result = await targetingContext.evalWithDefault(targeting);
+ } catch (e) {
+ log.debug("Targeting failed because of an error");
+ Cu.reportError(e);
+ }
+ return Boolean(result);
+ }
+
+ /**
+ * Get all recipes from remote settings
+ * @param {string} trigger What caused the update to occur?
+ */
+ async updateRecipes(trigger) {
+ if (this._updating || !this._initialized) {
+ return;
+ }
+ this._updating = true;
+
+ log.debug("Updating recipes" + (trigger ? ` with trigger ${trigger}` : ""));
+
+ let recipes;
+ let loadingError = false;
+
+ try {
+ recipes = await this.remoteSettingsClient.get();
+ log.debug(`Got ${recipes.length} recipes from Remote Settings`);
+ } catch (e) {
+ log.debug("Error getting recipes from remote settings.");
+ loadingError = true;
+ Cu.reportError(e);
+ }
+
+ let matches = 0;
+ if (recipes && !loadingError) {
+ const context = this.manager.createTargetingContext();
+
+ for (const r of recipes) {
+ if (await this.checkTargeting(r, context)) {
+ matches++;
+ log.debug(`${r.id} matched`);
+ await this.manager.onRecipe(r, "rs-loader");
+ } else {
+ log.debug(`${r.id} did not match due to targeting`);
+ }
+ }
+
+ log.debug(`${matches} recipes matched. Finalizing ExperimentManager.`);
+ this.manager.onFinalize("rs-loader");
+ }
+
+ if (trigger !== "timer") {
+ const lastUpdateTime = Math.round(Date.now() / 1000);
+ Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
+ }
+
+ this._updating = false;
+ }
+
+ /**
+ * Handles feature status based on feature pref and STUDIES_OPT_OUT_PREF.
+ * Changing any of them to false will turn off any recipe fetching and
+ * processing.
+ */
+ onEnabledPrefChange(prefName, oldValue, newValue) {
+ if (this._initialized && !newValue) {
+ this.uninit();
+ } else if (!this._initialized && newValue && this.enabled) {
+ // If the feature pref is turned on then turn on recipe processing.
+ // If the opt in pref is turned on then turn on recipe processing only if
+ // the feature pref is also enabled.
+ this.init();
+ }
+ }
+
+ /**
+ * Sets a timer to update recipes every this.intervalInSeconds
+ */
+ setTimer() {
+ // When this function is called, updateRecipes is also called immediately
+ timerManager.registerTimer(
+ TIMER_NAME,
+ () => this.updateRecipes("timer"),
+ this.intervalInSeconds
+ );
+ log.debug("Registered update timer");
+ }
+}
+
+const RemoteSettingsExperimentLoader = new _RemoteSettingsExperimentLoader();
diff --git a/toolkit/components/messaging-system/lib/SharedDataMap.jsm b/toolkit/components/messaging-system/lib/SharedDataMap.jsm
new file mode 100644
index 0000000000..b61b112a1d
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/SharedDataMap.jsm
@@ -0,0 +1,163 @@
+/* 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";
+
+const EXPORTED_SYMBOLS = ["SharedDataMap"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { EventEmitter } = ChromeUtils.import(
+ "resource://gre/modules/EventEmitter.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PromiseUtils",
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+
+const IS_MAIN_PROCESS =
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "JSONFile",
+ "resource://gre/modules/JSONFile.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+class SharedDataMap extends EventEmitter {
+ constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) {
+ super();
+
+ this._sharedDataKey = sharedDataKey;
+ this._isParent = options.isParent;
+ this._isReady = false;
+ this._readyDeferred = PromiseUtils.defer();
+ this._data = null;
+
+ if (this.isParent) {
+ // Lazy-load JSON file that backs Storage instances.
+ XPCOMUtils.defineLazyGetter(this, "_store", () => {
+ let path = options.path;
+ let store = null;
+ if (!path) {
+ try {
+ const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ path = PathUtils.join(profileDir, `${sharedDataKey}.json`);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ try {
+ store = new JSONFile({ path });
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ return store;
+ });
+ } else {
+ this._syncFromParent();
+ Services.cpmm.sharedData.addEventListener("change", this);
+ }
+ }
+
+ async init() {
+ if (!this._isReady && this.isParent) {
+ await this._store.load();
+ this._data = this._store.data;
+ this._syncToChildren({ flush: true });
+ this._checkIfReady();
+ }
+ }
+
+ get sharedDataKey() {
+ return this._sharedDataKey;
+ }
+
+ get isParent() {
+ return this._isParent;
+ }
+
+ ready() {
+ return this._readyDeferred.promise;
+ }
+
+ get(key) {
+ if (!this._data) {
+ return null;
+ }
+ return this._data[key];
+ }
+
+ set(key, value) {
+ if (!this.isParent) {
+ throw new Error(
+ "Setting values from within a content process is not allowed"
+ );
+ }
+ this._store.data[key] = value;
+ this._store.saveSoon();
+ this._syncToChildren();
+ this._notifyUpdate();
+ }
+
+ // Only used in tests
+ _deleteForTests(key) {
+ if (!this.isParent) {
+ throw new Error(
+ "Setting values from within a content process is not allowed"
+ );
+ }
+ if (this.has(key)) {
+ delete this._store.data[key];
+ this._store.saveSoon();
+ this._syncToChildren();
+ this._notifyUpdate();
+ }
+ }
+
+ has(key) {
+ return Boolean(this.get(key));
+ }
+
+ /**
+ * Notify store listeners of updates
+ * Called both from Main and Content process
+ */
+ _notifyUpdate(process = "parent") {
+ for (let key of Object.keys(this._data || {})) {
+ this.emit(`${process}-store-update:${key}`, this._data[key]);
+ }
+ }
+
+ _syncToChildren({ flush = false } = {}) {
+ Services.ppmm.sharedData.set(this.sharedDataKey, this._data);
+ if (flush) {
+ Services.ppmm.sharedData.flush();
+ }
+ }
+
+ _syncFromParent() {
+ this._data = Services.cpmm.sharedData.get(this.sharedDataKey);
+ this._checkIfReady();
+ this._notifyUpdate("child");
+ }
+
+ _checkIfReady() {
+ if (!this._isReady && this._data) {
+ this._isReady = true;
+ this._readyDeferred.resolve();
+ }
+ }
+
+ handleEvent(event) {
+ if (event.type === "change") {
+ if (event.changedKeys.includes(this.sharedDataKey)) {
+ this._syncFromParent();
+ }
+ }
+ }
+}
diff --git a/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm b/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm
new file mode 100644
index 0000000000..3d7bed5330
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm
@@ -0,0 +1,301 @@
+/* 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";
+
+const EXPORTED_SYMBOLS = ["SpecialMessageActions"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+const NETWORK_TRR_MODE_PREF = "network.trr.mode";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+ UITour: "resource:///modules/UITour.jsm",
+ FxAccounts: "resource://gre/modules/FxAccounts.jsm",
+ MigrationUtils: "resource:///modules/MigrationUtils.jsm",
+});
+
+const SpecialMessageActions = {
+ // This is overridden by ASRouter.init
+ blockMessageById() {
+ throw new Error("ASRouter not intialized yet");
+ },
+
+ /**
+ * loadAddonIconInURLBar - load addons-notification icon by displaying
+ * box containing addons icon in urlbar. See Bug 1513882
+ *
+ * @param {Browser} browser browser element for showing addons icon
+ */
+ loadAddonIconInURLBar(browser) {
+ if (!browser) {
+ return;
+ }
+ const chromeDoc = browser.ownerDocument;
+ let notificationPopupBox = chromeDoc.getElementById(
+ "notification-popup-box"
+ );
+ if (!notificationPopupBox) {
+ return;
+ }
+ if (
+ notificationPopupBox.style.display === "none" ||
+ notificationPopupBox.style.display === ""
+ ) {
+ notificationPopupBox.style.display = "block";
+ }
+ },
+
+ /**
+ *
+ * @param {Browser} browser The revelant Browser
+ * @param {string} url URL to look up install location
+ * @param {string} telemetrySource Telemetry information to pass to getInstallForURL
+ */
+ async installAddonFromURL(browser, url, telemetrySource = "amo") {
+ try {
+ this.loadAddonIconInURLBar(browser);
+ const aUri = Services.io.newURI(url);
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+ // AddonManager installation source associated to the addons installed from activitystream's CFR
+ // and RTAMO (source is going to be "amo" if not configured explicitly in the message provider).
+ const telemetryInfo = { source: telemetrySource };
+ const install = await AddonManager.getInstallForURL(aUri.spec, {
+ telemetryInfo,
+ });
+ await AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ browser,
+ systemPrincipal,
+ install
+ );
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ },
+
+ /**
+ * Set browser as the operating system default browser.
+ *
+ * @param {Window} window Reference to a window object
+ */
+ setDefaultBrowser(window) {
+ window.getShellService().setAsDefault();
+ },
+
+ /**
+ * Reset browser homepage and newtab to default with a certain section configuration
+ *
+ * @param {"default"|null} home Value to set for browser homepage
+ * @param {"default"|null} newtab Value to set for browser newtab
+ * @param {obj} layout Configuration options for newtab sections
+ * @returns {undefined}
+ */
+ configureHomepage({ homePage = null, newtab = null, layout = null }) {
+ // Homepage can be default, blank or a custom url
+ if (homePage === "default") {
+ Services.prefs.clearUserPref("browser.startup.homepage");
+ }
+ // Newtab page can only be default or blank
+ if (newtab === "default") {
+ Services.prefs.clearUserPref("browser.newtabpage.enabled");
+ }
+ if (layout) {
+ // Existing prefs that interact with the newtab page layout, we default to true
+ // or payload configuration
+ let newtabConfigurations = [
+ [
+ // controls the search bar
+ "browser.newtabpage.activity-stream.showSearch",
+ layout.search,
+ ],
+ [
+ // controls the topsites
+ "browser.newtabpage.activity-stream.feeds.topsites",
+ layout.topsites,
+ // User can control number of topsite rows
+ ["browser.newtabpage.activity-stream.topSitesRows"],
+ ],
+ [
+ // controls the highlights section
+ "browser.newtabpage.activity-stream.feeds.section.highlights",
+ layout.highlights,
+ // User can control number of rows and highlight sources
+ [
+ "browser.newtabpage.activity-stream.section.highlights.rows",
+ "browser.newtabpage.activity-stream.section.highlights.includeVisited",
+ "browser.newtabpage.activity-stream.section.highlights.includePocket",
+ "browser.newtabpage.activity-stream.section.highlights.includeDownloads",
+ "browser.newtabpage.activity-stream.section.highlights.includeBookmarks",
+ ],
+ ],
+ [
+ // controls the snippets section
+ "browser.newtabpage.activity-stream.feeds.snippets",
+ layout.snippets,
+ ],
+ [
+ // controls the topstories section
+ "browser.newtabpage.activity-stream.feeds.system.topstories",
+ layout.topstories,
+ ],
+ ].filter(
+ // If a section has configs that the user changed we will skip that section
+ ([, , sectionConfigs]) =>
+ !sectionConfigs ||
+ sectionConfigs.every(
+ prefName => !Services.prefs.prefHasUserValue(prefName)
+ )
+ );
+
+ for (let [prefName, prefValue] of newtabConfigurations) {
+ Services.prefs.setBoolPref(prefName, prefValue);
+ }
+ }
+ },
+
+ /**
+ * Processes "Special Message Actions", which are definitions of behaviors such as opening tabs
+ * installing add-ons, or focusing the awesome bar that are allowed to can be triggered from
+ * Messaging System interactions.
+ *
+ * @param {{type: string, data?: any}} action User action defined in message JSON.
+ * @param browser {Browser} The browser most relvant to the message.
+ */
+ async handleAction(action, browser) {
+ const window = browser.ownerGlobal;
+ switch (action.type) {
+ case "SHOW_MIGRATION_WIZARD":
+ MigrationUtils.showMigrationWizard(window, [
+ MigrationUtils.MIGRATION_ENTRYPOINT_NEWTAB,
+ action.data?.source,
+ ]);
+ break;
+ case "OPEN_PRIVATE_BROWSER_WINDOW":
+ // Forcefully open about:privatebrowsing
+ window.OpenBrowserWindow({ private: true });
+ break;
+ case "OPEN_URL":
+ window.openLinkIn(
+ Services.urlFormatter.formatURL(action.data.args),
+ action.data.where || "current",
+ {
+ private: false,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ csp: null,
+ }
+ );
+ break;
+ case "OPEN_ABOUT_PAGE":
+ let aboutPageURL = new URL(`about:${action.data.args}`);
+ if (action.data.entrypoint) {
+ aboutPageURL.search = action.data.entrypoint;
+ }
+ window.openTrustedLinkIn(
+ aboutPageURL.toString(),
+ action.data.where || "tab"
+ );
+ break;
+ case "OPEN_PREFERENCES_PAGE":
+ window.openPreferences(
+ action.data.category || action.data.args,
+ action.data.entrypoint && {
+ urlParams: { entrypoint: action.data.entrypoint },
+ }
+ );
+ break;
+ case "OPEN_APPLICATIONS_MENU":
+ UITour.showMenu(window, action.data.args);
+ break;
+ case "HIGHLIGHT_FEATURE":
+ const highlight = await UITour.getTarget(window, action.data.args);
+ if (highlight) {
+ await UITour.showHighlight(window, highlight, "none", {
+ autohide: true,
+ });
+ }
+ break;
+ case "INSTALL_ADDON_FROM_URL":
+ await this.installAddonFromURL(
+ browser,
+ action.data.url,
+ action.data.telemetrySource
+ );
+ break;
+ case "SET_DEFAULT_BROWSER":
+ this.setDefaultBrowser(window);
+ break;
+ case "PIN_CURRENT_TAB":
+ let tab = window.gBrowser.selectedTab;
+ window.gBrowser.pinTab(tab);
+ window.ConfirmationHint.show(tab, "pinTab", {
+ showDescription: true,
+ });
+ break;
+ case "SHOW_FIREFOX_ACCOUNTS":
+ const data = action.data;
+ const url = await FxAccounts.config.promiseConnectAccountURI(
+ (data && data.entrypoint) || "snippets",
+ (data && data.extraParams) || {}
+ );
+ // We want to replace the current tab.
+ window.openLinkIn(url, "current", {
+ private: false,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ csp: null,
+ });
+ break;
+ case "OPEN_PROTECTION_PANEL":
+ let { gProtectionsHandler } = window;
+ gProtectionsHandler.showProtectionsPopup({});
+ break;
+ case "OPEN_PROTECTION_REPORT":
+ window.gProtectionsHandler.openProtections();
+ break;
+ case "OPEN_AWESOME_BAR":
+ window.gURLBar.search("");
+ break;
+ case "DISABLE_STP_DOORHANGERS":
+ await this.blockMessageById([
+ "SOCIAL_TRACKING_PROTECTION",
+ "FINGERPRINTERS_PROTECTION",
+ "CRYPTOMINERS_PROTECTION",
+ ]);
+ break;
+ case "DISABLE_DOH":
+ Services.prefs.setStringPref(
+ DOH_DOORHANGER_DECISION_PREF,
+ "UIDisabled"
+ );
+ Services.prefs.setIntPref(NETWORK_TRR_MODE_PREF, 5);
+ break;
+ case "ACCEPT_DOH":
+ Services.prefs.setStringPref(DOH_DOORHANGER_DECISION_PREF, "UIOk");
+ break;
+ case "CANCEL":
+ // A no-op used by CFRs that minimizes the notification but does not
+ // trigger a dismiss or block (it keeps the notification around)
+ break;
+ case "CONFIGURE_HOMEPAGE":
+ this.configureHomepage(action.data);
+ const topWindow = browser.ownerGlobal.window.BrowserWindowTracker.getTopWindow();
+ if (topWindow) {
+ topWindow.BrowserHome();
+ }
+ break;
+ default:
+ throw new Error(
+ `Special message action with type ${action.type} is unsupported.`
+ );
+ }
+ },
+};
diff --git a/toolkit/components/messaging-system/moz.build b/toolkit/components/messaging-system/moz.build
new file mode 100644
index 0000000000..bce6b3df44
--- /dev/null
+++ b/toolkit/components/messaging-system/moz.build
@@ -0,0 +1,28 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Messaging System")
+
+BROWSER_CHROME_MANIFESTS += [
+ "schemas/SpecialMessageActionSchemas/test/browser/browser.ini",
+ "schemas/TriggerActionSchemas/test/browser/browser.ini",
+ "test/browser/browser.ini",
+]
+
+SPHINX_TREES["docs"] = "schemas"
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
+XPCSHELL_TESTS_MANIFESTS += ["targeting/test/unit/xpcshell.ini"]
+
+TESTING_JS_MODULES += [
+ "schemas/NimbusExperiment.schema.json",
+ "schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json",
+ "schemas/TriggerActionSchemas/TriggerActionSchemas.json",
+ "test/MSTestUtils.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/components/messaging-system/schemas/NimbusExperiment.schema.json b/toolkit/components/messaging-system/schemas/NimbusExperiment.schema.json
new file mode 100644
index 0000000000..c52750f0b1
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/NimbusExperiment.schema.json
@@ -0,0 +1,187 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$ref": "#/definitions/NimbusExperiment",
+ "definitions": {
+ "NimbusExperiment": {
+ "type": "object",
+ "properties": {
+ "slug": {
+ "type": "string",
+ "description": "Unique identifier for the experiment"
+ },
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for the experiment. This is a duplicate of slug, but is a required field\nfor all Remote Settings records."
+ },
+ "application": {
+ "type": "string",
+ "description": "A specific product such as Firefox Desktop or Fenix that supports Nimbus experiments"
+ },
+ "userFacingName": {
+ "type": "string",
+ "description": "Public name of the experiment displayed on \"about:studies\""
+ },
+ "userFacingDescription": {
+ "type": "string",
+ "description": "Short public description of the experiment displayed on on \"about:studies\""
+ },
+ "isEnrollmentPaused": {
+ "type": "boolean",
+ "description": "Should we enroll new users into the experiment?"
+ },
+ "bucketConfig": {
+ "type": "object",
+ "properties": {
+ "randomizationUnit": {
+ "type": "string",
+ "description": "A unique, stable identifier for the user used as an input to bucket hashing"
+ },
+ "namespace": {
+ "type": "string",
+ "description": "Additional inputs to the hashing function"
+ },
+ "start": {
+ "type": "number",
+ "description": "Index of start of the range of buckets"
+ },
+ "count": {
+ "type": "number",
+ "description": "Number of buckets to check"
+ },
+ "total": {
+ "type": "number",
+ "description": "Total number of buckets",
+ "default": 10000
+ }
+ },
+ "required": [
+ "randomizationUnit",
+ "namespace",
+ "start",
+ "count",
+ "total"
+ ],
+ "additionalProperties": false,
+ "description": "Bucketing configuration"
+ },
+ "probeSets": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "A list of probe set slugs relevant to the experiment analysis"
+ },
+ "branches": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "slug": {
+ "type": "string",
+ "description": "Identifier for the branch"
+ },
+ "ratio": {
+ "type": "number",
+ "description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3,\nbranch A would get 25% of the population)",
+ "default": 1
+ },
+ "feature": {
+ "type": "object",
+ "properties": {
+ "featureId": {
+ "type": "string",
+ "description": "The identifier for the feature flag"
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "This can be used to turn the whole feature on/off"
+ },
+ "value": {
+ "anyOf": [
+ {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Optional extra params for the feature (this should be validated against a schema)"
+ }
+ },
+ "required": [
+ "featureId",
+ "enabled",
+ "value"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "slug",
+ "ratio"
+ ],
+ "additionalProperties": false
+ },
+ "description": "Branch configuration for the experiment"
+ },
+ "targeting": {
+ "type": "string",
+ "description": "JEXL expression used to filter experiments based on locale, geo, etc."
+ },
+ "startDate": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Actual publish date of the experiment\nNote that this value is expected to be null in Remote Settings.",
+ "format": "date-time"
+ },
+ "endDate": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Actual end date of the experiment.\nNote that this value is expected to be null in Remote Settings.",
+ "format": "date-time"
+ },
+ "proposedDuration": {
+ "type": "number",
+ "description": "Duration of the experiment from the start date in days.\nNote that this value is expected to be null in Remote Settings.\nin Remote Settings."
+ },
+ "proposedEnrollment": {
+ "type": "number",
+ "description": "Duration of enrollment from the start date in days"
+ },
+ "referenceBranch": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The slug of the reference branch"
+ },
+ "filter_expression": {
+ "type": "string",
+ "description": "This is NOT used by Nimbus, but has special functionality in Remote Settings.\nSee https://remote-settings.readthedocs.io/en/latest/target-filters.html#how"
+ }
+ },
+ "required": [
+ "slug",
+ "id",
+ "application",
+ "userFacingName",
+ "userFacingDescription",
+ "isEnrollmentPaused",
+ "bucketConfig",
+ "probeSets",
+ "branches",
+ "startDate",
+ "endDate",
+ "proposedEnrollment",
+ "referenceBranch"
+ ],
+ "additionalProperties": true,
+ "description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API"
+ }
+ }
+}
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json
new file mode 100644
index 0000000000..f7fe9c9406
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json
@@ -0,0 +1,396 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$ref": "#/definitions/SpecialMessageActionSchemas",
+ "definitions": {
+ "SpecialMessageActionSchemas": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["DISABLE_STP_DOORHANGERS"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Disables all STP doorhangers."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "The element to highlight"
+ }
+ },
+ "required": ["args"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["HIGHLIGHT_FEATURE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Highlights an element, such as a menu item"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "telemetrySource": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": ["telemetrySource", "url"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["INSTALL_ADDON_FROM_URL"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Install an add-on from AMO"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "The about page. E.g. \"welcome\" for about:welcome'"
+ },
+ "where": {
+ "type": "string",
+ "enum": ["current", "save", "tab", "tabshifted", "window"],
+ "description": "Where the URL is opened",
+ "default": "tab"
+ },
+ "entrypoint": {
+ "type": "string",
+ "description": "Any optional entrypoint value that will be added to the search. E.g. \"foo=bar\" would result in about:welcome?foo=bar'"
+ }
+ },
+ "required": ["args", "where", "entrypoint"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_ABOUT_PAGE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens an about: page in Firefox"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "The menu name, e.g. \"appMenu\""
+ }
+ },
+ "required": ["args"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_APPLICATIONS_MENU"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens an application menu"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_AWESOME_BAR"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Focuses and expands the awesome bar"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Section of about:preferences, e.g. \"privacy-reports\""
+ },
+ "entrypoint": {
+ "type": "string",
+ "description": "Add a queryparam for metrics"
+ }
+ },
+ "required": ["category"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PREFERENCES_PAGE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens a preference page"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PRIVATE_BROWSER_WINDOW"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Opens a private browsing window."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PROTECTION_PANEL"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Opens the protections panel"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_PROTECTION_REPORT"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Opens the protections panel report"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "URL to open"
+ },
+ "where": {
+ "type": "string",
+ "enum": ["current", "save", "tab", "tabshifted", "window"],
+ "description": "Where the URL is opened",
+ "default": "tab"
+ }
+ },
+ "required": ["args", "where"],
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["OPEN_URL"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Opens given URL"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["PIN_CURRENT_TAB"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Pin the current tab"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["SHOW_FIREFOX_ACCOUNTS"]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "entrypoint": {
+ "type": "string",
+ "description": "Adds entrypoint={your value} to the FXA URL"
+ },
+ "extraParams": {
+ "type": "object",
+ "description": "Any extra parameter that will be added to the FXA URL. E.g. {foo: bar} would result in <FXA_url>?foo=bar'"
+ }
+ },
+ "required": ["entrypoint"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["type", "data"],
+ "additionalProperties": false,
+ "description": "Show Firefox Accounts"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["SHOW_MIGRATION_WIZARD"]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "description": "Identitifer of the browser that should be pre-selected in the import migration wizard popup (e.g. 'chrome'), See https://searchfox.org/mozilla-central/rev/8dae1cc76a6b45e05198bc0d5d4edb7bf1003265/browser/components/migration/MigrationUtils.jsm#917"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Shows the Migration Wizard to import data from another Browser. See https://support.mozilla.org/en-US/kb/import-data-another-browser\""
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["CANCEL"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Minimize the CFR doorhanger back into the URLbar"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["ACCEPT_DOH"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Accept DOH doorhanger notification"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["DISABLE_DOH"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Dismiss DOH doorhanger notification"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["SET_DEFAULT_BROWSER"]
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false,
+ "description": "Message action to set Firefox as default browser"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "homePage": {
+ "type": "string",
+ "description": "Should reset homepage pref",
+ "enum": ["default"]
+ },
+ "newtab": {
+ "type": "string",
+ "enum": ["default"],
+ "description": "Should reset newtab pref"
+ },
+ "layout": {
+ "type": "object",
+ "description": "Section name and boolean value that specifies if the section should be on or off.",
+ "properties": {
+ "search": {
+ "type": "boolean"
+ },
+ "topsites": {
+ "type": "boolean"
+ },
+ "highlights": {
+ "type": "boolean"
+ },
+ "snippets": {
+ "type": "boolean"
+ },
+ "topstories": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "search",
+ "topsites",
+ "highlights",
+ "snippets",
+ "topstories"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "type": {
+ "type": "string",
+ "enum": ["CONFIGURE_HOMEPAGE"]
+ }
+ },
+ "required": ["data", "type"],
+ "additionalProperties": false,
+ "description": "Resets homepage pref and sections layout"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md
new file mode 100644
index 0000000000..b4af53ba67
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md
@@ -0,0 +1,243 @@
+# User Actions
+
+A subset of actions are available to messages via fields like `button_action` for snippets, or `primary_action` for CFRs.
+
+## Usage
+
+For snippets, you should add the action type in `button_action` and any additional parameters in `button_action_args. For example:
+
+```json
+{
+ "button_action": "OPEN_ABOUT_PAGE",
+ "button_action_args": "config"
+}
+```
+
+## How to update
+
+Make a pull request against [mozilla/nimbus-shared](https://github.com/mozilla/nimbus-shared/) repo with your changes.
+Build and copy over resulting schema from `nimbus-shared/schemas/messaging/` to `toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas.json`.
+
+## Available Actions
+
+### `OPEN_APPLICATIONS_MENU`
+
+* args: (none)
+
+Opens the applications menu.
+
+### `OPEN_PRIVATE_BROWSER_WINDOW`
+
+* args: (none)
+
+Opens a new private browsing window.
+
+
+### `OPEN_URL`
+
+* args: `string` (a url)
+
+Opens a given url.
+
+Example:
+
+```json
+{
+ "button_action": "OPEN_URL",
+ "button_action_args": "https://foo.com"
+}
+```
+
+### `OPEN_ABOUT_PAGE`
+
+* args:
+```ts
+{
+ args: string, // (a valid about page without the `about:` prefix)
+ entrypoint?: string, // URL search param used for referrals
+}
+```
+
+Opens a given about page
+
+Example:
+
+```json
+{
+ "button_action": "OPEN_ABOUT_PAGE",
+ "button_action_args": "config"
+}
+```
+
+### `OPEN_PREFERENCES_PAGE`
+
+* args:
+```
+{
+ args?: string, // (a category accessible via a `#`)
+ entrypoint?: string // URL search param used to referrals
+
+Opens `about:preferences` with an optional category accessible via a `#` in the URL (e.g. `about:preferences#home`).
+
+Example:
+
+```json
+{
+ "button_action": "OPEN_PREFERENCES_PAGE",
+ "button_action_args": "home"
+}
+```
+
+### `SHOW_FIREFOX_ACCOUNTS`
+
+* args: (none)
+
+Opens Firefox accounts sign-up page. Encodes some information that the origin was from snippets by default.
+
+### `SHOW_MIGRATION_WIZARD`
+
+* args: (none)
+
+Opens import wizard to bring in settings and data from another browser.
+
+### `PIN_CURRENT_TAB`
+
+* args: (none)
+
+Pins the currently focused tab.
+
+### `ENABLE_FIREFOX_MONITOR`
+
+* args:
+```ts
+{
+ url: string;
+ flowRequestParams: {
+ entrypoint: string;
+ utm_term: string;
+ form_type: string;
+ }
+}
+```
+
+Opens an oauth flow to enable Firefox Monitor at a given `url` and adds Firefox metrics that user given a set of `flowRequestParams`.
+
+#### `url`
+
+The URL should start with `https://monitor.firefox.com/oauth/init` and add various metrics tags as search params, including:
+
+* `utm_source`
+* `utm_campaign`
+* `form_type`
+* `entrypoint`
+
+You should verify the values of these search params with whoever is doing the data analysis (e.g. Leif Oines).
+
+#### `flowRequestParams`
+
+These params are used by Firefox to add information specific to that individual user to the final oauth URL. You should include:
+
+* `entrypoint`
+* `utm_term`
+* `form_type`
+
+The `entrypoint` and `form_type` values should match the encoded values in your `url`.
+
+You should verify the values with whoever is doing the data analysis (e.g. Leif Oines).
+
+#### Example
+
+```json
+{
+ "button_action": "ENABLE_FIREFOX_MONITOR",
+ "button_action_args": {
+ "url": "https://monitor.firefox.com/oauth/init?utm_source=snippets&utm_campaign=monitor-snippet-test&form_type=email&entrypoint=newtab",
+ "flowRequestParams": {
+ "entrypoint": "snippets",
+ "utm_term": "monitor",
+ "form_type": "email"
+ }
+ }
+}
+```
+
+### `HIGHLIGHT_FEATURE`
+
+Can be used to highlight (show a light blue overlay) a certain button or part of the browser UI.
+
+* args: `string` a [valid targeting defined in the UITour](https://searchfox.org/mozilla-central/rev/7fd1c1c34923ece7ad8c822bee062dd0491d64dc/browser/components/uitour/UITour.jsm#108)
+
+### `INSTALL_ADDON_FROM_URL`
+
+Can be used to install an addon from addons.mozilla.org.
+
+* args:
+```ts
+{
+ url: string,
+ telemetrySource?: string
+};
+```
+
+### `OPEN_PROTECTION_REPORT`
+
+Opens `about:protections`
+
+### `OPEN_PROTECTION_PANEL`
+
+Opens the protection panel behind on the lock icon of the awesomebar
+
+### `DISABLE_STP_DOORHANGERS`
+
+Disables all Social Tracking Protection messages
+
+* args: (none)
+
+### `OPEN_AWESOME_BAR`
+
+Focuses and expands the awesome bar.
+
+* args: (none)
+
+### `CANCEL`
+
+No-op action used to dismiss CFR notifications (but not remove or block them)
+
+* args: (none)
+
+### `DISABLE_DOH`
+
+User action for turning off the DoH feature
+
+* args: (none)
+
+### `ACCEPT_DOH`
+
+User action for continuing to use the DoH feature
+
+* args: (none)
+
+### `CONFIGURE_HOMEPAGE`
+
+Action for configuring the user homepage and restoring defaults.
+
+* args:
+```ts
+{
+ homePage: "default" | null;
+ newtab: "default" | null;
+ layout: {
+ search: boolean;
+ topsites: boolean;
+ highlights: boolean;
+ topstories: boolean;
+ snippets: boolean;
+ }
+}
+```
+
+### `SET_DEFAULT_BROWSER`
+
+Action for configuring the default browser to Firefox on the user's system.
+
+* args: (none)
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini
new file mode 100644
index 0000000000..32bcff6a4d
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+prefs =
+ identity.fxaccounts.remote.root=https://example.com/
+support-files =
+ head.js
+ ../../index.md
+
+[browser_sma_open_about_page.js]
+[browser_sma_open_awesome_bar.js]
+[browser_sma_open_private_browser_window.js]
+[browser_sma_open_protection_panel.js]
+[browser_sma_open_protection_report.js]
+[browser_sma_open_url.js]
+[browser_sma_pin_current_tab.js]
+[browser_sma_show_firefox_accounts.js]
+[browser_sma_show_migration_wizard.js]
+[browser_sma.js]
+[browser_sma_docs.js]
+[browser_sma_accept_doh.js]
+[browser_sma_disable_doh.js]
+[browser_sma_cfrmessageprovider.js]
+[browser_sma_configure_homepage.js]
+[browser_sma_default_browser.js]
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js
new file mode 100644
index 0000000000..b46b3730e9
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_unknown_event() {
+ let error;
+ try {
+ await SpecialMessageActions.handleAction(
+ { type: "UNKNOWN_EVENT_123" },
+ gBrowser
+ );
+ } catch (e) {
+ error = e;
+ }
+ ok(error, "should throw if an unexpected event is handled");
+ Assert.equal(
+ error.message,
+ "Special message action with type UNKNOWN_EVENT_123 is unsupported."
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js
new file mode 100644
index 0000000000..f9255b17ec
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+
+add_task(async function test_disable_doh() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[DOH_DOORHANGER_DECISION_PREF, ""]],
+ });
+ await SMATestUtils.executeAndValidateAction({ type: "ACCEPT_DOH" });
+ Assert.equal(
+ Services.prefs.getStringPref(DOH_DOORHANGER_DECISION_PREF, ""),
+ "UIOk",
+ "Pref should be set on accept"
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js
new file mode 100644
index 0000000000..ca42dac563
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_cancel_event() {
+ let error = null;
+ try {
+ await SMATestUtils.executeAndValidateAction({ type: "CANCEL" });
+ } catch (e) {
+ error = e;
+ }
+ ok(!error, "should not throw for CANCEL");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js
new file mode 100644
index 0000000000..93f3cc851f
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { CFRMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/CFRMessageProvider.jsm"
+);
+
+add_task(async function test_all_test_messages() {
+ let messagesWithButtons = (await CFRMessageProvider.getMessages()).filter(
+ m => m.content.buttons
+ );
+
+ for (let message of messagesWithButtons) {
+ info(`Testing ${message.id}`);
+ if (message.template === "infobar") {
+ for (let button of message.content.buttons) {
+ await SMATestUtils.validateAction(button.action);
+ }
+ } else {
+ let { primary, secondary } = message.content.buttons;
+ await SMATestUtils.validateAction(primary.action);
+ for (let secondaryBtn of secondary) {
+ if (secondaryBtn.action) {
+ await SMATestUtils.validateAction(secondaryBtn.action);
+ }
+ }
+ }
+ }
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js
new file mode 100644
index 0000000000..8bc734cece
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js
@@ -0,0 +1,141 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromiseUtils } = ChromeUtils.import(
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+
+const HOMEPAGE_PREF = "browser.startup.homepage";
+const NEWTAB_PREF = "browser.newtabpage.enabled";
+const HIGHLIGHTS_PREF =
+ "browser.newtabpage.activity-stream.feeds.section.highlights";
+const HIGHLIGHTS_ROWS_PREF =
+ "browser.newtabpage.activity-stream.section.highlights.rows";
+const SEARCH_PREF = "browser.newtabpage.activity-stream.showSearch";
+const TOPSITES_PREF = "browser.newtabpage.activity-stream.feeds.topsites";
+const SNIPPETS_PREF = "browser.newtabpage.activity-stream.feeds.snippets";
+const TOPSTORIES_PREF =
+ "browser.newtabpage.activity-stream.feeds.system.topstories";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ // Highlights are preffed off by default.
+ set: [[HIGHLIGHTS_PREF, true]],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ [
+ HOMEPAGE_PREF,
+ NEWTAB_PREF,
+ HIGHLIGHTS_PREF,
+ HIGHLIGHTS_ROWS_PREF,
+ SEARCH_PREF,
+ TOPSITES_PREF,
+ SNIPPETS_PREF,
+ ].forEach(prefName => Services.prefs.clearUserPref(prefName));
+ });
+});
+
+function waitForHomeNavigation() {
+ let deferred = PromiseUtils.defer();
+ let navigation = {
+ observe(subject) {
+ if (subject === "browser-open-homepage-start") {
+ deferred.resolve();
+ Services.obs.removeObserver(navigation, "browser-open-homepage-start");
+ }
+ },
+ };
+ Services.obs.addObserver(navigation, "browser-open-homepage-start");
+
+ return deferred;
+}
+
+add_task(async function test_CONFIGURE_HOMEPAGE_newtab_home_prefs() {
+ const action = {
+ type: "CONFIGURE_HOMEPAGE",
+ data: { homePage: "default", newtab: "default" },
+ };
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [HOMEPAGE_PREF, "about:blank"],
+ [NEWTAB_PREF, false],
+ ],
+ });
+
+ Assert.ok(Services.prefs.prefHasUserValue(HOMEPAGE_PREF), "Test setup ok");
+ Assert.ok(Services.prefs.prefHasUserValue(NEWTAB_PREF), "Test setup ok");
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(HOMEPAGE_PREF),
+ "Homepage pref should be back to default"
+ );
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(NEWTAB_PREF),
+ "Newtab pref should be back to default"
+ );
+});
+
+add_task(async function test_CONFIGURE_HOMEPAGE_layout_prefs() {
+ const action = {
+ type: "CONFIGURE_HOMEPAGE",
+ data: {
+ layout: {
+ search: true,
+ topsites: false,
+ highlights: false,
+ snippets: false,
+ topstories: false,
+ },
+ },
+ };
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [HIGHLIGHTS_ROWS_PREF, 3],
+ [SEARCH_PREF, false],
+ ],
+ });
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ Assert.ok(Services.prefs.getBoolPref(SEARCH_PREF), "Search is turned on");
+ Assert.ok(
+ !Services.prefs.getBoolPref(TOPSITES_PREF),
+ "Topsites are turned off"
+ );
+ Assert.ok(
+ Services.prefs.getBoolPref(HIGHLIGHTS_PREF),
+ "HIGHLIGHTS_PREF are on because they have been customized"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref(TOPSTORIES_PREF),
+ "Topstories are turned off"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref(SNIPPETS_PREF),
+ "Snippets are turned off"
+ );
+});
+
+add_task(async function test_CONFIGURE_HOMEPAGE_home_redirect() {
+ const action = {
+ type: "CONFIGURE_HOMEPAGE",
+ data: { homePage: "default", newtab: "default" },
+ };
+
+ let browser = gBrowser.selectedBrowser;
+ // Wait for any other navigation events from previous tests
+ await BrowserTestUtils.browserLoaded(browser, false, "about:home");
+ BrowserTestUtils.loadURI(gBrowser.selectedBrowser, "about:config");
+ await BrowserTestUtils.browserLoaded(browser, false, "about:config");
+
+ await SMATestUtils.executeAndValidateAction(action);
+
+ await waitForHomeNavigation();
+ Assert.ok(true, "Redirected to about:home");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js
new file mode 100644
index 0000000000..2d919456cd
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_set_default_browser() {
+ const sandbox = sinon.createSandbox();
+ const stub = sandbox.stub();
+
+ await SMATestUtils.executeAndValidateAction(
+ { type: "SET_DEFAULT_BROWSER" },
+ {
+ ownerGlobal: {
+ getShellService: () => ({
+ setAsDefault: stub,
+ }),
+ },
+ }
+ );
+
+ Assert.equal(stub.callCount, 1, "setAsDefault was called by the action");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js
new file mode 100644
index 0000000000..aa61214360
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+const NETWORK_TRR_MODE_PREF = "network.trr.mode";
+
+add_task(async function test_disable_doh() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [DOH_DOORHANGER_DECISION_PREF, "mochitest"],
+ [NETWORK_TRR_MODE_PREF, 0],
+ ],
+ });
+
+ await SMATestUtils.executeAndValidateAction({ type: "DISABLE_DOH" });
+
+ Assert.equal(
+ Services.prefs.getStringPref(DOH_DOORHANGER_DECISION_PREF, ""),
+ "UIDisabled",
+ "Pref should be set on disabled"
+ );
+ Assert.equal(
+ Services.prefs.getIntPref(NETWORK_TRR_MODE_PREF, 0),
+ 5,
+ "Pref should be set on disabled"
+ );
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js
new file mode 100644
index 0000000000..d67843db74
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js
@@ -0,0 +1,30 @@
+const TEST_URL =
+ "https://example.com/browser/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/index.md";
+
+function getHeadingsFromDocs(docs) {
+ const re = /### `(\w+)`/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re.exec(docs);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+add_task(async function test_sma_docs() {
+ let request = await fetch(TEST_URL);
+ let docs = await request.text();
+ let headings = getHeadingsFromDocs(docs);
+ const schemaTypes = (await fetchSMASchema).anyOf.map(
+ s => s.properties.type.enum[0]
+ );
+ for (let schemaType of schemaTypes) {
+ Assert.ok(
+ headings.includes(schemaType),
+ `${schemaType} not found in SpecialMessageActionSchemas/index.md`
+ );
+ }
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js
new file mode 100644
index 0000000000..264646bd0e
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_ABOUT_PAGE() {
+ const tabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:logins?foo=bar"
+ );
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "logins", entrypoint: "foo=bar", where: "tabshifted" },
+ });
+
+ const tab = await tabPromise;
+ ok(tab, "should open about page with entrypoint in a new tab by default");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_OPEN_ABOUT_PAGE_NEW_WINDOW() {
+ const newWindowPromise = BrowserTestUtils.waitForNewWindow(
+ gBrowser,
+ "about:robots?foo=bar"
+ );
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "robots", entrypoint: "foo=bar", where: "window" },
+ });
+
+ const win = await newWindowPromise;
+ ok(win, "should open about page in a new window");
+ BrowserTestUtils.closeWindow(win);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js
new file mode 100644
index 0000000000..62f7d8bb68
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_AWESOME_BAR() {
+ await SMATestUtils.executeAndValidateAction({ type: "OPEN_AWESOME_BAR" });
+ Assert.ok(gURLBar.focused, "Focus should be on awesome bar");
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js
new file mode 100644
index 0000000000..b6c933fbcf
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_PRIVATE_BROWSER_WINDOW() {
+ const newWindowPromise = BrowserTestUtils.waitForNewWindow();
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_PRIVATE_BROWSER_WINDOW",
+ });
+ const win = await newWindowPromise;
+ ok(
+ PrivateBrowsingUtils.isWindowPrivate(win),
+ "should open a private browsing window"
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js
new file mode 100644
index 0000000000..c9522426a2
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_PROTECTION_PANEL() {
+ await BrowserTestUtils.withNewTab(EXAMPLE_URL, async browser => {
+ const popupshown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ e => e.target.id == "protections-popup"
+ );
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_PROTECTION_PANEL",
+ });
+
+ let { target: popupEl } = await popupshown;
+ Assert.equal(popupEl.state, "open", "Protections popup is open.");
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js
new file mode 100644
index 0000000000..f9d4fa1252
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_PROTECTION_REPORT() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "about:protections"
+ );
+
+ await SMATestUtils.executeAndValidateAction({
+ type: "OPEN_PROTECTION_REPORT",
+ });
+
+ await loaded;
+
+ // When the graph is built it means any messaging has finished,
+ // we can close the tab.
+ await SpecialPowers.spawn(browser, [], async function() {
+ await ContentTaskUtils.waitForCondition(() => {
+ let bars = content.document.querySelectorAll(".graph-bar");
+ return bars.length;
+ }, "The graph has been built");
+ });
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js
new file mode 100644
index 0000000000..876193b7ad
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_OPEN_URL() {
+ const action = {
+ type: "OPEN_URL",
+ data: { args: EXAMPLE_URL, where: "current" },
+ };
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction(action);
+ const url = await loaded;
+ Assert.equal(
+ url,
+ "https://example.com/",
+ "should open URL in the same tab"
+ );
+ });
+});
+
+add_task(async function test_OPEN_URL_new_tab() {
+ const action = {
+ type: "OPEN_URL",
+ data: { args: EXAMPLE_URL, where: "tab" },
+ };
+ const tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, EXAMPLE_URL);
+ await SpecialMessageActions.handleAction(action, gBrowser);
+ const browser = await tabPromise;
+ ok(browser, "should open URL in a new tab");
+ BrowserTestUtils.removeTab(browser);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js
new file mode 100644
index 0000000000..4425325526
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_PIN_CURRENT_TAB() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await SMATestUtils.executeAndValidateAction({ type: "PIN_CURRENT_TAB" });
+
+ ok(gBrowser.selectedTab.pinned, "should pin current tab");
+
+ gBrowser.unpinTab(gBrowser.selectedTab);
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js
new file mode 100644
index 0000000000..3f99fa77ef
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Note: "identity.fxaccounts.remote.root" is set to https://example.com in browser.ini
+add_task(async function test_SHOW_FIREFOX_ACCOUNTS() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "snippets" },
+ });
+ Assert.equal(
+ await loaded,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=snippets&action=email&service=sync",
+ "should load fxa with endpoint=snippets"
+ );
+
+ // Open a URL
+ loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "aboutwelcome" },
+ });
+
+ Assert.equal(
+ await loaded,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=aboutwelcome&action=email&service=sync",
+ "should load fxa with a custom endpoint"
+ );
+
+ // Open a URL with extra parameters
+ loaded = BrowserTestUtils.browserLoaded(browser);
+ await SMATestUtils.executeAndValidateAction({
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "test", extraParams: { foo: "bar" } },
+ });
+
+ Assert.equal(
+ await loaded,
+ "https://example.com/?context=fx_desktop_v3&entrypoint=test&action=email&service=sync&foo=bar",
+ "should load fxa with a custom endpoint and extra parameters in url"
+ );
+ });
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js
new file mode 100644
index 0000000000..e9123c0b36
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MigrationUtils } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+
+add_task(async function test_SHOW_MIGRATION_WIZARD() {
+ let migratorOpen = TestUtils.waitForCondition(() => {
+ let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard");
+ return win && win.document && win.document.readyState == "complete";
+ }, "Migrator window loaded");
+
+ // We can't call this code directly or our JS execution will get blocked on Windows/Linux where
+ // the dialog is modal.
+ executeSoon(() =>
+ SMATestUtils.executeAndValidateAction({ type: "SHOW_MIGRATION_WIZARD" })
+ );
+
+ await migratorOpen;
+ let migratorWindow = Services.wm.getMostRecentWindow(
+ "Browser:MigrationWizard"
+ );
+ ok(migratorWindow, "Migrator window opened");
+ await BrowserTestUtils.closeWindow(migratorWindow);
+});
+
+add_task(async function test_SHOW_MIGRATION_WIZARD_WITH_SOURCE() {
+ let migratorOpen = TestUtils.waitForCondition(() => {
+ let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard");
+ return win && win.document && win.document.readyState == "complete";
+ }, "Migrator window loaded");
+
+ // We can't call this code directly or our JS execution will get blocked on Windows/Linux where
+ // the dialog is modal.
+ executeSoon(() =>
+ SMATestUtils.executeAndValidateAction({
+ type: "SHOW_MIGRATION_WIZARD",
+ data: { source: "chrome" },
+ })
+ );
+
+ await migratorOpen;
+ let migratorWindow = Services.wm.getMostRecentWindow(
+ "Browser:MigrationWizard"
+ );
+ ok(migratorWindow, "Migrator window opened when source param specified");
+ await BrowserTestUtils.closeWindow(migratorWindow);
+});
diff --git a/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js
new file mode 100644
index 0000000000..46a11275f3
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "SpecialMessageActions",
+ "resource://messaging-system/lib/SpecialMessageActions.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Ajv",
+ "resource://testing-common/ajv-4.1.1.js"
+);
+
+XPCOMUtils.defineLazyGetter(this, "fetchSMASchema", async () => {
+ const response = await fetch(
+ "resource://testing-common/SpecialMessageActionSchemas.json"
+ );
+ const schema = await response.json();
+ if (!schema) {
+ throw new Error("Failed to load SpecialMessageActionSchemas");
+ }
+ return schema.definitions.SpecialMessageActionSchemas;
+});
+
+const EXAMPLE_URL = "https://example.com/";
+
+const SMATestUtils = {
+ /**
+ * Checks if an action is valid acording to existing schemas
+ * @param {SpecialMessageAction} action
+ */
+ async validateAction(action) {
+ const schema = await fetchSMASchema;
+ const ajv = new Ajv({ async: "co*" });
+ const validator = ajv.compile(schema);
+ if (!validator(action)) {
+ throw new Error(`Action with type ${action.type} was not valid.`);
+ }
+ ok(!validator.errors, `should be a valid action of type ${action.type}`);
+ },
+
+ /**
+ * Executes a Special Message Action after validating it
+ * @param {SpecialMessageAction} action
+ * @param {Browser} browser
+ */
+ async executeAndValidateAction(action, browser = gBrowser) {
+ await SMATestUtils.validateAction(action);
+ await SpecialMessageActions.handleAction(action, browser);
+ },
+};
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json
new file mode 100644
index 0000000000..682603f82c
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json
@@ -0,0 +1,192 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$ref": "#/definitions/TriggerActionSchemas",
+ "definitions": {
+ "TriggerActionSchemas": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "openURL"
+ ]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of urls we should match against"
+ },
+ "patterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of Match pattern compatible strings to match against"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user loads a new URL that matches the provided `hosts` or `patterns`"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "openArticleURL"
+ ]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of urls we should match against"
+ },
+ "patterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of Match pattern compatible strings to match against"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user loads a document that is Reader Mode compatible"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "openBookmarkedURL"
+ ]
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user adds a bookmark from the URL bar star icon"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "frequentVisits"
+ ]
+ },
+ "params": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of urls we should match against"
+ },
+ "patterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of Match pattern compatible strings to match against"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time a user navigates (or switches tab to) to any of the `hosts` or `patterns` arguments but additionally provides information about the number of accesses to the matched domain."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "newSavedLogin"
+ ]
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time the user adds or updates a login"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": [
+ "contentBlocking"
+ ]
+ },
+ "params": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "params"
+ ],
+ "additionalProperties": false,
+ "description": "Happens every time Firefox blocks the loading of a page script/asset/resource that matches the one of the tracking behaviours specifid through params. See https://searchfox.org/mozilla-central/rev/8ccea36c4fb09412609fb738c722830d7098602b/uriloader/base/nsIWebProgressListener.idl#336"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "enum": ["defaultBrowserCheck"]
+ },
+ "context": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "enum": ["newtab"],
+ "description": "When the source of the trigger is home/newtab"
+ },
+ "willShowDefaultPrompt": {
+ "type": "boolean",
+ "description": "When the source of the trigger is startup"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Happens when starting the browser or navigating to about:home/newtab"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md
new file mode 100644
index 0000000000..11898c4ccd
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md
@@ -0,0 +1,121 @@
+# Trigger Listeners
+
+A set of action listeners that can be used to trigger CFR messages.
+
+## How to update
+
+Make a pull request against [mozilla/nimbus-shared](https://github.com/mozilla/nimbus-shared/) repo with your changes.
+Build and copy over resulting schema from `nimbus-shared/schemas/messaging/` to `toolkit/components/messaging-system/schemas/TriggerActionSchemas.json`.
+
+## Usage
+
+[As part of the CFR definition](https://searchfox.org/mozilla-central/rev/2bfe3415fb3a2fba9b1c694bc0b376365e086927/browser/components/newtab/lib/CFRMessageProvider.jsm#194) the message can register at most one trigger used to decide when the message is shown.
+
+Most triggers (unless otherwise specified) take the same arguments of `hosts` and/or `patterns`
+used to target the message to specific websites.
+
+```javascript
+// Optional set of hosts to filter out triggers only to certain websites
+let params: string[];
+// Optional set of [match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns) to filter out triggers only to certain websites
+let patterns: string[];
+```
+
+```javascript
+{
+ ...
+ // Show the message when opening mozilla.org
+ "trigger": { "id": "openURL", "params": ["mozilla.org", "www.mozilla.org"] }
+ ...
+}
+```
+
+```javascript
+{
+ ...
+ // Show the message when opening any HTTP, HTTPS URL.
+ trigger: { id: "openURL", patterns: ["*://*/*"] }
+ ...
+}
+```
+
+## Available trigger actions
+
+### `openArticleURL`
+
+Happens when the user loads a Reader Mode compatible webpage.
+
+### `openBookmarkedURL`
+
+Happens when the user bookmarks or navigates to a bookmarked URL.
+
+Does not filter by host or patterns.
+
+### `frequentVisits`
+
+Happens every time a user navigates (or switches tab to) to any of the `hosts` or `patterns` arguments
+provided. Additionally it stores timestamps of these visits that are provided back to the targeting context.
+They can be used inside of the targeting expression:
+
+```javascript
+// Has at least 3 visits in the past hour
+recentVisits[.timestamp > (currentDate|date - 3600 * 1000 * 1)]|length >= 3
+
+```
+
+```typescript
+interface visit {
+ host: string,
+ timestamp: UnixTimestamp
+};
+// Host and timestamp for every visit to "Host"
+let recentVisits: visit[];
+```
+
+### `openURL`
+
+Happens every time the user loads a new URL that matches the provided `hosts` or `patterns`.
+During a browsing session it keeps track of visits to unique urls that can be used inside targeting expression.
+
+```javascript
+// True on the third visit for the URL which the trigger matched on
+visitsCount >= 3
+```
+
+### `newSavedLogin`
+
+Happens every time the user saves or updates a login via the login capture doorhanger.
+Provides a `type` to diferentiate between the two events that can be used in targeting.
+
+Does not filter by host or patterns.
+
+```typescript
+let type = "update" | "save";
+```
+
+### `contentBlocking`
+
+Happens at the and of a document load and for every subsequent content blocked event.
+Provides a context of the number of pages loaded in the current browsing session that can be used in targeting.
+
+Does not filter by host or patterns.
+
+The event it reports back is a flag or a combination of flags merged together by
+ANDing the various STATE_BLOCKED_* flags.
+
+```typescript
+// https://searchfox.org/mozilla-central/rev/2fcab997046ba9e068c5391dc7d8848e121d84f8/uriloader/base/nsIWebProgressListener.idl#260
+let event: ContentBlockingEventFlag;
+let pageLoad = number;
+```
+
+### `defaultBrowserCheck`
+
+Happens at startup, when opening a newtab and when navigating to about:home.
+At startup it provides the result of running `DefaultBrowserCheck.willCheckDefaultBrowser` to follow existing behaviour if needed.
+On the newtab/homepage it reports the `source` as `newtab`.
+
+```typescript
+let source = "newtab" | undefined;
+let willShowDefaultPrompt = boolean;
+```
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini
new file mode 100644
index 0000000000..63598cbfb3
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+support-files =
+ ../../index.md
+
+[browser_asrouter_trigger_listeners.js]
+[browser_asrouter_trigger_docs.js]
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js
new file mode 100644
index 0000000000..4146c001c1
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js
@@ -0,0 +1,66 @@
+const TEST_URL =
+ "https://example.com/browser/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/index.md";
+
+const { ASRouterTriggerListeners } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
+);
+const { CFRMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/CFRMessageProvider.jsm"
+);
+const { Ajv } = ChromeUtils.import("resource://testing-common/ajv-4.1.1.js");
+
+XPCOMUtils.defineLazyGetter(this, "fetchTriggerActionSchema", async () => {
+ const response = await fetch(
+ "resource://testing-common/TriggerActionSchemas.json"
+ );
+ const schema = await response.json();
+ if (!schema) {
+ throw new Error("Failed to load TriggerActionSchemas");
+ }
+ return schema.definitions.TriggerActionSchemas;
+});
+
+async function validateTrigger(trigger) {
+ const schema = await fetchTriggerActionSchema;
+ const ajv = new Ajv({ async: "co*" });
+ const validator = ajv.compile(schema);
+ if (!validator(trigger)) {
+ throw new Error(`Trigger with id ${trigger.id} was not valid.`);
+ }
+ Assert.ok(
+ !validator.errors,
+ `should be a valid trigger of type ${trigger.id}`
+ );
+}
+
+function getHeadingsFromDocs(docs) {
+ const re = /### `(\w+)`/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re.exec(docs);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+add_task(async function test_trigger_docs() {
+ let request = await fetch(TEST_URL, { credentials: "omit" });
+ let docs = await request.text();
+ let headings = getHeadingsFromDocs(docs);
+ for (let triggerName of ASRouterTriggerListeners.keys()) {
+ Assert.ok(
+ headings.includes(triggerName),
+ `${triggerName} not found in TriggerActionSchemas/index.md`
+ );
+ }
+});
+
+add_task(async function test_message_triggers() {
+ const messages = await CFRMessageProvider.getMessages();
+ for (let message of messages) {
+ await validateTrigger(message.trigger);
+ }
+});
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js
new file mode 100644
index 0000000000..b38f14e449
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js
@@ -0,0 +1,507 @@
+ChromeUtils.defineModuleGetter(
+ this,
+ "ASRouterTriggerListeners",
+ "resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "TestUtils",
+ "resource://testing-common/TestUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+
+async function openURLInWindow(window, url) {
+ const { selectedBrowser } = window.gBrowser;
+ BrowserTestUtils.loadURI(selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(selectedBrowser, false, url);
+}
+
+add_task(async function check_matchPatternFailureCase() {
+ const articleTrigger = ASRouterTriggerListeners.get("openArticleURL");
+
+ articleTrigger.uninit();
+
+ articleTrigger.init(() => {}, [], ["example.com"]);
+
+ is(
+ articleTrigger._matchPatternSet.matches("http://example.com"),
+ false,
+ "Should fail, bad pattern"
+ );
+
+ articleTrigger.init(() => {}, [], ["*://*.example.com/"]);
+
+ is(
+ articleTrigger._matchPatternSet.matches("http://www.example.com"),
+ true,
+ "Should work, updated pattern"
+ );
+
+ articleTrigger.uninit();
+});
+
+add_task(async function check_openArticleURL() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+ const articleTrigger = ASRouterTriggerListeners.get("openArticleURL");
+
+ // Previously initialized by the Router
+ articleTrigger.uninit();
+
+ // Initialize the trigger with a new triggerHandler that resolves a promise
+ // with the URL match
+ const listenerTriggered = new Promise(resolve =>
+ articleTrigger.init((browser, match) => resolve(match), ["example.com"])
+ );
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, TEST_URL);
+ // Send a message from the content page (the TEST_URL) to the parent
+ // This should trigger the `receiveMessage` cb in the articleTrigger
+ await ContentTask.spawn(win.gBrowser.selectedBrowser, null, async () => {
+ let readerActor = content.windowGlobalChild.getActor("AboutReader");
+ readerActor.sendAsyncMessage("Reader:UpdateReaderButton", {
+ isArticle: true,
+ });
+ });
+
+ await listenerTriggered.then(data =>
+ is(
+ data.param.url,
+ TEST_URL,
+ "We should match on the TEST_URL as a website article"
+ )
+ );
+
+ // Cleanup
+ articleTrigger.uninit();
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function check_openURL_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let urlVisitCount = 0;
+ const triggerHandler = () => urlVisitCount++;
+ const openURLListener = ASRouterTriggerListeners.get("openURL");
+
+ // Previously initialized by the Router
+ openURLListener.uninit();
+
+ const normalWindow = await BrowserTestUtils.openNewBrowserWindow();
+ const privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // Initialise listener
+ openURLListener.init(triggerHandler, ["example.com"]);
+
+ await openURLInWindow(normalWindow, TEST_URL);
+ await BrowserTestUtils.waitForCondition(
+ () => urlVisitCount !== 0,
+ "Wait for the location change listener to run"
+ );
+ is(urlVisitCount, 1, "should receive page visits from existing windows");
+
+ await openURLInWindow(normalWindow, "http://www.example.com/abc");
+ is(urlVisitCount, 1, "should not receive page visits for different domains");
+
+ await openURLInWindow(privateWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 1,
+ "should not receive page visits from existing private windows"
+ );
+
+ const secondNormalWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(secondNormalWindow, TEST_URL);
+ await BrowserTestUtils.waitForCondition(
+ () => urlVisitCount === 2,
+ "Wait for the location change listener to run"
+ );
+ is(urlVisitCount, 2, "should receive page visits from newly opened windows");
+
+ const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await openURLInWindow(secondPrivateWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 2,
+ "should not receive page visits from newly opened private windows"
+ );
+
+ // Uninitialise listener
+ openURLListener.uninit();
+
+ await openURLInWindow(normalWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 2,
+ "should now not receive page visits from existing windows"
+ );
+
+ const thirdNormalWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(thirdNormalWindow, TEST_URL);
+ is(
+ urlVisitCount,
+ 2,
+ "should now not receive page visits from newly opened windows"
+ );
+
+ // Cleanup
+ const windows = [
+ normalWindow,
+ privateWindow,
+ secondNormalWindow,
+ secondPrivateWindow,
+ thirdNormalWindow,
+ ];
+ await Promise.all(windows.map(win => BrowserTestUtils.closeWindow(win)));
+});
+
+add_task(async function check_newSavedLogin_save_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let triggerTypesHandled = {
+ save: 0,
+ update: 0,
+ };
+ const triggerHandler = (sub, { id, context }) => {
+ is(id, "newSavedLogin", "Check trigger id");
+ triggerTypesHandled[context.type]++;
+ };
+ const newSavedLoginListener = ASRouterTriggerListeners.get("newSavedLogin");
+
+ // Previously initialized by the Router
+ newSavedLoginListener.uninit();
+
+ // Initialise listener
+ await newSavedLoginListener.init(triggerHandler);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerNewSavedPassword(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
+ await BrowserTestUtils.waitForCondition(
+ () => triggerTypesHandled.save !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(triggerTypesHandled.save, 1, "should receive observer notification");
+ }
+ );
+
+ is(triggerTypesHandled.update, 0, "shouldn't have handled other trigger");
+
+ // Uninitialise listener
+ newSavedLoginListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerNewSavedPasswordAfterUninit(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
+ await new Promise(resolve => executeSoon(resolve));
+ is(
+ triggerTypesHandled.save,
+ 1,
+ "shouldn't receive obs. notification after uninit"
+ );
+ }
+ );
+});
+
+add_task(async function check_newSavedLogin_update_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let triggerTypesHandled = {
+ save: 0,
+ update: 0,
+ };
+ const triggerHandler = (sub, { id, context }) => {
+ is(id, "newSavedLogin", "Check trigger id");
+ triggerTypesHandled[context.type]++;
+ };
+ const newSavedLoginListener = ASRouterTriggerListeners.get("newSavedLogin");
+
+ // Previously initialized by the Router
+ newSavedLoginListener.uninit();
+
+ // Initialise listener
+ await newSavedLoginListener.init(triggerHandler);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerLoginUpdateSaved(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved");
+ await BrowserTestUtils.waitForCondition(
+ () => triggerTypesHandled.update !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(triggerTypesHandled.update, 1, "should receive observer notification");
+ }
+ );
+
+ is(triggerTypesHandled.save, 0, "shouldn't have handled other trigger");
+
+ // Uninitialise listener
+ newSavedLoginListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggerLoginUpdateSavedAfterUninit(browser) {
+ Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved");
+ await new Promise(resolve => executeSoon(resolve));
+ is(
+ triggerTypesHandled.update,
+ 1,
+ "shouldn't receive obs. notification after uninit"
+ );
+ }
+ );
+});
+
+add_task(async function check_contentBlocking_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ const event1 = 0x0001;
+ const event2 = 0x0010;
+ const event3 = 0x0100;
+ const event4 = 0x1000;
+
+ // Initialise listener to listen 2 events, for any incoming event e,
+ // it will be triggered if and only if:
+ // 1. (e & event1) && (e & event2)
+ // 2. (e & event3)
+ const bindEvents = [event1 | event2, event3];
+
+ let observerEvent = 0;
+ let pageLoadSum = 0;
+ const triggerHandler = (target, trigger) => {
+ const {
+ id,
+ param: { host, type },
+ context: { pageLoad },
+ } = trigger;
+ is(id, "contentBlocking", "should match event name");
+ is(host, TEST_URL, "should match test URL");
+ is(
+ bindEvents.filter(e => (type & e) === e).length,
+ 1,
+ `event ${type} is valid`
+ );
+ ok(pageLoadSum <= pageLoad, "pageLoad is non-decreasing");
+
+ observerEvent += 1;
+ pageLoadSum = pageLoad;
+ };
+ const contentBlockingListener = ASRouterTriggerListeners.get(
+ "contentBlocking"
+ );
+
+ // Previously initialized by the Router
+ contentBlockingListener.uninit();
+
+ await contentBlockingListener.init(triggerHandler, bindEvents);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event1, // won't trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+ }
+ );
+
+ is(observerEvent, 0, "shouldn't receive unrelated observer notification");
+ is(pageLoadSum, 0, "shouldn't receive unrelated observer notification");
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event3, // will trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => observerEvent !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(observerEvent, 1, "should receive observer notification");
+ is(pageLoadSum, 2, "should receive observer notification");
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event1 | event2 | event4, // still trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => observerEvent !== 1,
+ "Wait for the observer notification to run"
+ );
+ is(observerEvent, 2, "should receive another observer notification");
+ is(pageLoadSum, 2, "should receive another observer notification");
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event1, // no trigger
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+
+ await new Promise(resolve => executeSoon(resolve));
+ is(observerEvent, 2, "shouldn't receive unrelated notification");
+ is(pageLoadSum, 2, "shouldn't receive unrelated notification");
+ }
+ );
+
+ // Uninitialise listener
+ contentBlockingListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlockingAfterUninit(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ host: TEST_URL,
+ event: event3, // wont trigger after uninit
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+ await new Promise(resolve => executeSoon(resolve));
+ is(observerEvent, 2, "shouldn't receive obs. notification after uninit");
+ is(pageLoadSum, 2, "shouldn't receive obs. notification after uninit");
+ }
+ );
+});
+
+add_task(async function check_contentBlockingMilestone_listener() {
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
+
+ let observerEvent = 0;
+ const triggerHandler = (target, trigger) => {
+ const {
+ id,
+ param: { type },
+ } = trigger;
+ is(id, "contentBlocking", "should match event name");
+ is(type, "ContentBlockingMilestone", "Should be the correct event type");
+ observerEvent += 1;
+ };
+ const contentBlockingListener = ASRouterTriggerListeners.get(
+ "contentBlocking"
+ );
+
+ // Previously initialized by the Router
+ contentBlockingListener.uninit();
+
+ // Initialise listener
+ contentBlockingListener.init(triggerHandler, ["ContentBlockingMilestone"]);
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ event: "Other Event",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+ }
+ );
+
+ is(observerEvent, 0, "shouldn't receive unrelated observer notification");
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlocking(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ event: "ContentBlockingMilestone",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => observerEvent !== 0,
+ "Wait for the observer notification to run"
+ );
+ is(observerEvent, 1, "should receive observer notification");
+ }
+ );
+
+ // Uninitialise listener
+ contentBlockingListener.uninit();
+
+ await BrowserTestUtils.withNewTab(
+ TEST_URL,
+ async function triggercontentBlockingAfterUninit(browser) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser,
+ event: "ContentBlockingMilestone",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+ await new Promise(resolve => executeSoon(resolve));
+ is(observerEvent, 1, "shouldn't receive obs. notification after uninit");
+ }
+ );
+});
+
+add_task(function test_pattern_match() {
+ const openURLListener = ASRouterTriggerListeners.get("openURL");
+ openURLListener.uninit();
+ openURLListener.init(() => {}, [], ["*://*/*.pdf"]);
+ let pattern = openURLListener._matchPatternSet;
+
+ Assert.ok(pattern.matches("https://example.com/foo.pdf"), "match 1");
+ Assert.ok(pattern.matches("https://example.com/bar/foo.pdf"), "match 2");
+ Assert.ok(pattern.matches("https://www.example.com/foo.pdf"), "match 3");
+ // Shouldn't match. Too generic.
+ Assert.ok(!pattern.matches("https://www.example.com/foo"), "match 4");
+ Assert.ok(!pattern.matches("https://www.example.com/pdf"), "match 5");
+});
diff --git a/toolkit/components/messaging-system/schemas/index.rst b/toolkit/components/messaging-system/schemas/index.rst
new file mode 100644
index 0000000000..952e9756d0
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/index.rst
@@ -0,0 +1,18 @@
+Messaging System Schemas
+========================
+
+Docs
+----
+
+More information about `Messaging System`__.
+
+.. __: /browser/components/newtab/content-src/asrouter/docs
+
+Triggers and actions
+---------------------
+
+.. toctree::
+ :maxdepth: 2
+
+ SpecialMessageActionSchemas/index
+ TriggerActionSchemas/index
diff --git a/toolkit/components/messaging-system/targeting/Targeting.jsm b/toolkit/components/messaging-system/targeting/Targeting.jsm
new file mode 100644
index 0000000000..27b2619102
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/Targeting.jsm
@@ -0,0 +1,216 @@
+/* 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";
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Services: "resource://gre/modules/Services.jsm",
+ clearTimeout: "resource://gre/modules/Timer.jsm",
+ setTimeout: "resource://gre/modules/Timer.jsm",
+ ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
+ FilterExpressions:
+ "resource://gre/modules/components-utils/FilterExpressions.jsm",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["TargetingContext"];
+
+const TARGETING_EVENT_CATEGORY = "messaging_experiments";
+const TARGETING_EVENT_METHOD = "targeting";
+const DEFAULT_TIMEOUT = 3000;
+const ERROR_TYPES = {
+ ATTRIBUTE_ERROR: "attribute_error",
+ TIMEOUT: "attribute_timeout",
+};
+
+const TargetingEnvironment = {
+ get locale() {
+ return ASRouterTargeting.Environment.locale;
+ },
+
+ get localeLanguageCode() {
+ return ASRouterTargeting.Environment.localeLanguageCode;
+ },
+
+ get region() {
+ return ASRouterTargeting.Environment.region;
+ },
+
+ get userId() {
+ return ClientEnvironment.userId;
+ },
+
+ get version() {
+ return AppConstants.MOZ_APP_VERSION_DISPLAY;
+ },
+
+ get channel() {
+ return AppConstants.MOZ_UPDATE_CHANNEL;
+ },
+
+ get platform() {
+ return AppConstants.platform;
+ },
+};
+
+class TargetingContext {
+ constructor(customContext) {
+ if (customContext) {
+ this.ctx = new Proxy(customContext, {
+ get: (customCtx, prop) => {
+ if (prop in TargetingEnvironment) {
+ return TargetingEnvironment[prop];
+ }
+ return customCtx[prop];
+ },
+ });
+ } else {
+ this.ctx = TargetingEnvironment;
+ }
+
+ // Enable event recording
+ Services.telemetry.setEventRecordingEnabled(TARGETING_EVENT_CATEGORY, true);
+ }
+
+ _sendUndesiredEvent(eventData) {
+ Services.telemetry.recordEvent(
+ TARGETING_EVENT_CATEGORY,
+ TARGETING_EVENT_METHOD,
+ eventData.event,
+ eventData.value
+ );
+ }
+
+ /**
+ * Wrap each property of context[key] with a Proxy that captures errors and
+ * timeouts
+ *
+ * @param {Object.<string, TargetingGetters> | TargetingGetters} context
+ * @param {string} key Namespace value found in `context` param
+ * @returns {TargetingGetters} Wrapped context where getter report errors and timeouts
+ */
+ createContextWithTimeout(context, key = null) {
+ const timeoutDuration = key ? context[key].timeout : context.timeout;
+ const logUndesiredEvent = (event, key, prop) => {
+ const value = key ? `${key}.${prop}` : prop;
+ this._sendUndesiredEvent({ event, value });
+ Cu.reportError(`${event}: ${value}`);
+ };
+
+ return new Proxy(context, {
+ get(target, prop) {
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async (resolve, reject) => {
+ // Create timeout cb to record attribute resolution taking too long.
+ let timeout = setTimeout(() => {
+ logUndesiredEvent(ERROR_TYPES.TIMEOUT, key, prop);
+ reject(
+ new Error(
+ `${prop} targeting getter timed out after ${timeoutDuration ||
+ DEFAULT_TIMEOUT}ms`
+ )
+ );
+ }, timeoutDuration || DEFAULT_TIMEOUT);
+
+ try {
+ resolve(await (key ? target[key][prop] : target[prop]));
+ } catch (error) {
+ logUndesiredEvent(ERROR_TYPES.ATTRIBUTE_ERROR, key, prop);
+ reject(error);
+ Cu.reportError(error);
+ } finally {
+ clearTimeout(timeout);
+ }
+ });
+ },
+ });
+ }
+
+ /**
+ * Merge all evaluation contexts and wrap the getters with timeouts
+ *
+ * @param {Object.<string, TargetingGetters>[]} contexts
+ * @returns {Object.<string, TargetingGetters>} Object that follows the pattern of `namespace: getters`
+ */
+ mergeEvaluationContexts(contexts) {
+ let context = {};
+ for (let c of contexts) {
+ for (let envNamespace of Object.keys(c)) {
+ // Take the provided context apart, replace it with a proxy
+ context[envNamespace] = this.createContextWithTimeout(c, envNamespace);
+ }
+ }
+
+ return context;
+ }
+
+ /**
+ * Merge multiple TargetingGetters objects without accidentally evaluating
+ *
+ * @param {TargetingGetters[]} ...contexts
+ * @returns {Proxy<TargetingGetters>}
+ */
+ static combineContexts(...contexts) {
+ return new Proxy(
+ {},
+ {
+ get(target, prop) {
+ for (let context of contexts) {
+ if (prop in context) {
+ return context[prop];
+ }
+ }
+
+ return null;
+ },
+ }
+ );
+ }
+
+ /**
+ * Evaluate JEXL expressions with default `TargetingEnvironment` and custom
+ * provided targeting contexts
+ *
+ * @example
+ * eval(
+ * "ctx.locale == 'en-US' && customCtx.foo == 42",
+ * { customCtx: { foo: 42 } }
+ * ); // true
+ *
+ * @param {string} expression JEXL expression
+ * @param {Object.<string, TargetingGetters>[]} ...contexts Additional custom context
+ * objects where the keys act as namespaces for the different getters
+ *
+ * @returns {promise} Evaluation result
+ */
+ eval(expression, ...contexts) {
+ return FilterExpressions.eval(
+ expression,
+ this.mergeEvaluationContexts([{ ctx: this.ctx }, ...contexts])
+ );
+ }
+
+ /**
+ * Evaluate JEXL expressions with default provided targeting context
+ *
+ * @example
+ * new TargetingContext({ bar: 42 });
+ * evalWithDefault(
+ * "bar == 42",
+ * ); // true
+ *
+ * @param {string} expression JEXL expression
+ * @returns {promise} Evaluation result
+ */
+ evalWithDefault(expression) {
+ return FilterExpressions.eval(
+ expression,
+ this.createContextWithTimeout(this.ctx)
+ );
+ }
+}
diff --git a/toolkit/components/messaging-system/targeting/test/unit/head.js b/toolkit/components/messaging-system/targeting/test/unit/head.js
new file mode 100644
index 0000000000..085386dc49
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/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/targeting/test/unit/test_targeting.js b/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js
new file mode 100644
index 0000000000..c86833a6fb
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js
@@ -0,0 +1,245 @@
+const { ClientEnvironment } = ChromeUtils.import(
+ "resource://normandy/lib/ClientEnvironment.jsm"
+);
+const { TargetingContext } = ChromeUtils.import(
+ "resource://messaging-system/targeting/Targeting.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.import(
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+
+add_task(async function instance_with_default() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ `ctx.locale == '${Services.locale.appLocaleAsBCP47}'`
+ );
+
+ Assert.ok(res, "Has local context");
+});
+
+add_task(async function instance_with_context() {
+ let targeting = new TargetingContext({ bar: 42 });
+
+ let res = await targeting.eval("ctx.bar == 42");
+
+ Assert.ok(res, "Merge provided context with default");
+});
+
+add_task(async function eval_1_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval("custom1.bar == 42", { custom1: { bar: 42 } });
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_2_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval("custom1.bar == 42 && custom2.foo == 42", {
+ custom1: { bar: 42 },
+ custom2: { foo: 42 },
+ });
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_multiple_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ "custom1.bar == 42 && custom2.foo == 42 && custom3.baz == 42",
+ { custom1: { bar: 42 }, custom2: { foo: 42 } },
+ { custom3: { baz: 42 } }
+ );
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_multiple_context_precedence() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ "custom1.bar == 42 && custom2.foo == 42",
+ { custom1: { bar: 24 }, custom2: { foo: 24 } },
+ { custom1: { bar: 42 }, custom2: { foo: 42 } }
+ );
+
+ Assert.ok(res, "Last provided context overrides previously defined ones.");
+});
+
+add_task(async function eval_evalWithDefault() {
+ let targeting = new TargetingContext({ foo: 42 });
+
+ let res = await targeting.evalWithDefault("foo == 42");
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function log_targeting_error_events() {
+ let ctx = {
+ get foo() {
+ throw new Error("unit test");
+ },
+ };
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+
+ await Assert.rejects(
+ targeting.evalWithDefault("foo == 42", ctx),
+ /unit test/,
+ "Getter should throw"
+ );
+
+ Assert.equal(stub.callCount, 1, "Error event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_error", "Correct error message");
+ Assert.equal(value, "foo", "Correct attribute name");
+});
+
+add_task(async function eval_evalWithDefault_precedence() {
+ let targeting = new TargetingContext({ region: "space" });
+ let res = await targeting.evalWithDefault("region != 'space'");
+
+ Assert.ok(res, "Custom context does not override TargetingEnvironment");
+});
+
+add_task(async function eval_evalWithDefault_combineContexts() {
+ let combinedCtxs = TargetingContext.combineContexts({ foo: 1 }, { foo: 2 });
+ let targeting = new TargetingContext(combinedCtxs);
+ let res = await targeting.evalWithDefault("foo == 1");
+
+ Assert.ok(res, "First match is returned for combineContexts");
+});
+
+add_task(async function log_targeting_error_events_in_namespace() {
+ let ctx = {
+ get foo() {
+ throw new Error("unit test");
+ },
+ };
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+ let catchStub = sinon.stub();
+
+ try {
+ await targeting.eval("ctx.foo == 42");
+ } catch (e) {
+ catchStub();
+ }
+
+ Assert.equal(stub.callCount, 1, "Error event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_error", "Correct error message");
+ Assert.equal(value, "ctx.foo", "Correct attribute name");
+ Assert.ok(catchStub.calledOnce, "eval throws errors");
+});
+
+add_task(async function log_timeout_errors() {
+ let ctx = {
+ timeout: 1,
+ get foo() {
+ return new Promise(() => {});
+ },
+ };
+
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+ let catchStub = sinon.stub();
+
+ try {
+ await targeting.eval("ctx.foo");
+ } catch (e) {
+ catchStub();
+ }
+
+ Assert.equal(catchStub.callCount, 1, "Timeout error throws");
+ Assert.equal(stub.callCount, 1, "Timeout event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_timeout", "Correct error message");
+ Assert.equal(value, "ctx.foo", "Correct attribute name");
+});
+
+add_task(async function test_telemetry_event_timeout() {
+ Services.telemetry.clearEvents();
+ let ctx = {
+ timeout: 1,
+ get foo() {
+ return new Promise(() => {});
+ },
+ };
+ let expectedEvents = [
+ ["messaging_experiments", "targeting", "attribute_timeout", "ctx.foo"],
+ ];
+ let targeting = new TargetingContext(ctx);
+
+ try {
+ await targeting.eval("ctx.foo");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+add_task(async function test_telemetry_event_error() {
+ Services.telemetry.clearEvents();
+ let ctx = {
+ get bar() {
+ throw new Error("unit test");
+ },
+ };
+ let expectedEvents = [
+ ["messaging_experiments", "targeting", "attribute_error", "ctx.bar"],
+ ];
+ let targeting = new TargetingContext(ctx);
+
+ try {
+ await targeting.eval("ctx.bar");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+// Make sure that when using the Normandy-style ClientEnvironment context,
+// `liveTelemetry` works. `liveTelemetry` is a particularly tricky object to
+// proxy, so it's useful to check specifically.
+add_task(async function test_live_telemetry() {
+ let ctx = { env: ClientEnvironment };
+ let targeting = new TargetingContext();
+ // This shouldn't throw.
+ await targeting.eval("env.liveTelemetry.main", ctx);
+});
+
+add_task(async function test_default_targeting() {
+ const targeting = new TargetingContext();
+ const expected_attributes = [
+ "locale",
+ "localeLanguageCode",
+ // "region", // Not available in test, requires network access to determine
+ "userId",
+ "version",
+ "channel",
+ "platform",
+ ];
+
+ for (let attribute of expected_attributes) {
+ let res = await targeting.eval(`ctx.${attribute}`);
+ Assert.ok(res, `[eval] result for ${attribute} should not be null`);
+ }
+
+ for (let attribute of expected_attributes) {
+ let res = await targeting.evalWithDefault(attribute);
+ Assert.ok(
+ res,
+ `[evalWithDefault] result for ${attribute} should not be null`
+ );
+ }
+});
diff --git a/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..3653c7f549
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head.js
+tags = messaging-system
+firefox-appdir = browser
+
+[test_targeting.js]
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]