From 2aa4a82499d4becd2284cdb482213d541b8804dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 16:29:10 +0200 Subject: Adding upstream version 86.0.1. Signed-off-by: Daniel Baumann --- .../experiments/@types/ExperimentManager.d.ts | 49 ++ .../messaging-system/experiments/ExperimentAPI.jsm | 266 +++++++++++ .../experiments/ExperimentManager.jsm | 476 +++++++++++++++++++ .../experiments/ExperimentStore.jsm | 196 ++++++++ toolkit/components/messaging-system/jar.mn | 9 + toolkit/components/messaging-system/lib/Logger.jsm | 22 + .../lib/RemoteSettingsExperimentLoader.jsm | 229 ++++++++++ .../messaging-system/lib/SharedDataMap.jsm | 163 +++++++ .../messaging-system/lib/SpecialMessageActions.jsm | 301 ++++++++++++ toolkit/components/messaging-system/moz.build | 28 ++ .../schemas/NimbusExperiment.schema.json | 187 ++++++++ .../SpecialMessageActionSchemas.json | 396 ++++++++++++++++ .../schemas/SpecialMessageActionSchemas/index.md | 243 ++++++++++ .../test/browser/browser.ini | 23 + .../test/browser/browser_sma.js | 21 + .../test/browser/browser_sma_accept_doh.js | 17 + .../test/browser/browser_sma_cancel.js | 14 + .../test/browser/browser_sma_cfrmessageprovider.js | 31 ++ .../test/browser/browser_sma_configure_homepage.js | 141 ++++++ .../test/browser/browser_sma_default_browser.js | 22 + .../test/browser/browser_sma_disable_doh.js | 28 ++ .../test/browser/browser_sma_docs.js | 30 ++ .../test/browser/browser_sma_open_about_page.js | 34 ++ .../test/browser/browser_sma_open_awesome_bar.js | 9 + .../browser_sma_open_private_browser_window.js | 17 + .../browser/browser_sma_open_protection_panel.js | 22 + .../browser/browser_sma_open_protection_report.js | 29 ++ .../test/browser/browser_sma_open_url.js | 33 ++ .../test/browser/browser_sma_pin_current_tab.js | 14 + .../browser/browser_sma_show_firefox_accounts.js | 46 ++ .../browser/browser_sma_show_migration_wizard.js | 51 +++ .../test/browser/head.js | 60 +++ .../TriggerActionSchemas/TriggerActionSchemas.json | 192 ++++++++ .../schemas/TriggerActionSchemas/index.md | 121 +++++ .../TriggerActionSchemas/test/browser/browser.ini | 6 + .../test/browser/browser_asrouter_trigger_docs.js | 66 +++ .../browser/browser_asrouter_trigger_listeners.js | 507 +++++++++++++++++++++ .../components/messaging-system/schemas/index.rst | 18 + .../messaging-system/targeting/Targeting.jsm | 216 +++++++++ .../messaging-system/targeting/test/unit/head.js | 5 + .../targeting/test/unit/test_targeting.js | 245 ++++++++++ .../targeting/test/unit/xpcshell.ini | 6 + .../messaging-system/test/MSTestUtils.jsm | 145 ++++++ .../messaging-system/test/browser/browser.ini | 5 + .../test/browser/browser_experimentstore_load.js | 40 ++ .../browser_remotesettings_experiment_enroll.js | 111 +++++ .../components/messaging-system/test/unit/head.js | 5 + .../reference_aboutwelcome_experiment_content.json | 190 ++++++++ .../test/unit/test_ExperimentAPI.js | 374 +++++++++++++++ .../test/unit/test_ExperimentManager_context.js | 33 ++ .../test/unit/test_ExperimentManager_enroll.js | 233 ++++++++++ .../unit/test_ExperimentManager_generateTestIds.js | 111 +++++ .../test/unit/test_ExperimentManager_lifecycle.js | 254 +++++++++++ .../test/unit/test_ExperimentManager_unenroll.js | 143 ++++++ .../test/unit/test_ExperimentStore.js | 449 ++++++++++++++++++ .../messaging-system/test/unit/test_MSTestUtils.js | 13 + .../unit/test_RemoteSettingsExperimentLoader.js | 209 +++++++++ ...RemoteSettingsExperimentLoader_updateRecipes.js | 55 +++ .../test/unit/test_SharedDataMap.js | 183 ++++++++ .../messaging-system/test/unit/xpcshell.ini | 18 + 60 files changed, 7160 insertions(+) create mode 100644 toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts create mode 100644 toolkit/components/messaging-system/experiments/ExperimentAPI.jsm create mode 100644 toolkit/components/messaging-system/experiments/ExperimentManager.jsm create mode 100644 toolkit/components/messaging-system/experiments/ExperimentStore.jsm create mode 100644 toolkit/components/messaging-system/jar.mn create mode 100644 toolkit/components/messaging-system/lib/Logger.jsm create mode 100644 toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm create mode 100644 toolkit/components/messaging-system/lib/SharedDataMap.jsm create mode 100644 toolkit/components/messaging-system/lib/SpecialMessageActions.jsm create mode 100644 toolkit/components/messaging-system/moz.build create mode 100644 toolkit/components/messaging-system/schemas/NimbusExperiment.schema.json create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/index.md create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser.ini create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_accept_doh.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cancel.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_cfrmessageprovider.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_configure_homepage.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_default_browser.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_disable_doh.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_docs.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_about_page.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_awesome_bar.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_private_browser_window.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_panel.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_protection_report.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_open_url.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_pin_current_tab.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_firefox_accounts.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/browser_sma_show_migration_wizard.js create mode 100644 toolkit/components/messaging-system/schemas/SpecialMessageActionSchemas/test/browser/head.js create mode 100644 toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json create mode 100644 toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md create mode 100644 toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.ini create mode 100644 toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js create mode 100644 toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js create mode 100644 toolkit/components/messaging-system/schemas/index.rst create mode 100644 toolkit/components/messaging-system/targeting/Targeting.jsm create mode 100644 toolkit/components/messaging-system/targeting/test/unit/head.js create mode 100644 toolkit/components/messaging-system/targeting/test/unit/test_targeting.js create mode 100644 toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini create mode 100644 toolkit/components/messaging-system/test/MSTestUtils.jsm create mode 100644 toolkit/components/messaging-system/test/browser/browser.ini create mode 100644 toolkit/components/messaging-system/test/browser/browser_experimentstore_load.js create mode 100644 toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js create mode 100644 toolkit/components/messaging-system/test/unit/head.js create mode 100644 toolkit/components/messaging-system/test/unit/reference_aboutwelcome_experiment_content.json create mode 100644 toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js create mode 100644 toolkit/components/messaging-system/test/unit/test_ExperimentManager_context.js create mode 100644 toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js create mode 100644 toolkit/components/messaging-system/test/unit/test_ExperimentManager_generateTestIds.js create mode 100644 toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js create mode 100644 toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js create mode 100644 toolkit/components/messaging-system/test/unit/test_ExperimentStore.js create mode 100644 toolkit/components/messaging-system/test/unit/test_MSTestUtils.js create mode 100644 toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js create mode 100644 toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js create mode 100644 toolkit/components/messaging-system/test/unit/test_SharedDataMap.js create mode 100644 toolkit/components/messaging-system/test/unit/xpcshell.ini (limited to 'toolkit/components/messaging-system') 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} + */ + 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} 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} + * @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} 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} 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 ?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. | 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.[]} contexts + * @returns {Object.} 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} + */ + 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.[]} ...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": "" + }, + "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] -- cgit v1.2.3