diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/normandy | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
118 files changed, 24427 insertions, 0 deletions
diff --git a/toolkit/components/normandy/Normandy.sys.mjs b/toolkit/components/normandy/Normandy.sys.mjs new file mode 100644 index 0000000000..26f1c69105 --- /dev/null +++ b/toolkit/components/normandy/Normandy.sys.mjs @@ -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/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs"; +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs", + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + LogManager: "resource://normandy/lib/LogManager.sys.mjs", + NormandyMigrations: "resource://normandy/NormandyMigrations.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", + PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs", + RecipeRunner: "resource://normandy/lib/RecipeRunner.sys.mjs", + RemoteSettingsExperimentLoader: + "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs", + ShieldPreferences: "resource://normandy/lib/ShieldPreferences.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", + TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs", +}); + +const UI_AVAILABLE_NOTIFICATION = "sessionstore-windows-restored"; +const BOOTSTRAP_LOGGER_NAME = "app.normandy.bootstrap"; +const SHIELD_INIT_NOTIFICATION = "shield-init-complete"; + +const STARTUP_EXPERIMENT_PREFS_BRANCH = "app.normandy.startupExperimentPrefs."; +const STARTUP_ROLLOUT_PREFS_BRANCH = "app.normandy.startupRolloutPrefs."; +const PREF_LOGGING_LEVEL = "app.normandy.logging.level"; + +// Logging +const log = Log.repository.getLogger(BOOTSTRAP_LOGGER_NAME); +log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); +log.level = Services.prefs.getIntPref(PREF_LOGGING_LEVEL, Log.Level.Warn); + +export var Normandy = { + studyPrefsChanged: {}, + rolloutPrefsChanged: {}, + defaultPrefsHaveBeenApplied: PromiseUtils.defer(), + uiAvailableNotificationObserved: PromiseUtils.defer(), + + /** Initialization that needs to happen before the first paint on startup. */ + async init({ runAsync = true } = {}) { + // It is important to register the listener for the UI before the first + // await, to avoid missing it. + Services.obs.addObserver(this, UI_AVAILABLE_NOTIFICATION); + + // Listen for when Telemetry is disabled or re-enabled. + Services.obs.addObserver( + this, + lazy.TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC + ); + + // It is important this happens before the first `await`. Note that this + // also happens before migrations are applied. + this.rolloutPrefsChanged = this.applyStartupPrefs( + STARTUP_ROLLOUT_PREFS_BRANCH + ); + this.studyPrefsChanged = this.applyStartupPrefs( + STARTUP_EXPERIMENT_PREFS_BRANCH + ); + this.defaultPrefsHaveBeenApplied.resolve(); + + await lazy.NormandyMigrations.applyAll(); + + // Wait for the UI to be ready, or time out after 5 minutes. + if (runAsync) { + await Promise.race([ + this.uiAvailableNotificationObserved.promise, + new Promise(resolve => setTimeout(resolve, 5 * 60 * 1000)), + ]); + } + + // Remove observer for UI notifications. It will error if the notification + // was already removed, which is fine. + try { + Services.obs.removeObserver(this, UI_AVAILABLE_NOTIFICATION); + } catch (e) {} + + await this.finishInit(); + }, + + async observe(subject, topic, data) { + if (topic === UI_AVAILABLE_NOTIFICATION) { + Services.obs.removeObserver(this, UI_AVAILABLE_NOTIFICATION); + this.uiAvailableNotificationObserved.resolve(); + } else if (topic === lazy.TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC) { + await Promise.all( + [ + lazy.PreferenceExperiments, + lazy.PreferenceRollouts, + lazy.AddonStudies, + lazy.AddonRollouts, + ].map(service => service.onTelemetryDisabled()) + ); + } + }, + + async finishInit() { + try { + lazy.TelemetryEvents.init(); + } catch (err) { + log.error("Failed to initialize telemetry events:", err); + } + + await lazy.PreferenceRollouts.recordOriginalValues( + this.rolloutPrefsChanged + ); + await lazy.PreferenceExperiments.recordOriginalValues( + this.studyPrefsChanged + ); + + // Setup logging and listen for changes to logging prefs + lazy.LogManager.configure( + Services.prefs.getIntPref(PREF_LOGGING_LEVEL, Log.Level.Warn) + ); + Services.prefs.addObserver(PREF_LOGGING_LEVEL, lazy.LogManager.configure); + lazy.CleanupManager.addCleanupHandler(() => + Services.prefs.removeObserver( + PREF_LOGGING_LEVEL, + lazy.LogManager.configure + ) + ); + + try { + await lazy.ExperimentManager.onStartup(); + } catch (err) { + log.error("Failed to initialize ExperimentManager:", err); + } + + try { + await lazy.RemoteSettingsExperimentLoader.init(); + } catch (err) { + log.error("Failed to initialize RemoteSettingsExperimentLoader:", err); + } + + try { + await lazy.AddonStudies.init(); + } catch (err) { + log.error("Failed to initialize addon studies:", err); + } + + try { + await lazy.PreferenceRollouts.init(); + } catch (err) { + log.error("Failed to initialize preference rollouts:", err); + } + + try { + await lazy.AddonRollouts.init(); + } catch (err) { + log.error("Failed to initialize addon rollouts:", err); + } + + try { + await lazy.PreferenceExperiments.init(); + } catch (err) { + log.error("Failed to initialize preference experiments:", err); + } + + try { + lazy.ShieldPreferences.init(); + } catch (err) { + log.error("Failed to initialize preferences UI:", err); + } + + await lazy.RecipeRunner.init(); + Services.obs.notifyObservers(null, SHIELD_INIT_NOTIFICATION); + }, + + async uninit() { + await lazy.CleanupManager.cleanup(); + // Note that Service.pref.removeObserver and Service.obs.removeObserver have + // oppositely ordered parameters. + Services.prefs.removeObserver( + PREF_LOGGING_LEVEL, + lazy.LogManager.configure + ); + for (const topic of [ + lazy.TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC, + UI_AVAILABLE_NOTIFICATION, + ]) { + try { + Services.obs.removeObserver(this, topic); + } catch (e) { + // topic must have already been removed or never added + } + } + }, + + /** + * Copy a preference subtree from one branch to another, being careful about + * types, and return the values the target branch originally had. Prefs will + * be read from the user branch and applied to the default branch. + * + * @param sourcePrefix + * The pref prefix to read prefs from. + * @returns + * The original values that each pref had on the default branch. + */ + applyStartupPrefs(sourcePrefix) { + // Note that this is called before Normandy's migrations are applied. This + // currently has no effect, but future changes should be careful to be + // backwards compatible. + const originalValues = {}; + const sourceBranch = Services.prefs.getBranch(sourcePrefix); + const targetBranch = Services.prefs.getDefaultBranch(""); + + for (const prefName of sourceBranch.getChildList("")) { + const sourcePrefType = sourceBranch.getPrefType(prefName); + const targetPrefType = targetBranch.getPrefType(prefName); + + if ( + targetPrefType !== Services.prefs.PREF_INVALID && + targetPrefType !== sourcePrefType + ) { + console.error( + new Error( + `Error setting startup pref ${prefName}; pref type does not match.` + ) + ); + continue; + } + + // record the value of the default branch before setting it + try { + switch (targetPrefType) { + case Services.prefs.PREF_STRING: { + originalValues[prefName] = targetBranch.getCharPref(prefName); + break; + } + case Services.prefs.PREF_INT: { + originalValues[prefName] = targetBranch.getIntPref(prefName); + break; + } + case Services.prefs.PREF_BOOL: { + originalValues[prefName] = targetBranch.getBoolPref(prefName); + break; + } + case Services.prefs.PREF_INVALID: { + originalValues[prefName] = null; + break; + } + default: { + // This should never happen + log.error( + `Error getting startup pref ${prefName}; unknown value type ${sourcePrefType}.` + ); + } + } + } catch (e) { + if (e.result === Cr.NS_ERROR_UNEXPECTED) { + // There is a value for the pref on the user branch but not on the default branch. This is ok. + originalValues[prefName] = null; + } else { + // Unexpected error, report it and move on + console.error(e); + continue; + } + } + + // now set the new default value + switch (sourcePrefType) { + case Services.prefs.PREF_STRING: { + targetBranch.setCharPref( + prefName, + sourceBranch.getCharPref(prefName) + ); + break; + } + case Services.prefs.PREF_INT: { + targetBranch.setIntPref(prefName, sourceBranch.getIntPref(prefName)); + break; + } + case Services.prefs.PREF_BOOL: { + targetBranch.setBoolPref( + prefName, + sourceBranch.getBoolPref(prefName) + ); + break; + } + default: { + // This should never happen. + console.error( + new Error( + `Error getting startup pref ${prefName}; unexpected value type ${sourcePrefType}.` + ) + ); + } + } + } + + return originalValues; + }, +}; diff --git a/toolkit/components/normandy/NormandyMigrations.sys.mjs b/toolkit/components/normandy/NormandyMigrations.sys.mjs new file mode 100644 index 0000000000..389102e144 --- /dev/null +++ b/toolkit/components/normandy/NormandyMigrations.sys.mjs @@ -0,0 +1,139 @@ +/* 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/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { AddonStudies } from "resource://normandy/lib/AddonStudies.sys.mjs"; +import { PreferenceExperiments } from "resource://normandy/lib/PreferenceExperiments.sys.mjs"; +import { RecipeRunner } from "resource://normandy/lib/RecipeRunner.sys.mjs"; + +const BOOTSTRAP_LOGGER_NAME = "app.normandy.bootstrap"; + +const PREF_PREFIX = "app.normandy"; +const LEGACY_PREF_PREFIX = "extensions.shield-recipe-client"; +const PREF_LOGGING_LEVEL = "app.normandy.logging.level"; +const PREF_MIGRATIONS_APPLIED = "app.normandy.migrationsApplied"; +const PREF_OPTOUTSTUDIES_ENABLED = "app.shield.optoutstudies.enabled"; + +// Logging +const log = Log.repository.getLogger(BOOTSTRAP_LOGGER_NAME); +log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); +log.level = Services.prefs.getIntPref(PREF_LOGGING_LEVEL, Log.Level.Warn); + +export const NormandyMigrations = { + async applyAll() { + let migrationsApplied = Services.prefs.getIntPref( + PREF_MIGRATIONS_APPLIED, + 0 + ); + + for (let i = migrationsApplied; i < this.migrations.length; i++) { + await this.applyOne(i); + migrationsApplied++; + Services.prefs.setIntPref(PREF_MIGRATIONS_APPLIED, migrationsApplied); + } + }, + + async applyOne(id) { + const migration = this.migrations[id]; + log.debug(`Running Normandy migration ${migration.name}`); + await migration(); + }, + + migrations: [ + migrateShieldPrefs, + migrateStudiesEnabledWithoutHealthReporting, + AddonStudies.migrations + .migration01AddonStudyFieldsToSlugAndUserFacingFields, + PreferenceExperiments.migrations.migration01MoveExperiments, + PreferenceExperiments.migrations.migration02MultiPreference, + PreferenceExperiments.migrations.migration03AddActionName, + PreferenceExperiments.migrations.migration04RenameNameToSlug, + RecipeRunner.migrations.migration01RemoveOldRecipesCollection, + AddonStudies.migrations.migration02RemoveOldAddonStudyAction, + migrateRemoveLastBuildIdPref, + PreferenceExperiments.migrations.migration05RemoveOldAction, + PreferenceExperiments.migrations.migration06TrackOverriddenPrefs, + ], +}; + +function migrateShieldPrefs() { + const legacyBranch = Services.prefs.getBranch(LEGACY_PREF_PREFIX + "."); + const newBranch = Services.prefs.getBranch(PREF_PREFIX + "."); + + for (const prefName of legacyBranch.getChildList("")) { + const legacyPrefType = legacyBranch.getPrefType(prefName); + const newPrefType = newBranch.getPrefType(prefName); + + // If new preference exists and is not the same as the legacy pref, skip it + if ( + newPrefType !== Services.prefs.PREF_INVALID && + newPrefType !== legacyPrefType + ) { + log.error( + `Error migrating normandy pref ${prefName}; pref type does not match.` + ); + continue; + } + + // Now move the value over. If it matches the default, this will be a no-op + switch (legacyPrefType) { + case Services.prefs.PREF_STRING: + newBranch.setCharPref(prefName, legacyBranch.getCharPref(prefName)); + break; + + case Services.prefs.PREF_INT: + newBranch.setIntPref(prefName, legacyBranch.getIntPref(prefName)); + break; + + case Services.prefs.PREF_BOOL: + newBranch.setBoolPref(prefName, legacyBranch.getBoolPref(prefName)); + break; + + case Services.prefs.PREF_INVALID: + // This should never happen. + log.error( + `Error migrating pref ${prefName}; pref type is invalid (${legacyPrefType}).` + ); + break; + + default: + // This should never happen either. + log.error( + `Error getting startup pref ${prefName}; unknown value type ${legacyPrefType}.` + ); + } + + legacyBranch.clearUserPref(prefName); + } +} + +/** + * Migration to handle moving the studies opt-out pref from under the health + * report upload pref to an independent pref. + * + * If the pref was set to true and the health report upload pref was set + * to true then the pref should stay true. Otherwise set it to false. + */ +function migrateStudiesEnabledWithoutHealthReporting() { + const optOutStudiesEnabled = Services.prefs.getBoolPref( + PREF_OPTOUTSTUDIES_ENABLED, + false + ); + const healthReportUploadEnabled = Services.prefs.getBoolPref( + "datareporting.healthreport.uploadEnabled", + false + ); + Services.prefs.setBoolPref( + PREF_OPTOUTSTUDIES_ENABLED, + optOutStudiesEnabled && healthReportUploadEnabled + ); +} + +/** + * Tracking last build ID is now done by comparing Services.appinfo.appBuildID + * and Services.appinfo.lastAppBuildID. Remove the manual tracking. + */ +function migrateRemoveLastBuildIdPref() { + Services.prefs.clearUserPref("app.normandy.last_seen_buildid"); +} diff --git a/toolkit/components/normandy/ShieldContentProcess.sys.mjs b/toolkit/components/normandy/ShieldContentProcess.sys.mjs new file mode 100644 index 0000000000..5406da63ab --- /dev/null +++ b/toolkit/components/normandy/ShieldContentProcess.sys.mjs @@ -0,0 +1,17 @@ +/* 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/. */ + +/** + * Registers about: pages provided by Shield, and listens for a shutdown event + * from the add-on before un-registering them. + * + * This file is loaded as a process script. It is executed once for each + * process, including the parent one. + */ + +import { AboutPages } from "resource://normandy-content/AboutPages.sys.mjs"; + +export function AboutStudies() { + return AboutPages.aboutStudies; +} diff --git a/toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs b/toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs new file mode 100644 index 0000000000..d4b2d4c423 --- /dev/null +++ b/toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs @@ -0,0 +1,88 @@ +/* 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/. */ + +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +export class AddonRollbackAction extends BaseAction { + get schema() { + return lazy.ActionSchemas["addon-rollback"]; + } + + async _run(recipe) { + const { rolloutSlug } = recipe.arguments; + const rollout = await lazy.AddonRollouts.get(rolloutSlug); + + if (!rollout) { + this.log.debug(`Rollback ${rolloutSlug} not applicable, skipping`); + return; + } + + switch (rollout.state) { + case lazy.AddonRollouts.STATE_ACTIVE: { + await lazy.AddonRollouts.update({ + ...rollout, + state: lazy.AddonRollouts.STATE_ROLLED_BACK, + }); + + const addon = await lazy.AddonManager.getAddonByID(rollout.addonId); + if (addon) { + try { + await addon.uninstall(); + } catch (err) { + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "addon_rollback", + rolloutSlug, + { + reason: "uninstall-failed", + enrollmentId: + rollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + throw err; + } + } else { + this.log.warn( + `Could not uninstall addon ${rollout.addonId} for rollback ${rolloutSlug}: it is not installed.` + ); + } + + lazy.TelemetryEvents.sendEvent( + "unenroll", + "addon_rollback", + rolloutSlug, + { + reason: "rollback", + enrollmentId: + rollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + lazy.TelemetryEnvironment.setExperimentInactive(rolloutSlug); + break; + } + + case lazy.AddonRollouts.STATE_ROLLED_BACK: { + return; // Do nothing + } + + default: { + throw new Error( + `Unexpected state when rolling back ${rolloutSlug}: ${rollout.state}` + ); + } + } + } +} diff --git a/toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs b/toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs new file mode 100644 index 0000000000..f669aaaf4f --- /dev/null +++ b/toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs @@ -0,0 +1,241 @@ +/* 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/. */ + +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs", + NormandyAddonManager: "resource://normandy/lib/NormandyAddonManager.sys.mjs", + NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +class AddonRolloutError extends Error { + /** + * @param {string} slug + * @param {object} extra Extra details to include when reporting the error to telemetry. + * @param {string} extra.reason The specific reason for the failure. + */ + constructor(slug, extra) { + let message; + let { reason } = extra; + switch (reason) { + case "conflict": { + message = "an existing rollout already exists for this add-on"; + break; + } + case "addon-id-changed": { + message = "upgrade add-on ID does not match installed add-on ID"; + break; + } + case "upgrade-required": { + message = "a newer version of the add-on is already installed"; + break; + } + case "download-failure": { + message = "the add-on failed to download"; + break; + } + case "metadata-mismatch": { + message = "the server metadata does not match the downloaded add-on"; + break; + } + case "install-failure": { + message = "the add-on failed to install"; + break; + } + default: { + throw new Error(`Unexpected AddonRolloutError reason: ${reason}`); + } + } + super(`Cannot install add-on for rollout (${slug}): ${message}.`); + this.slug = slug; + this.extra = extra; + } +} + +export class AddonRolloutAction extends BaseAction { + get schema() { + return lazy.ActionSchemas["addon-rollout"]; + } + + async _run(recipe) { + const { extensionApiId, slug } = recipe.arguments; + + const existingRollout = await lazy.AddonRollouts.get(slug); + const eventName = existingRollout ? "update" : "enroll"; + const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails( + extensionApiId + ); + let enrollmentId = existingRollout + ? existingRollout.enrollmentId + : undefined; + + // Check if the existing rollout matches the current rollout + if ( + existingRollout && + existingRollout.addonId === extensionDetails.extension_id + ) { + const versionCompare = Services.vc.compare( + existingRollout.addonVersion, + extensionDetails.version + ); + + if (versionCompare === 0) { + return; // Do nothing + } + } + + const createError = (reason, extra) => { + return new AddonRolloutError(slug, { + ...extra, + reason, + }); + }; + + // Check for a conflict (addon already installed by another rollout) + const activeRollouts = await lazy.AddonRollouts.getAllActive(); + const conflictingRollout = activeRollouts.find( + rollout => + rollout.slug !== slug && + rollout.addonId === extensionDetails.extension_id + ); + if (conflictingRollout) { + const conflictError = createError("conflict", { + addonId: conflictingRollout.addonId, + conflictingSlug: conflictingRollout.slug, + enrollmentId: + conflictingRollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + this.reportError(conflictError, "enrollFailed"); + throw conflictError; + } + + const onInstallStarted = (install, installDeferred) => { + const existingAddon = install.existingAddon; + + if (existingRollout && existingRollout.addonId !== install.addon.id) { + installDeferred.reject( + createError("addon-id-changed", { + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }) + ); + return false; // cancel the upgrade, the add-on ID has changed + } + + if ( + existingAddon && + Services.vc.compare(existingAddon.version, install.addon.version) > 0 + ) { + installDeferred.reject( + createError("upgrade-required", { + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }) + ); + return false; // cancel the installation, must be an upgrade + } + + return true; + }; + + const applyNormandyChanges = async install => { + const details = { + addonId: install.addon.id, + addonVersion: install.addon.version, + extensionApiId, + xpiUrl: extensionDetails.xpi, + xpiHash: extensionDetails.hash, + xpiHashAlgorithm: extensionDetails.hash_algorithm, + }; + + if (existingRollout) { + await lazy.AddonRollouts.update({ + ...existingRollout, + ...details, + }); + } else { + enrollmentId = lazy.NormandyUtils.generateUuid(); + await lazy.AddonRollouts.add({ + recipeId: recipe.id, + state: lazy.AddonRollouts.STATE_ACTIVE, + slug, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + ...details, + }); + } + }; + + const undoNormandyChanges = async () => { + if (existingRollout) { + await lazy.AddonRollouts.update(existingRollout); + } else { + await lazy.AddonRollouts.delete(recipe.id); + } + }; + + const [installedId, installedVersion] = + await lazy.NormandyAddonManager.downloadAndInstall({ + createError, + extensionDetails, + applyNormandyChanges, + undoNormandyChanges, + onInstallStarted, + reportError: error => this.reportError(error, `${eventName}Failed`), + }); + + if (existingRollout) { + this.log.debug(`Updated addon rollout ${slug}`); + } else { + this.log.debug(`Enrolled in addon rollout ${slug}`); + lazy.TelemetryEnvironment.setExperimentActive( + slug, + lazy.AddonRollouts.STATE_ACTIVE, + { + type: "normandy-addonrollout", + } + ); + } + + // All done, report success to Telemetry + lazy.TelemetryEvents.sendEvent(eventName, "addon_rollout", slug, { + addonId: installedId, + addonVersion: installedVersion, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } + + reportError(error, eventName) { + if (error instanceof AddonRolloutError) { + // One of our known errors. Report it nicely to telemetry + lazy.TelemetryEvents.sendEvent( + eventName, + "addon_rollout", + error.slug, + error.extra + ); + } else { + /* + * Some unknown error. Add some helpful details, and report it to + * telemetry. The actual stack trace and error message could possibly + * contain PII, so we don't include them here. Instead include some + * information that should still be helpful, and is less likely to be + * unsafe. + */ + const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`; + lazy.TelemetryEvents.sendEvent(eventName, "addon_rollout", error.slug, { + reason: safeErrorMessage.slice(0, 80), // max length is 80 chars + }); + } + } +} diff --git a/toolkit/components/normandy/actions/BaseAction.sys.mjs b/toolkit/components/normandy/actions/BaseAction.sys.mjs new file mode 100644 index 0000000000..f71d5c71dd --- /dev/null +++ b/toolkit/components/normandy/actions/BaseAction.sys.mjs @@ -0,0 +1,338 @@ +/* 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/. */ + +import { Uptake } from "resource://normandy/lib/Uptake.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JsonSchemaValidator: + "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs", + LogManager: "resource://normandy/lib/LogManager.sys.mjs", +}); + +/** + * Base class for local actions. + * + * This should be subclassed. Subclasses must implement _run() for + * per-recipe behavior, and may implement _preExecution and _finalize + * for actions to be taken once before and after recipes are run. + * + * Other methods should be overridden with care, to maintain the life + * cycle events and error reporting implemented by this class. + */ +export class BaseAction { + constructor() { + this.state = BaseAction.STATE_PREPARING; + this.log = lazy.LogManager.getLogger(`action.${this.name}`); + this.lastError = null; + } + + /** + * Be sure to run the _preExecution() hook once during its + * lifecycle. + * + * This is not intended for overriding by subclasses. + */ + _ensurePreExecution() { + if (this.state !== BaseAction.STATE_PREPARING) { + return; + } + + try { + this._preExecution(); + // if _preExecution changed the state, don't overwrite it + if (this.state === BaseAction.STATE_PREPARING) { + this.state = BaseAction.STATE_READY; + } + } catch (err) { + // Sometimes err.message is editable. If it is, add helpful details. + // Otherwise log the helpful details and move on. + try { + err.message = `Could not initialize action ${this.name}: ${err.message}`; + } catch (_e) { + this.log.error( + `Could not initialize action ${this.name}, error follows.` + ); + } + this.fail(err); + } + } + + get schema() { + return { + type: "object", + properties: {}, + }; + } + + /** + * Disable the action for a non-error reason, such as the user opting out of + * this type of action. + */ + disable() { + this.state = BaseAction.STATE_DISABLED; + } + + fail(err) { + switch (this.state) { + case BaseAction.STATE_PREPARING: { + Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR); + break; + } + default: { + console.error(new Error("BaseAction.fail() called at unexpected time")); + } + } + this.state = BaseAction.STATE_FAILED; + this.lastError = err; + console.error(err); + } + + // Gets the name of the action. Does not necessarily match the + // server slug for the action. + get name() { + return this.constructor.name; + } + + /** + * Action specific pre-execution behavior should be implemented + * here. It will be called once per execution session. + */ + _preExecution() { + // Does nothing, may be overridden + } + + validateArguments(args, schema = this.schema) { + let { valid, parsedValue: validated } = lazy.JsonSchemaValidator.validate( + args, + schema, + { + allowExtraProperties: true, + } + ); + if (!valid) { + throw new Error( + `Arguments do not match schema. arguments:\n${JSON.stringify(args)}\n` + + `schema:\n${JSON.stringify(schema)}` + ); + } + return validated; + } + + /** + * Execute the per-recipe behavior of this action for a given + * recipe. Reports Uptake telemetry for the execution of the recipe. + * + * @param {Recipe} recipe + * @param {BaseAction.suitability} suitability + * @throws If this action has already been finalized. + */ + async processRecipe(recipe, suitability) { + if (!BaseAction.suitabilitySet.has(suitability)) { + throw new Error(`Unknown recipe status ${suitability}`); + } + + this._ensurePreExecution(); + + if (this.state === BaseAction.STATE_FINALIZED) { + throw new Error("Action has already been finalized"); + } + + if (this.state !== BaseAction.STATE_READY) { + Uptake.reportRecipe(recipe, Uptake.RECIPE_ACTION_DISABLED); + this.log.warn( + `Skipping recipe ${recipe.name} because ${this.name} was disabled during preExecution.` + ); + return; + } + + let uptakeResult = BaseAction.suitabilityToUptakeStatus[suitability]; + if (!uptakeResult) { + throw new Error( + `Coding error, no uptake status for suitability ${suitability}` + ); + } + + // If capabilties don't match, we can't even be sure that the arguments + // should be valid. In that case don't try to validate them. + if (suitability !== BaseAction.suitability.CAPABILITIES_MISMATCH) { + try { + recipe.arguments = this.validateArguments(recipe.arguments); + } catch (error) { + console.error(error); + uptakeResult = Uptake.RECIPE_EXECUTION_ERROR; + suitability = BaseAction.suitability.ARGUMENTS_INVALID; + } + } + + try { + await this._processRecipe(recipe, suitability); + } catch (err) { + console.error(err); + uptakeResult = Uptake.RECIPE_EXECUTION_ERROR; + } + Uptake.reportRecipe(recipe, uptakeResult); + } + + /** + * Action specific recipe behavior may be implemented here. It will be + * executed once for each recipe that applies to this client. + * The recipe will be passed as a parameter. + * + * @param {Recipe} recipe + */ + async _run(recipe) { + throw new Error("Not implemented"); + } + + /** + * Action specific recipe behavior should be implemented here. It will be + * executed once for every recipe currently published. The suitability of the + * recipe will be passed, it will be one of the constants from + * `BaseAction.suitability`. + * + * By default, this calls `_run()` for recipes with `status == FILTER_MATCH`, + * and does nothing for all other recipes. It is invalid for an action to + * override both `_run` and `_processRecipe`. + * + * @param {Recipe} recipe + * @param {RecipeSuitability} suitability + */ + async _processRecipe(recipe, suitability) { + if (!suitability) { + throw new Error("Suitability is undefined:", suitability); + } + if (suitability == BaseAction.suitability.FILTER_MATCH) { + await this._run(recipe); + } + } + + /** + * Finish an execution session. After this method is called, no + * other methods may be called on this method, and all relevant + * recipes will be assumed to have been seen. + */ + async finalize(options) { + // It's possible that no recipes used this action, so processRecipe() + // was never called. In that case, we should ensure that we call + // _preExecute() here. + this._ensurePreExecution(); + + let status; + switch (this.state) { + case BaseAction.STATE_FINALIZED: { + throw new Error("Action has already been finalized"); + } + case BaseAction.STATE_READY: { + try { + await this._finalize(options); + status = Uptake.ACTION_SUCCESS; + } catch (err) { + status = Uptake.ACTION_POST_EXECUTION_ERROR; + // Sometimes Error.message can be updated in place. This gives better messages when debugging errors. + try { + err.message = `Could not run postExecution hook for ${this.name}: ${err.message}`; + } catch (err) { + // Sometimes Error.message cannot be updated. Log a warning, and move on. + this.log.debug(`Could not run postExecution hook for ${this.name}`); + } + + this.lastError = err; + console.error(err); + } + break; + } + case BaseAction.STATE_DISABLED: { + this.log.debug( + `Skipping post-execution hook for ${this.name} because it is disabled.` + ); + status = Uptake.ACTION_SUCCESS; + break; + } + case BaseAction.STATE_FAILED: { + this.log.debug( + `Skipping post-execution hook for ${this.name} because it failed during pre-execution.` + ); + // Don't report a status. A status should have already been reported by this.fail(). + break; + } + default: { + throw new Error(`Unexpected state during finalize: ${this.state}`); + } + } + + this.state = BaseAction.STATE_FINALIZED; + if (status) { + Uptake.reportAction(this.name, status); + } + } + + /** + * Action specific post-execution behavior should be implemented + * here. It will be executed once after all recipes have been + * processed. + */ + async _finalize(_options = {}) { + // Does nothing, may be overridden + } +} + +BaseAction.STATE_PREPARING = "ACTION_PREPARING"; +BaseAction.STATE_READY = "ACTION_READY"; +BaseAction.STATE_DISABLED = "ACTION_DISABLED"; +BaseAction.STATE_FAILED = "ACTION_FAILED"; +BaseAction.STATE_FINALIZED = "ACTION_FINALIZED"; + +// Make sure to update the docs in ../docs/suitabilities.rst when changing this. +BaseAction.suitability = { + /** + * The recipe's signature is not valid. If any action is taken this recipe + * should be treated with extreme suspicion. + */ + SIGNATURE_ERROR: "RECIPE_SUITABILITY_SIGNATURE_ERROR", + + /** + * The recipe requires capabilities that this recipe runner does not have. + * Use caution when interacting with this recipe, as it may not match the + * expected schema. + */ + CAPABILITIES_MISMATCH: "RECIPE_SUITABILITY_CAPABILITIES_MISMATCH", + + /** + * The recipe is suitable to execute in this client. + */ + FILTER_MATCH: "RECIPE_SUITABILITY_FILTER_MATCH", + + /** + * This client does not match the recipe's filter, but it is otherwise a + * suitable recipe. + */ + FILTER_MISMATCH: "RECIPE_SUITABILITY_FILTER_MISMATCH", + + /** + * There was an error while evaluating the filter. It is unknown if this + * client matches this filter. This may be temporary, due to network errors, + * or permanent due to syntax errors. + */ + FILTER_ERROR: "RECIPE_SUITABILITY_FILTER_ERROR", + + /** + * The arguments of the recipe do not match the expected schema for the named + * action. + */ + ARGUMENTS_INVALID: "RECIPE_SUITABILITY_ARGUMENTS_INVALID", +}; + +BaseAction.suitabilitySet = new Set(Object.values(BaseAction.suitability)); + +BaseAction.suitabilityToUptakeStatus = { + [BaseAction.suitability.SIGNATURE_ERROR]: Uptake.RECIPE_INVALID_SIGNATURE, + [BaseAction.suitability.CAPABILITIES_MISMATCH]: + Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES, + [BaseAction.suitability.FILTER_MATCH]: Uptake.RECIPE_SUCCESS, + [BaseAction.suitability.FILTER_MISMATCH]: Uptake.RECIPE_DIDNT_MATCH_FILTER, + [BaseAction.suitability.FILTER_ERROR]: Uptake.RECIPE_FILTER_BROKEN, + [BaseAction.suitability.ARGUMENTS_INVALID]: Uptake.RECIPE_ARGUMENTS_INVALID, +}; diff --git a/toolkit/components/normandy/actions/BaseStudyAction.sys.mjs b/toolkit/components/normandy/actions/BaseStudyAction.sys.mjs new file mode 100644 index 0000000000..cca66e65fa --- /dev/null +++ b/toolkit/components/normandy/actions/BaseStudyAction.sys.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled"; + +/** + * Base class for local study actions. + * + * This should be subclassed. Subclasses must implement _run() for + * per-recipe behavior, and may implement _finalize for actions to be + * taken once after recipes are run. + * + * For actions that need to be taken once before recipes are run + * _preExecution may be overriden but the overridden method must + * call the parent method to ensure the appropriate checks occur. + * + * Other methods should be overridden with care, to maintain the life + * cycle events and error reporting implemented by this class. + */ +export class BaseStudyAction extends BaseAction { + _preExecution() { + if (!Services.policies.isAllowed("Shield")) { + this.log.debug("Disabling Shield because it's blocked by policy."); + this.disable(); + } + + if (!Services.prefs.getBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, true)) { + this.log.debug( + "User has opted-out of opt-out experiments, disabling action." + ); + this.disable(); + } + } +} diff --git a/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs b/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs new file mode 100644 index 0000000000..b341635668 --- /dev/null +++ b/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs @@ -0,0 +1,789 @@ +/* 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/. */ + +/* + * This action handles the life cycle of add-on based studies. Currently that + * means installing the add-on the first time the recipe applies to this + * client, updating the add-on to new versions if the recipe changes, and + * uninstalling them when the recipe no longer applies. + */ + +import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +class AddonStudyEnrollError extends Error { + /** + * @param {string} studyName + * @param {object} extra Extra details to include when reporting the error to telemetry. + * @param {string} extra.reason The specific reason for the failure. + */ + constructor(studyName, extra) { + let message; + let { reason } = extra; + switch (reason) { + case "conflicting-addon-id": { + message = "an add-on with this ID is already installed"; + break; + } + case "download-failure": { + message = "the add-on failed to download"; + break; + } + case "metadata-mismatch": { + message = "the server metadata does not match the downloaded add-on"; + break; + } + case "install-failure": { + message = "the add-on failed to install"; + break; + } + default: { + throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`); + } + } + super(`Cannot install study add-on for ${studyName}: ${message}.`); + this.studyName = studyName; + this.extra = extra; + } +} + +class AddonStudyUpdateError extends Error { + /** + * @param {string} studyName + * @param {object} extra Extra details to include when reporting the error to telemetry. + * @param {string} extra.reason The specific reason for the failure. + */ + constructor(studyName, extra) { + let message; + let { reason } = extra; + switch (reason) { + case "addon-id-mismatch": { + message = "new add-on ID does not match old add-on ID"; + break; + } + case "addon-does-not-exist": { + message = "an add-on with this ID does not exist"; + break; + } + case "no-downgrade": { + message = "the add-on was an older version than is installed"; + break; + } + case "metadata-mismatch": { + message = "the server metadata does not match the downloaded add-on"; + break; + } + case "download-failure": { + message = "the add-on failed to download"; + break; + } + case "install-failure": { + message = "the add-on failed to install"; + break; + } + default: { + throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`); + } + } + super(`Cannot update study add-on for ${studyName}: ${message}.`); + this.studyName = studyName; + this.extra = extra; + } +} + +export class BranchedAddonStudyAction extends BaseStudyAction { + get schema() { + return lazy.ActionSchemas["branched-addon-study"]; + } + + constructor() { + super(); + this.seenRecipeIds = new Set(); + } + + async _run(recipe) { + throw new Error("_run should not be called anymore"); + } + + /** + * This hook is executed once for every recipe currently enabled on the + * server. It is responsible for: + * + * - Enrolling studies the first time they have a FILTER_MATCH suitability. + * - Updating studies that have changed and still have a FILTER_MATCH suitability. + * - Marking studies as having been seen in this session. + * - Unenrolling studies when they have permanent errors. + * - Unenrolling studies when temporary errors persist for too long. + * + * If the action fails to perform any of these tasks, it should throw to + * properly report its status. + */ + async _processRecipe(recipe, suitability) { + this.seenRecipeIds.add(recipe.id); + const study = await lazy.AddonStudies.get(recipe.id); + + switch (suitability) { + case lazy.BaseAction.suitability.FILTER_MATCH: { + if (!study) { + await this.enroll(recipe); + } else if (study.active) { + await this.update(recipe, study); + } + break; + } + + case lazy.BaseAction.suitability.SIGNATURE_ERROR: { + await this._considerTemporaryError({ + study, + reason: "signature-error", + }); + break; + } + + case lazy.BaseAction.suitability.FILTER_ERROR: { + await this._considerTemporaryError({ + study, + reason: "filter-error", + }); + break; + } + + case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: { + if (study?.active) { + await this.unenroll(recipe.id, "capability-mismatch"); + } + break; + } + + case lazy.BaseAction.suitability.FILTER_MISMATCH: { + if (study?.active) { + await this.unenroll(recipe.id, "filter-mismatch"); + } + break; + } + + case lazy.BaseAction.suitability.ARGUMENTS_INVALID: { + if (study?.active) { + await this.unenroll(recipe.id, "arguments-invalid"); + } + break; + } + + default: { + throw new Error(`Unknown recipe suitability "${suitability}".`); + } + } + } + + /** + * This hook is executed once after all recipes that apply to this client + * have been processed. It is responsible for unenrolling the client from any + * studies that no longer apply, based on this.seenRecipeIds. + */ + async _finalize({ noRecipes } = {}) { + const activeStudies = await lazy.AddonStudies.getAllActive({ + branched: lazy.AddonStudies.FILTER_BRANCHED_ONLY, + }); + + if (noRecipes) { + if (this.seenRecipeIds.size) { + throw new BranchedAddonStudyAction.BadNoRecipesArg(); + } + for (const study of activeStudies) { + await this._considerTemporaryError({ study, reason: "no-recipes" }); + } + } else { + for (const study of activeStudies) { + if (!this.seenRecipeIds.has(study.recipeId)) { + this.log.debug( + `Stopping branched add-on study for recipe ${study.recipeId}` + ); + try { + await this.unenroll(study.recipeId, "recipe-not-seen"); + } catch (err) { + console.error(err); + } + } + } + } + } + + /** + * Download and install the addon for a given recipe + * + * @param recipe Object describing the study to enroll in. + * @param extensionDetails Object describing the addon to be installed. + * @param onInstallStarted A function that returns a callback for the install listener. + * @param onComplete A callback function that is run on completion of the download. + * @param onFailedInstall A callback function that is run if the installation fails. + * @param errorClass The class of error to be thrown when exceptions occur. + * @param reportError A function that reports errors to Telemetry. + * @param [errorExtra] Optional, an object that will be merged into the + * `extra` field of the error generated, if any. + */ + async downloadAndInstall({ + recipe, + extensionDetails, + branchSlug, + onInstallStarted, + onComplete, + onFailedInstall, + errorClass, + reportError, + errorExtra = {}, + }) { + const { slug } = recipe.arguments; + const { hash, hash_algorithm } = extensionDetails; + + const downloadDeferred = lazy.PromiseUtils.defer(); + const installDeferred = lazy.PromiseUtils.defer(); + + const install = await lazy.AddonManager.getInstallForURL( + extensionDetails.xpi, + { + hash: `${hash_algorithm}:${hash}`, + telemetryInfo: { source: "internal" }, + } + ); + + const listener = { + onDownloadFailed() { + downloadDeferred.reject( + new errorClass(slug, { + reason: "download-failure", + branch: branchSlug, + detail: lazy.AddonManager.errorToString(install.error), + ...errorExtra, + }) + ); + }, + + onDownloadEnded() { + downloadDeferred.resolve(); + return false; // temporarily pause installation for Normandy bookkeeping + }, + + onInstallFailed() { + installDeferred.reject( + new errorClass(slug, { + reason: "install-failure", + branch: branchSlug, + detail: lazy.AddonManager.errorToString(install.error), + }) + ); + }, + + onInstallEnded() { + installDeferred.resolve(); + }, + }; + + listener.onInstallStarted = onInstallStarted(installDeferred); + + install.addListener(listener); + + // Download the add-on + try { + install.install(); + await downloadDeferred.promise; + } catch (err) { + reportError(err); + install.removeListener(listener); + throw err; + } + + await onComplete(install, listener); + + // Finish paused installation + try { + install.install(); + await installDeferred.promise; + } catch (err) { + reportError(err); + install.removeListener(listener); + await onFailedInstall(); + throw err; + } + + install.removeListener(listener); + + return [install.addon.id, install.addon.version]; + } + + async chooseBranch({ slug, branches }) { + const ratios = branches.map(branch => branch.ratio); + const userId = lazy.ClientEnvironment.userId; + + // 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 = `${userId}-${slug}-addon-branch`; + + const index = await lazy.Sampling.ratioSample(input, ratios); + return branches[index]; + } + + /** + * Enroll in the study represented by the given recipe. + * @param recipe Object describing the study to enroll in. + * @param extensionDetails Object describing the addon to be installed. + */ + async enroll(recipe) { + // This function first downloads the add-on to get its metadata. Then it + // uses that metadata to record a study in `AddonStudies`. Then, it finishes + // installing the add-on, and finally sends telemetry. If any of these steps + // fails, the previous ones are undone, as needed. + // + // This ordering is important because the only intermediate states we can be + // in are: + // 1. The add-on is only downloaded, in which case AddonManager will clean it up. + // 2. The study has been recorded, in which case we will unenroll on next + // start up. The start up code will assume that the add-on was uninstalled + // while the browser was shutdown. + // 3. After installation is complete, but before telemetry, in which case we + // lose an enroll event. This is acceptable. + // + // This way a shutdown, crash or unexpected error can't leave Normandy in a + // long term inconsistent state. The main thing avoided is having a study + // add-on installed but no record of it, which would leave it permanently + // installed. + + if (recipe.arguments.isEnrollmentPaused) { + // Recipe does not need anything done + return; + } + + const { slug, userFacingName, userFacingDescription } = recipe.arguments; + const branch = await this.chooseBranch({ + slug: recipe.arguments.slug, + branches: recipe.arguments.branches, + }); + this.log.debug(`Enrolling in branch ${branch.slug}`); + + const enrollmentId = lazy.NormandyUtils.generateUuid(); + + if (branch.extensionApiId === null) { + const study = { + recipeId: recipe.id, + slug, + userFacingName, + userFacingDescription, + branch: branch.slug, + addonId: null, + addonVersion: null, + addonUrl: null, + extensionApiId: null, + extensionHash: null, + extensionHashAlgorithm: null, + active: true, + studyStartDate: new Date(), + studyEndDate: null, + enrollmentId, + temporaryErrorDeadline: null, + }; + + try { + await lazy.AddonStudies.add(study); + } catch (err) { + this.reportEnrollError(err); + throw err; + } + + // All done, report success to Telemetry + lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, { + addonId: lazy.AddonStudies.NO_ADDON_MARKER, + addonVersion: lazy.AddonStudies.NO_ADDON_MARKER, + branch: branch.slug, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } else { + const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails( + branch.extensionApiId + ); + + const onInstallStarted = installDeferred => cbInstall => { + const versionMatches = + cbInstall.addon.version === extensionDetails.version; + const idMatches = cbInstall.addon.id === extensionDetails.extension_id; + + if (cbInstall.existingAddon) { + installDeferred.reject( + new AddonStudyEnrollError(slug, { + reason: "conflicting-addon-id", + branch: branch.slug, + }) + ); + return false; // cancel the installation, no upgrades allowed + } else if (!versionMatches || !idMatches) { + installDeferred.reject( + new AddonStudyEnrollError(slug, { + branch: branch.slug, + reason: "metadata-mismatch", + }) + ); + return false; // cancel the installation, server metadata does not match downloaded add-on + } + return true; + }; + + let study; + const onComplete = async (install, listener) => { + study = { + recipeId: recipe.id, + slug, + userFacingName, + userFacingDescription, + branch: branch.slug, + addonId: install.addon.id, + addonVersion: install.addon.version, + addonUrl: extensionDetails.xpi, + extensionApiId: branch.extensionApiId, + extensionHash: extensionDetails.hash, + extensionHashAlgorithm: extensionDetails.hash_algorithm, + active: true, + studyStartDate: new Date(), + studyEndDate: null, + enrollmentId, + temporaryErrorDeadline: null, + }; + + try { + await lazy.AddonStudies.add(study); + } catch (err) { + this.reportEnrollError(err); + install.removeListener(listener); + install.cancel(); + throw err; + } + }; + + const onFailedInstall = async () => { + await lazy.AddonStudies.delete(recipe.id); + }; + + const [installedId, installedVersion] = await this.downloadAndInstall({ + recipe, + branchSlug: branch.slug, + extensionDetails, + onInstallStarted, + onComplete, + onFailedInstall, + errorClass: AddonStudyEnrollError, + reportError: this.reportEnrollError, + }); + + // All done, report success to Telemetry + lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, { + addonId: installedId, + addonVersion: installedVersion, + branch: branch.slug, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } + + lazy.TelemetryEnvironment.setExperimentActive(slug, branch.slug, { + type: "normandy-addonstudy", + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } + + /** + * Update the study represented by the given recipe. + * @param recipe Object describing the study to be updated. + * @param extensionDetails Object describing the addon to be installed. + */ + async update(recipe, study) { + const { slug } = recipe.arguments; + + // Stay in the same branch, don't re-sample every time. + const branch = recipe.arguments.branches.find( + branch => branch.slug === study.branch + ); + + if (!branch) { + // Our branch has been removed. Unenroll. + await this.unenroll(recipe.id, "branch-removed"); + return; + } + + // Since we saw a non-error suitability, clear the temporary error deadline. + study.temporaryErrorDeadline = null; + await lazy.AddonStudies.update(study); + + const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails( + branch.extensionApiId + ); + + let error; + + if (study.addonId && study.addonId !== extensionDetails.extension_id) { + error = new AddonStudyUpdateError(slug, { + branch: branch.slug, + reason: "addon-id-mismatch", + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } + + const versionCompare = Services.vc.compare( + study.addonVersion, + extensionDetails.version + ); + if (versionCompare > 0) { + error = new AddonStudyUpdateError(slug, { + branch: branch.slug, + reason: "no-downgrade", + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } else if (versionCompare === 0) { + return; // Unchanged, do nothing + } + + if (error) { + this.reportUpdateError(error); + throw error; + } + + const onInstallStarted = installDeferred => cbInstall => { + const versionMatches = + cbInstall.addon.version === extensionDetails.version; + const idMatches = cbInstall.addon.id === extensionDetails.extension_id; + + if (!cbInstall.existingAddon) { + installDeferred.reject( + new AddonStudyUpdateError(slug, { + branch: branch.slug, + reason: "addon-does-not-exist", + enrollmentId: + study.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }) + ); + return false; // cancel the installation, must upgrade an existing add-on + } else if (!versionMatches || !idMatches) { + installDeferred.reject( + new AddonStudyUpdateError(slug, { + branch: branch.slug, + reason: "metadata-mismatch", + enrollmentId: + study.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }) + ); + return false; // cancel the installation, server metadata do not match downloaded add-on + } + + return true; + }; + + const onComplete = async (install, listener) => { + try { + await lazy.AddonStudies.update({ + ...study, + addonVersion: install.addon.version, + addonUrl: extensionDetails.xpi, + extensionHash: extensionDetails.hash, + extensionHashAlgorithm: extensionDetails.hash_algorithm, + extensionApiId: branch.extensionApiId, + }); + } catch (err) { + this.reportUpdateError(err); + install.removeListener(listener); + install.cancel(); + throw err; + } + }; + + const onFailedInstall = () => { + lazy.AddonStudies.update(study); + }; + + const [installedId, installedVersion] = await this.downloadAndInstall({ + recipe, + extensionDetails, + branchSlug: branch.slug, + onInstallStarted, + onComplete, + onFailedInstall, + errorClass: AddonStudyUpdateError, + reportError: this.reportUpdateError, + errorExtra: { + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }, + }); + + // All done, report success to Telemetry + lazy.TelemetryEvents.sendEvent("update", "addon_study", slug, { + addonId: installedId, + addonVersion: installedVersion, + branch: branch.slug, + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } + + reportEnrollError(error) { + if (error instanceof AddonStudyEnrollError) { + // One of our known errors. Report it nicely to telemetry + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "addon_study", + error.studyName, + error.extra + ); + } else { + /* + * Some unknown error. Add some helpful details, and report it to + * telemetry. The actual stack trace and error message could possibly + * contain PII, so we don't include them here. Instead include some + * information that should still be helpful, and is less likely to be + * unsafe. + */ + const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`; + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "addon_study", + error.studyName, + { + reason: safeErrorMessage.slice(0, 80), // max length is 80 chars + } + ); + } + } + + reportUpdateError(error) { + if (error instanceof AddonStudyUpdateError) { + // One of our known errors. Report it nicely to telemetry + lazy.TelemetryEvents.sendEvent( + "updateFailed", + "addon_study", + error.studyName, + error.extra + ); + } else { + /* + * Some unknown error. Add some helpful details, and report it to + * telemetry. The actual stack trace and error message could possibly + * contain PII, so we don't include them here. Instead include some + * information that should still be helpful, and is less likely to be + * unsafe. + */ + const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`; + lazy.TelemetryEvents.sendEvent( + "updateFailed", + "addon_study", + error.studyName, + { + reason: safeErrorMessage.slice(0, 80), // max length is 80 chars + } + ); + } + } + + /** + * Unenrolls the client from the study with a given recipe ID. + * @param recipeId The recipe ID of an enrolled study + * @param reason The reason for this unenrollment, to be used in Telemetry + * @throws If the specified study does not exist, or if it is already inactive. + */ + async unenroll(recipeId, reason = "unknown") { + const study = await lazy.AddonStudies.get(recipeId); + if (!study) { + throw new Error(`No study found for recipe ${recipeId}.`); + } + if (!study.active) { + throw new Error( + `Cannot stop study for recipe ${recipeId}; it is already inactive.` + ); + } + + await lazy.AddonStudies.markAsEnded(study, reason); + + // Study branches may indicate that no add-on should be installed, as a + // form of control branch. In that case, `study.addonId` will be null (as + // will the other add-on related fields). Only try to uninstall the add-on + // if we expect one should be installed. + if (study.addonId) { + const addon = await lazy.AddonManager.getAddonByID(study.addonId); + if (addon) { + await addon.uninstall(); + } else { + this.log.warn( + `Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.` + ); + } + } + } + + /** + * Given that a temporary error has occured for a study, check if it + * should be temporarily ignored, or if the deadline has passed. If the + * deadline is passed, the study will be ended. If this is the first + * temporary error, a deadline will be generated. Otherwise, nothing will + * happen. + * + * If a temporary deadline exists but cannot be parsed, a new one will be + * made. + * + * The deadline is 7 days from the first time that recipe failed, as + * reckoned by the client's clock. + * + * @param {Object} args + * @param {Study} args.study The enrolled study to potentially unenroll. + * @param {String} args.reason If the study should end, the reason it is ending. + */ + async _considerTemporaryError({ study, reason }) { + if (!study?.active) { + return; + } + + let now = Date.now(); // milliseconds-since-epoch + let day = 24 * 60 * 60 * 1000; + let newDeadline = new Date(now + 7 * day); + + if (study.temporaryErrorDeadline) { + // if deadline is an invalid date, set it to one week from now. + if (isNaN(study.temporaryErrorDeadline)) { + study.temporaryErrorDeadline = newDeadline; + await lazy.AddonStudies.update(study); + return; + } + + if (now > study.temporaryErrorDeadline) { + await this.unenroll(study.recipeId, reason); + } + } else { + // there is no deadline, so set one + study.temporaryErrorDeadline = newDeadline; + await lazy.AddonStudies.update(study); + } + } +} + +BranchedAddonStudyAction.BadNoRecipesArg = class extends Error { + message = "noRecipes is true, but some recipes observed"; +}; diff --git a/toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs b/toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs new file mode 100644 index 0000000000..a0b9f859fa --- /dev/null +++ b/toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs @@ -0,0 +1,20 @@ +/* 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/. */ + +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", +}); + +export class ConsoleLogAction extends BaseAction { + get schema() { + return lazy.ActionSchemas["console-log"]; + } + + async _run(recipe) { + this.log.info(recipe.arguments.message); + } +} diff --git a/toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs b/toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs new file mode 100644 index 0000000000..393d2cc3f2 --- /dev/null +++ b/toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs @@ -0,0 +1,34 @@ +/* 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/. */ + +import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", +}); + +const RECIPE_SOURCE = "normandy"; + +export class MessagingExperimentAction extends BaseStudyAction { + constructor() { + super(); + this.manager = lazy.ExperimentManager; + } + get schema() { + return lazy.ActionSchemas["messaging-experiment"]; + } + + async _run(recipe) { + if (recipe.arguments) { + await this.manager.onRecipe(recipe.arguments, RECIPE_SOURCE); + } + } + + async _finalize() { + this.manager.onFinalize(RECIPE_SOURCE); + } +} diff --git a/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs b/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs new file mode 100644 index 0000000000..310d1b08fd --- /dev/null +++ b/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs @@ -0,0 +1,278 @@ +/* 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/. */ + +import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", +}); + +/** + * Enrolls a user in a preference experiment, in which we assign the + * user to an experiment branch and modify a preference temporarily to + * measure how it affects Firefox via Telemetry. + */ +export class PreferenceExperimentAction extends BaseStudyAction { + get schema() { + return lazy.ActionSchemas["multi-preference-experiment"]; + } + + constructor() { + super(); + this.seenExperimentSlugs = new Set(); + } + + async _processRecipe(recipe, suitability) { + const { + branches, + isHighPopulation, + isEnrollmentPaused, + slug, + userFacingName, + userFacingDescription, + } = recipe.arguments || {}; + + let experiment; + // Slug might not exist, because if suitability is ARGUMENTS_INVALID, the + // arguments is not guaranteed to match the schema. + if (slug) { + this.seenExperimentSlugs.add(slug); + + try { + experiment = await lazy.PreferenceExperiments.get(slug); + } catch (err) { + // This is probably that the experiment doesn't exist. If that's not the + // case, re-throw the error. + if (!(err instanceof lazy.PreferenceExperiments.NotFoundError)) { + throw err; + } + } + } + + switch (suitability) { + case lazy.BaseAction.suitability.SIGNATURE_ERROR: { + this._considerTemporaryError({ experiment, reason: "signature-error" }); + break; + } + + case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: { + if (experiment && !experiment.expired) { + await lazy.PreferenceExperiments.stop(slug, { + resetValue: true, + reason: "capability-mismatch", + caller: + "PreferenceExperimentAction._processRecipe::capabilities_mismatch", + }); + } + break; + } + + case lazy.BaseAction.suitability.FILTER_MATCH: { + // If we're not in the experiment, try to enroll + if (!experiment) { + // Check all preferences that could be used by this experiment. + // If there's already an active experiment that has set that preference, abort. + const activeExperiments = + await lazy.PreferenceExperiments.getAllActive(); + for (const branch of branches) { + const conflictingPrefs = Object.keys(branch.preferences).filter( + preferenceName => { + return activeExperiments.some(exp => + exp.preferences.hasOwnProperty(preferenceName) + ); + } + ); + if (conflictingPrefs.length) { + throw new Error( + `Experiment ${slug} ignored; another active experiment is already using the + ${conflictingPrefs[0]} preference.` + ); + } + } + + // Determine if enrollment is currently paused for this experiment. + if (isEnrollmentPaused) { + this.log.debug(`Enrollment is paused for experiment "${slug}"`); + return; + } + + // Otherwise, enroll! + const branch = await this.chooseBranch(slug, branches); + const experimentType = isHighPopulation ? "exp-highpop" : "exp"; + await lazy.PreferenceExperiments.start({ + slug, + actionName: this.name, + branch: branch.slug, + preferences: branch.preferences, + experimentType, + userFacingName, + userFacingDescription, + }); + } else if (experiment.expired) { + this.log.debug(`Experiment ${slug} has expired, aborting.`); + } else { + experiment.temporaryErrorDeadline = null; + await lazy.PreferenceExperiments.update(experiment); + await lazy.PreferenceExperiments.markLastSeen(slug); + } + break; + } + + case lazy.BaseAction.suitability.FILTER_MISMATCH: { + if (experiment && !experiment.expired) { + await lazy.PreferenceExperiments.stop(slug, { + resetValue: true, + reason: "filter-mismatch", + caller: + "PreferenceExperimentAction._processRecipe::filter_mismatch", + }); + } + break; + } + + case lazy.BaseAction.suitability.FILTER_ERROR: { + this._considerTemporaryError({ experiment, reason: "filter-error" }); + break; + } + + case lazy.BaseAction.suitability.ARGUMENTS_INVALID: { + if (experiment && !experiment.expired) { + await lazy.PreferenceExperiments.stop(slug, { + resetValue: true, + reason: "arguments-invalid", + caller: + "PreferenceExperimentAction._processRecipe::arguments_invalid", + }); + } + break; + } + + default: { + throw new Error(`Unknown recipe suitability "${suitability}".`); + } + } + } + + async _run(recipe) { + throw new Error("_run shouldn't be called anymore"); + } + + async chooseBranch(slug, branches) { + const ratios = branches.map(branch => branch.ratio); + const userId = lazy.ClientEnvironment.userId; + + // 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 = `${userId}-${slug}-branch`; + + const index = await lazy.Sampling.ratioSample(input, ratios); + return branches[index]; + } + + /** + * End any experiments which we didn't see during this session. + * This is the "normal" way experiments end, as they are disabled on + * the server and so we stop seeing them. This can also happen if + * the user doesn't match the filter any more. + */ + async _finalize({ noRecipes } = {}) { + const activeExperiments = await lazy.PreferenceExperiments.getAllActive(); + + if (noRecipes && this.seenExperimentSlugs.size) { + throw new PreferenceExperimentAction.BadNoRecipesArg(); + } + + return Promise.all( + activeExperiments.map(experiment => { + if (this.name != experiment.actionName) { + // Another action is responsible for cleaning this one + // up. Leave it alone. + return null; + } + + if (noRecipes) { + return this._considerTemporaryError({ + experiment, + reason: "no-recipes", + }); + } + + if (this.seenExperimentSlugs.has(experiment.slug)) { + return null; + } + + return lazy.PreferenceExperiments.stop(experiment.slug, { + resetValue: true, + reason: "recipe-not-seen", + caller: "PreferenceExperimentAction._finalize", + }).catch(e => { + this.log.warn(`Stopping experiment ${experiment.slug} failed: ${e}`); + }); + }) + ); + } + + /** + * Given that a temporary error has occurred for an experiment, check if it + * should be temporarily ignored, or if the deadline has passed. If the + * deadline is passed, the experiment will be ended. If this is the first + * temporary error, a deadline will be generated. Otherwise, nothing will + * happen. + * + * If a temporary deadline exists but cannot be parsed, a new one will be + * made. + * + * The deadline is 7 days from the first time that recipe failed, as + * reckoned by the client's clock. + * + * @param {Object} args + * @param {Experiment} args.experiment The enrolled experiment to potentially unenroll. + * @param {String} args.reason If the recipe should end, the reason it is ending. + */ + async _considerTemporaryError({ experiment, reason }) { + if (!experiment || experiment.expired) { + return; + } + + let now = Date.now(); // milliseconds-since-epoch + let day = 24 * 60 * 60 * 1000; + let newDeadline = new Date(now + 7 * day).toJSON(); + + if (experiment.temporaryErrorDeadline) { + let deadline = new Date(experiment.temporaryErrorDeadline); + // if deadline is an invalid date, set it to one week from now. + if (isNaN(deadline)) { + experiment.temporaryErrorDeadline = newDeadline; + await lazy.PreferenceExperiments.update(experiment); + return; + } + + if (now > deadline) { + await lazy.PreferenceExperiments.stop(experiment.slug, { + resetValue: true, + reason, + caller: "PreferenceExperimentAction._considerTemporaryFailure", + }); + } + } else { + // there is no deadline, so set one + experiment.temporaryErrorDeadline = newDeadline; + await lazy.PreferenceExperiments.update(experiment); + } + } +} + +PreferenceExperimentAction.BadNoRecipesArg = class extends Error { + message = "noRecipes is true, but some recipes observed"; +}; diff --git a/toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs b/toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs new file mode 100644 index 0000000000..cc86ad8f70 --- /dev/null +++ b/toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs @@ -0,0 +1,104 @@ +/* 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/. */ + +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +export class PreferenceRollbackAction extends BaseAction { + get schema() { + return lazy.ActionSchemas["preference-rollback"]; + } + + async _run(recipe) { + const { rolloutSlug } = recipe.arguments; + const rollout = await lazy.PreferenceRollouts.get(rolloutSlug); + + if (lazy.PreferenceRollouts.GRADUATION_SET.has(rolloutSlug)) { + // graduated rollouts can't be rolled back + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_rollback", + rolloutSlug, + { + reason: "in-graduation-set", + enrollmentId: + rollout?.enrollmentId ?? + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + throw new Error( + `Cannot rollback rollout in graduation set "${rolloutSlug}".` + ); + } + + if (!rollout) { + this.log.debug(`Rollback ${rolloutSlug} not applicable, skipping`); + return; + } + + switch (rollout.state) { + case lazy.PreferenceRollouts.STATE_ACTIVE: { + this.log.info(`Rolling back ${rolloutSlug}`); + rollout.state = lazy.PreferenceRollouts.STATE_ROLLED_BACK; + for (const { preferenceName, previousValue } of rollout.preferences) { + lazy.PrefUtils.setPref(preferenceName, previousValue, { + branch: "default", + }); + } + await lazy.PreferenceRollouts.update(rollout); + lazy.TelemetryEvents.sendEvent( + "unenroll", + "preference_rollback", + rolloutSlug, + { + reason: "rollback", + enrollmentId: + rollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + lazy.TelemetryEnvironment.setExperimentInactive(rolloutSlug); + break; + } + case lazy.PreferenceRollouts.STATE_ROLLED_BACK: { + // The rollout has already been rolled back, so nothing to do here. + break; + } + case lazy.PreferenceRollouts.STATE_GRADUATED: { + // graduated rollouts can't be rolled back + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_rollback", + rolloutSlug, + { + reason: "graduated", + enrollmentId: + rollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + throw new Error( + `Cannot rollback already graduated rollout ${rolloutSlug}` + ); + } + default: { + throw new Error( + `Unexpected state when rolling back ${rolloutSlug}: ${rollout.state}` + ); + } + } + } + + async _finalize() { + await lazy.PreferenceRollouts.saveStartupPrefs(); + } +} diff --git a/toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs b/toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs new file mode 100644 index 0000000000..b2f917bd95 --- /dev/null +++ b/toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs @@ -0,0 +1,265 @@ +/* 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/. */ + +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const PREFERENCE_TYPE_MAP = { + boolean: Services.prefs.PREF_BOOL, + string: Services.prefs.PREF_STRING, + number: Services.prefs.PREF_INT, +}; + +export class PreferenceRolloutAction extends BaseAction { + get schema() { + return lazy.ActionSchemas["preference-rollout"]; + } + + async _run(recipe) { + const args = recipe.arguments; + + // Check if the rollout is on the list of rollouts to stop applying. + if (lazy.PreferenceRollouts.GRADUATION_SET.has(args.slug)) { + this.log.debug( + `Skipping rollout "${args.slug}" because it is in the graduation set.` + ); + return; + } + + // Determine which preferences are already being managed, to avoid + // conflicts between recipes. This will throw if there is a problem. + await this._verifyRolloutPrefs(args); + + const newRollout = { + slug: args.slug, + state: "active", + preferences: args.preferences.map(({ preferenceName, value }) => ({ + preferenceName, + value, + previousValue: lazy.PrefUtils.getPref(preferenceName, { + branch: "default", + }), + })), + }; + + const existingRollout = await lazy.PreferenceRollouts.get(args.slug); + if (existingRollout) { + const anyChanged = await this._updatePrefsForExistingRollout( + existingRollout, + newRollout + ); + + // If anything was different about the new rollout, write it to the db and send an event about it + if (anyChanged) { + await lazy.PreferenceRollouts.update(newRollout); + lazy.TelemetryEvents.sendEvent( + "update", + "preference_rollout", + args.slug, + { + previousState: existingRollout.state, + enrollmentId: + existingRollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + + switch (existingRollout.state) { + case lazy.PreferenceRollouts.STATE_ACTIVE: { + this.log.debug(`Updated preference rollout ${args.slug}`); + break; + } + case lazy.PreferenceRollouts.STATE_GRADUATED: { + this.log.debug(`Ungraduated preference rollout ${args.slug}`); + lazy.TelemetryEnvironment.setExperimentActive( + args.slug, + newRollout.state, + { type: "normandy-prefrollout" } + ); + break; + } + default: { + console.error( + new Error( + `Updated pref rollout in unexpected state: ${existingRollout.state}` + ) + ); + } + } + } else { + this.log.debug(`No updates to preference rollout ${args.slug}`); + } + } else { + // new enrollment + // Check if this rollout would be a no-op, which is not allowed. + if ( + newRollout.preferences.every( + ({ value, previousValue }) => value === previousValue + ) + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_rollout", + args.slug, + { reason: "would-be-no-op" } + ); + // Throw so that this recipe execution is marked as a failure + throw new Error( + `New rollout ${args.slug} does not change any preferences.` + ); + } + + let enrollmentId = lazy.NormandyUtils.generateUuid(); + newRollout.enrollmentId = enrollmentId; + + await lazy.PreferenceRollouts.add(newRollout); + + for (const { preferenceName, value } of args.preferences) { + lazy.PrefUtils.setPref(preferenceName, value, { branch: "default" }); + } + + this.log.debug(`Enrolled in preference rollout ${args.slug}`); + lazy.TelemetryEnvironment.setExperimentActive( + args.slug, + newRollout.state, + { + type: "normandy-prefrollout", + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + lazy.TelemetryEvents.sendEvent( + "enroll", + "preference_rollout", + args.slug, + { + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + } + } + + /** + * Check that all the preferences in a rollout are ok to set. This means 1) no + * other rollout is managing them, and 2) they match the types of the builtin + * values. + * @param {PreferenceRollout} rollout The arguments from a rollout recipe. + * @throws If the preferences are not valid, with details in the error message. + */ + async _verifyRolloutPrefs({ slug, preferences }) { + const existingManagedPrefs = new Set(); + for (const rollout of await lazy.PreferenceRollouts.getAllActive()) { + if (rollout.slug === slug) { + continue; + } + for (const prefSpec of rollout.preferences) { + existingManagedPrefs.add(prefSpec.preferenceName); + } + } + + for (const prefSpec of preferences) { + if (existingManagedPrefs.has(prefSpec.preferenceName)) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_rollout", + slug, + { + reason: "conflict", + preference: prefSpec.preferenceName, + } + ); + // Throw so that this recipe execution is marked as a failure + throw new Error( + `Cannot start rollout ${slug}. Preference ${prefSpec.preferenceName} is already managed.` + ); + } + const existingPrefType = Services.prefs.getPrefType( + prefSpec.preferenceName + ); + const rolloutPrefType = PREFERENCE_TYPE_MAP[typeof prefSpec.value]; + + if ( + existingPrefType !== Services.prefs.PREF_INVALID && + existingPrefType !== rolloutPrefType + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_rollout", + slug, + { + reason: "invalid type", + preference: prefSpec.preferenceName, + } + ); + // Throw so that this recipe execution is marked as a failure + throw new Error( + `Cannot start rollout "${slug}" on "${prefSpec.preferenceName}". ` + + `Existing preference is of type ${existingPrefType}, but rollout ` + + `specifies type ${rolloutPrefType}` + ); + } + } + } + + async _updatePrefsForExistingRollout(existingRollout, newRollout) { + let anyChanged = false; + const oldPrefSpecs = new Map( + existingRollout.preferences.map(p => [p.preferenceName, p]) + ); + const newPrefSpecs = new Map( + newRollout.preferences.map(p => [p.preferenceName, p]) + ); + + // Check for any preferences that no longer exist, and un-set them. + for (const { preferenceName, previousValue } of oldPrefSpecs.values()) { + if (!newPrefSpecs.has(preferenceName)) { + this.log.debug( + `updating ${existingRollout.slug}: ${preferenceName} no longer exists` + ); + anyChanged = true; + lazy.PrefUtils.setPref(preferenceName, previousValue, { + branch: "default", + }); + } + } + + // Check for any preferences that are new and need added, or changed and need updated. + for (const prefSpec of Object.values(newRollout.preferences)) { + let oldValue = null; + if (oldPrefSpecs.has(prefSpec.preferenceName)) { + let oldPrefSpec = oldPrefSpecs.get(prefSpec.preferenceName); + oldValue = oldPrefSpec.value; + + // Trust the old rollout for the values of `previousValue`, but don't + // consider this a change, since it already matches the DB, and doesn't + // have any other stateful effect. + prefSpec.previousValue = oldPrefSpec.previousValue; + } + if (oldValue !== newPrefSpecs.get(prefSpec.preferenceName).value) { + anyChanged = true; + this.log.debug( + `updating ${existingRollout.slug}: ${prefSpec.preferenceName} value changed from ${oldValue} to ${prefSpec.value}` + ); + lazy.PrefUtils.setPref(prefSpec.preferenceName, prefSpec.value, { + branch: "default", + }); + } + } + return anyChanged; + } + + async _finalize() { + await lazy.PreferenceRollouts.saveStartupPrefs(); + } +} diff --git a/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs b/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs new file mode 100644 index 0000000000..d571e46167 --- /dev/null +++ b/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs @@ -0,0 +1,226 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + Heartbeat: "resource://normandy/lib/Heartbeat.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", + Storage: "resource://normandy/lib/Storage.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "gAllRecipeStorage", function () { + return new lazy.Storage("normandy-heartbeat"); +}); + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const HEARTBEAT_THROTTLE = 1 * DAY_IN_MS; + +export class ShowHeartbeatAction extends BaseAction { + static Heartbeat = lazy.Heartbeat; + + static overrideHeartbeatForTests(newHeartbeat) { + if (newHeartbeat) { + this.Heartbeat = newHeartbeat; + } else { + this.Heartbeat = lazy.Heartbeat; + } + } + + get schema() { + return lazy.ActionSchemas["show-heartbeat"]; + } + + async _run(recipe) { + const { + message, + engagementButtonLabel, + thanksMessage, + learnMoreMessage, + learnMoreUrl, + } = recipe.arguments; + + const recipeStorage = new lazy.Storage(recipe.id); + + if (!(await this.shouldShow(recipeStorage, recipe))) { + return; + } + + this.log.debug( + `Heartbeat for recipe ${recipe.id} showing prompt "${message}"` + ); + const targetWindow = lazy.BrowserWindowTracker.getTopWindow(); + + if (!targetWindow) { + throw new Error("No window to show heartbeat in"); + } + + const heartbeat = new ShowHeartbeatAction.Heartbeat(targetWindow, { + surveyId: this.generateSurveyId(recipe), + message, + engagementButtonLabel, + thanksMessage, + learnMoreMessage, + learnMoreUrl, + postAnswerUrl: await this.generatePostAnswerURL(recipe), + flowId: lazy.NormandyUtils.generateUuid(), + // Recipes coming from Nimbus won't have a revision_id. + ...(Object.hasOwn(recipe, "revision_id") + ? { surveyVersion: recipe.revision_id } + : {}), + }); + + heartbeat.eventEmitter.once( + "Voted", + this.updateLastInteraction.bind(this, recipeStorage) + ); + heartbeat.eventEmitter.once( + "Engaged", + this.updateLastInteraction.bind(this, recipeStorage) + ); + + let now = Date.now(); + await Promise.all([ + lazy.gAllRecipeStorage.setItem("lastShown", now), + recipeStorage.setItem("lastShown", now), + ]); + } + + async shouldShow(recipeStorage, recipe) { + const { repeatOption, repeatEvery } = recipe.arguments; + // Don't show any heartbeats to a user more than once per throttle period + let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown"); + if (lastShown) { + const duration = new Date() - lastShown; + if (duration < HEARTBEAT_THROTTLE) { + // show the number of hours since the last heartbeat, with at most 1 decimal point. + const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10; + this.log.debug( + `A heartbeat was shown too recently (${hoursAgo} hours), skipping recipe ${recipe.id}.` + ); + return false; + } + } + + switch (repeatOption) { + case "once": { + // Don't show if we've ever shown before + if (await recipeStorage.getItem("lastShown")) { + this.log.debug( + `Heartbeat for "once" recipe ${recipe.id} has been shown before, skipping.` + ); + return false; + } + break; + } + + case "nag": { + // Show a heartbeat again only if the user has not interacted with it before + if (await recipeStorage.getItem("lastInteraction")) { + this.log.debug( + `Heartbeat for "nag" recipe ${recipe.id} has already been interacted with, skipping.` + ); + return false; + } + break; + } + + case "xdays": { + // Show this heartbeat again if it has been at least `repeatEvery` days since the last time it was shown. + let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown"); + if (lastShown) { + lastShown = new Date(lastShown); + const duration = new Date() - lastShown; + if (duration < repeatEvery * DAY_IN_MS) { + // show the number of hours since the last time this hearbeat was shown, with at most 1 decimal point. + const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10; + this.log.debug( + `Heartbeat for "xdays" recipe ${recipe.id} ran in the last ${repeatEvery} days, skipping. (${hoursAgo} hours ago)` + ); + return false; + } + } + } + } + + return true; + } + + /** + * Returns a surveyId value. If recipe calls to include the Normandy client + * ID, then the client ID is attached to the surveyId in the format + * `${surveyId}::${userId}`. + * + * @return {String} Survey ID, possibly with user UUID + */ + generateSurveyId(recipe) { + const { includeTelemetryUUID, surveyId } = recipe.arguments; + if (includeTelemetryUUID) { + return `${surveyId}::${lazy.ClientEnvironment.userId}`; + } + return surveyId; + } + + /** + * Generate the appropriate post-answer URL for a recipe. + * @param recipe + * @return {String} URL with post-answer query params + */ + async generatePostAnswerURL(recipe) { + const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments; + + // Don`t bother with empty URLs. + if (!postAnswerUrl) { + return postAnswerUrl; + } + + const userId = lazy.ClientEnvironment.userId; + const searchEngine = (await Services.search.getDefault()).identifier; + const args = { + fxVersion: Services.appinfo.version, + isDefaultBrowser: lazy.ShellService.isDefaultBrowser() ? 1 : 0, + searchEngine, + source: "heartbeat", + // `surveyversion` used to be the version of the heartbeat action when it + // was hosted on a server. Keeping it around for compatibility. + surveyversion: Services.appinfo.version, + syncSetup: Services.prefs.prefHasUserValue("services.sync.username") + ? 1 + : 0, + updateChannel: lazy.UpdateUtils.getUpdateChannel(false), + utm_campaign: encodeURIComponent(message.replace(/\s+/g, "")), + utm_medium: recipe.action, + utm_source: "firefox", + }; + if (includeTelemetryUUID) { + args.userId = userId; + } + + let url = new URL(postAnswerUrl); + // create a URL object to append arguments to + for (const [key, val] of Object.entries(args)) { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, val); + } + } + + // return the address with encoded queries + return url.toString(); + } + + updateLastInteraction(recipeStorage) { + recipeStorage.setItem("lastInteraction", Date.now()); + } +} diff --git a/toolkit/components/normandy/actions/schemas/README.md b/toolkit/components/normandy/actions/schemas/README.md new file mode 100644 index 0000000000..06a7dd4b15 --- /dev/null +++ b/toolkit/components/normandy/actions/schemas/README.md @@ -0,0 +1,13 @@ +# Normandy Action Argument Schemas + +This is a collection of schemas describing the arguments expected by Normandy +actions. Its primary purpose is to be used in the Normandy server and Delivery +Console to validate data and provide better user interactions. + +# Contributing + +Modifications to this package are made as part of mozilla-central following +standard procedures: push a patch to Phabricator as part of a bug. + +To release, bump the package version and land that, as above -- it can be part +of another patch -- and then `cd` to this directory and `npm publish`. diff --git a/toolkit/components/normandy/actions/schemas/export_json.js b/toolkit/components/normandy/actions/schemas/export_json.js new file mode 100644 index 0000000000..940db247aa --- /dev/null +++ b/toolkit/components/normandy/actions/schemas/export_json.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/* 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/. */ + +/* eslint-env node */ + +/** + * This script exports the schemas from this package in JSON format, for use by + * other tools. It is run as a part of the publishing process to NPM. + */ + +const fs = require("fs"); +const schemas = require("./index.js"); + +fs.writeFile("./schemas.json", JSON.stringify(schemas), err => { + if (err) { + console.error("error", err); + } +}); diff --git a/toolkit/components/normandy/actions/schemas/index.sys.mjs b/toolkit/components/normandy/actions/schemas/index.sys.mjs new file mode 100644 index 0000000000..7ef006e905 --- /dev/null +++ b/toolkit/components/normandy/actions/schemas/index.sys.mjs @@ -0,0 +1,528 @@ +/* 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/. */ + +export const ActionSchemas = { + "console-log": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Log a message to the console", + type: "object", + required: ["message"], + properties: { + message: { + description: "Message to log to the console", + type: "string", + default: "", + }, + }, + }, + + "messaging-experiment": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Messaging Experiment", + type: "object", + required: ["slug", "branches", "isEnrollmentPaused"], + properties: { + slug: { + description: "Unique identifier for this experiment", + type: "string", + pattern: "^[A-Za-z0-9\\-_]+$", + }, + isEnrollmentPaused: { + description: "If true, new users will not be enrolled in the study.", + type: "boolean", + default: true, + }, + branches: { + description: "List of experimental branches", + type: "array", + minItems: 1, + items: { + type: "object", + required: ["slug", "value", "ratio", "groups"], + properties: { + slug: { + description: + "Unique identifier for this branch of the experiment.", + type: "string", + pattern: "^[A-Za-z0-9\\-_]+$", + }, + value: { + description: "Message content.", + type: "object", + properties: {}, + }, + ratio: { + description: + "Ratio of users who should be grouped into this branch.", + type: "integer", + minimum: 1, + }, + groups: { + description: + "A list of experiment groups that can be used to exclude or select related experiments. May be empty.", + type: "array", + items: { + type: "string", + description: "Identifier of the group", + }, + }, + }, + }, + }, + }, + }, + + "preference-rollout": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Change preferences permanently", + type: "object", + required: ["slug", "preferences"], + properties: { + slug: { + description: + "Unique identifer for the rollout, used in telemetry and rollbacks", + type: "string", + pattern: "^[a-z0-9\\-_]+$", + }, + preferences: { + description: "The preferences to change, and their values", + type: "array", + minItems: 1, + items: { + type: "object", + required: ["preferenceName", "value"], + properties: { + preferenceName: { + description: "Full dotted-path of the preference being changed", + type: "string", + }, + value: { + description: "Value to set the preference to", + type: ["string", "integer", "boolean"], + }, + }, + }, + }, + }, + }, + + "preference-rollback": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Undo a preference rollout", + type: "object", + required: ["rolloutSlug"], + properties: { + rolloutSlug: { + description: "Unique identifer for the rollout to undo", + type: "string", + pattern: "^[a-z0-9\\-_]+$", + }, + }, + }, + + "addon-study": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Enroll a user in an opt-out SHIELD study", + type: "object", + required: [ + "name", + "description", + "addonUrl", + "extensionApiId", + "isEnrollmentPaused", + ], + properties: { + name: { + description: "User-facing name of the study", + type: "string", + minLength: 1, + }, + description: { + description: "User-facing description of the study", + type: "string", + minLength: 1, + }, + addonUrl: { + description: "URL of the add-on XPI file", + type: "string", + format: "uri", + minLength: 1, + }, + extensionApiId: { + description: + "The record ID of the extension used for Normandy API calls.", + type: "integer", + }, + isEnrollmentPaused: { + description: "If true, new users will not be enrolled in the study.", + type: "boolean", + default: true, + }, + }, + }, + + "addon-rollout": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Install add-on permanently", + type: "object", + required: ["extensionApiId", "slug"], + properties: { + extensionApiId: { + description: + "The record ID of the extension used for Normandy API calls.", + type: "integer", + }, + slug: { + description: + "Unique identifer for the rollout, used in telemetry and rollbacks.", + type: "string", + pattern: "^[a-z0-9\\-_]+$", + }, + }, + }, + + "addon-rollback": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Undo an add-on rollout", + type: "object", + required: ["rolloutSlug"], + properties: { + rolloutSlug: { + description: "Unique identifer for the rollout to undo.", + type: "string", + pattern: "^[a-z0-9\\-_]+$", + }, + }, + }, + + "branched-addon-study": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Enroll a user in an add-on experiment, with managed branches", + type: "object", + required: [ + "slug", + "userFacingName", + "userFacingDescription", + "branches", + "isEnrollmentPaused", + ], + properties: { + slug: { + description: "Machine-readable identifier", + type: "string", + minLength: 1, + }, + userFacingName: { + description: "User-facing name of the study", + type: "string", + minLength: 1, + }, + userFacingDescription: { + description: "User-facing description of the study", + type: "string", + minLength: 1, + }, + isEnrollmentPaused: { + description: "If true, new users will not be enrolled in the study.", + type: "boolean", + default: true, + }, + branches: { + description: "List of experimental branches", + type: "array", + minItems: 1, + items: { + type: "object", + required: ["slug", "ratio", "extensionApiId"], + properties: { + slug: { + description: + "Unique identifier for this branch of the experiment.", + type: "string", + pattern: "^[A-Za-z0-9\\-_]+$", + }, + ratio: { + description: + "Ratio of users who should be grouped into this branch.", + type: "integer", + minimum: 1, + }, + extensionApiId: { + description: + "The record ID of the add-on uploaded to the Normandy server. May be null, in which case no add-on will be installed.", + type: ["number", "null"], + default: null, + }, + }, + }, + }, + }, + }, + + "show-heartbeat": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Show a Heartbeat survey.", + description: "This action shows a single survey.", + + type: "object", + required: [ + "surveyId", + "message", + "thanksMessage", + "postAnswerUrl", + "learnMoreMessage", + "learnMoreUrl", + ], + properties: { + repeatOption: { + type: "string", + enum: ["once", "xdays", "nag"], + description: "Determines how often a prompt is shown executes.", + default: "once", + }, + repeatEvery: { + description: + "For repeatOption=xdays, how often (in days) the prompt is displayed.", + default: null, + type: ["number", "null"], + }, + includeTelemetryUUID: { + type: "boolean", + description: "Include unique user ID in post-answer-url and Telemetry", + default: false, + }, + surveyId: { + type: "string", + description: "Slug uniquely identifying this survey in telemetry", + }, + message: { + description: "Message to show to the user", + type: "string", + }, + engagementButtonLabel: { + description: + "Text for the engagement button. If specified, this button will be shown instead of rating stars.", + default: null, + type: ["string", "null"], + }, + thanksMessage: { + description: + "Thanks message to show to the user after they've rated Firefox", + type: "string", + }, + postAnswerUrl: { + description: + "URL to redirect the user to after rating Firefox or clicking the engagement button", + default: null, + type: ["string", "null"], + }, + learnMoreMessage: { + description: "Message to show to the user to learn more", + default: null, + type: ["string", "null"], + }, + learnMoreUrl: { + description: "URL to show to the user when they click Learn More", + default: null, + type: ["string", "null"], + }, + }, + }, + + "multi-preference-experiment": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Run a feature experiment activated by a set of preferences.", + type: "object", + required: [ + "slug", + "userFacingName", + "userFacingDescription", + "branches", + "isEnrollmentPaused", + ], + properties: { + slug: { + description: "Unique identifier for this experiment", + type: "string", + pattern: "^[A-Za-z0-9\\-_]+$", + }, + userFacingName: { + description: "User-facing name of the experiment", + type: "string", + minLength: 1, + }, + userFacingDescription: { + description: "User-facing description of the experiment", + type: "string", + minLength: 1, + }, + experimentDocumentUrl: { + description: "URL of a document describing the experiment", + type: "string", + format: "uri", + default: "", + }, + isHighPopulation: { + description: + "Marks the preference experiment as a high population experiment, that should be excluded from certain types of telemetry", + type: "boolean", + default: "false", + }, + isEnrollmentPaused: { + description: "If true, new users will not be enrolled in the study.", + type: "boolean", + default: true, + }, + branches: { + description: "List of experimental branches", + type: "array", + minItems: 1, + items: { + type: "object", + required: ["slug", "ratio", "preferences"], + properties: { + slug: { + description: + "Unique identifier for this branch of the experiment", + type: "string", + pattern: "^[A-Za-z0-9\\-_]+$", + }, + ratio: { + description: + "Ratio of users who should be grouped into this branch", + type: "integer", + minimum: 1, + }, + preferences: { + description: + "The set of preferences to be set if this branch is chosen", + type: "object", + patternProperties: { + ".*": { + type: "object", + properties: { + preferenceType: { + description: + "Data type of the preference that controls this experiment", + type: "string", + enum: ["string", "integer", "boolean"], + }, + preferenceBranchType: { + description: + "Controls whether the default or user value of the preference is modified", + type: "string", + enum: ["user", "default"], + default: "default", + }, + preferenceValue: { + description: + "Value for this preference when this branch is chosen", + type: ["string", "number", "boolean"], + }, + }, + required: [ + "preferenceType", + "preferenceBranchType", + "preferenceValue", + ], + }, + }, + }, + }, + }, + }, + }, + }, + + "single-preference-experiment": { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Run a feature experiment activated by a preference.", + type: "object", + required: [ + "slug", + "preferenceName", + "preferenceType", + "branches", + "isEnrollmentPaused", + ], + properties: { + slug: { + description: "Unique identifier for this experiment", + type: "string", + pattern: "^[A-Za-z0-9\\-_]+$", + }, + experimentDocumentUrl: { + description: "URL of a document describing the experiment", + type: "string", + format: "uri", + default: "", + }, + preferenceName: { + description: + "Full dotted-path of the preference that controls this experiment", + type: "string", + }, + preferenceType: { + description: + "Data type of the preference that controls this experiment", + type: "string", + enum: ["string", "integer", "boolean"], + }, + preferenceBranchType: { + description: + "Controls whether the default or user value of the preference is modified", + type: "string", + enum: ["user", "default"], + default: "default", + }, + isHighPopulation: { + description: + "Marks the preference experiment as a high population experiment, that should be excluded from certain types of telemetry", + type: "boolean", + default: "false", + }, + isEnrollmentPaused: { + description: "If true, new users will not be enrolled in the study.", + type: "boolean", + default: true, + }, + branches: { + description: "List of experimental branches", + type: "array", + minItems: 1, + items: { + type: "object", + required: ["slug", "value", "ratio"], + properties: { + slug: { + description: + "Unique identifier for this branch of the experiment", + type: "string", + pattern: "^[A-Za-z0-9\\-_]+$", + }, + value: { + description: "Value to set the preference to for this branch", + type: ["string", "number", "boolean"], + }, + ratio: { + description: + "Ratio of users who should be grouped into this branch", + type: "integer", + minimum: 1, + }, + }, + }, + }, + }, + }, +}; + +// Legacy name used on Normandy server +ActionSchemas["opt-out-study"] = ActionSchemas["addon-study"]; + +// If running in Node.js, export the schemas. +if (typeof module !== "undefined") { + /* globals module */ + module.exports = ActionSchemas; +} diff --git a/toolkit/components/normandy/actions/schemas/package.json b/toolkit/components/normandy/actions/schemas/package.json new file mode 100644 index 0000000000..f09031f88d --- /dev/null +++ b/toolkit/components/normandy/actions/schemas/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mozilla/normandy-action-argument-schemas", + "version": "0.11.0", + "description": "Schemas for Normandy action arguments", + "main": "index.js", + "author": "Michael Cooper <mcooper@mozilla.com>", + "license": "MPL-2.0", + "scripts": { + "prepack": "node export_json.js" + } +} diff --git a/toolkit/components/normandy/components.conf b/toolkit/components/normandy/components.conf new file mode 100644 index 0000000000..0c61d6d2f5 --- /dev/null +++ b/toolkit/components/normandy/components.conf @@ -0,0 +1,14 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{6ab96943-a163-482c-9622-4faedc0e827f}', + 'contract_ids': ['@mozilla.org/network/protocol/about;1?what=studies'], + 'esModule': 'resource://gre/modules/ShieldContentProcess.sys.mjs', + 'constructor': 'AboutStudies', + }, +] diff --git a/toolkit/components/normandy/content/AboutPages.sys.mjs b/toolkit/components/normandy/content/AboutPages.sys.mjs new file mode 100644 index 0000000000..fedf85c2e8 --- /dev/null +++ b/toolkit/components/normandy/content/AboutPages.sys.mjs @@ -0,0 +1,232 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", + RecipeRunner: "resource://normandy/lib/RecipeRunner.sys.mjs", + RemoteSettingsExperimentLoader: + "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs", +}); + +const SHIELD_LEARN_MORE_URL_PREF = "app.normandy.shieldLearnMoreUrl"; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gOptOutStudiesEnabled", + "app.shield.optoutstudies.enabled" +); + +/** + * Class for managing an about: page that Normandy provides. Adapted from + * browser/components/pocket/content/AboutPocket.sys.mjs. + * + * @implements nsIFactory + * @implements nsIAboutModule + */ +class AboutPage { + constructor({ chromeUrl, aboutHost, classID, description, uriFlags }) { + this.chromeUrl = chromeUrl; + this.aboutHost = aboutHost; + this.classID = Components.ID(classID); + this.description = description; + this.uriFlags = uriFlags; + } + + getURIFlags() { + return this.uriFlags; + } + + newChannel(uri, loadInfo) { + const newURI = Services.io.newURI(this.chromeUrl); + const channel = Services.io.newChannelFromURIWithLoadInfo(newURI, loadInfo); + channel.originalURI = uri; + + if (this.uriFlags & Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT) { + const principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + channel.owner = principal; + } + return channel; + } +} +AboutPage.prototype.QueryInterface = ChromeUtils.generateQI(["nsIAboutModule"]); + +/** + * The module exported by this file. + */ +export let AboutPages = {}; + +/** + * The weak set that keeps track of which browsing contexts + * have an about:studies page. + */ +let BrowsingContexts = new WeakSet(); +/** + * about:studies page for displaying in-progress and past Shield studies. + * @type {AboutPage} + * @implements {nsIMessageListener} + */ +XPCOMUtils.defineLazyGetter(AboutPages, "aboutStudies", () => { + const aboutStudies = new AboutPage({ + chromeUrl: "resource://normandy-content/about-studies/about-studies.html", + aboutHost: "studies", + classID: "{6ab96943-a163-482c-9622-4faedc0e827f}", + description: "Shield Study Listing", + uriFlags: + Ci.nsIAboutModule.ALLOW_SCRIPT | + Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT | + Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD | + Ci.nsIAboutModule.IS_SECURE_CHROME_UI, + }); + + // Extra methods for about:study-specific behavior. + Object.assign(aboutStudies, { + getAddonStudyList() { + return lazy.AddonStudies.getAll(); + }, + + getPreferenceStudyList() { + return lazy.PreferenceExperiments.getAll(); + }, + + getMessagingSystemList() { + return lazy.ExperimentManager.store.getAll(); + }, + + async optInToExperiment(data) { + try { + await lazy.RemoteSettingsExperimentLoader.optInToExperiment(data); + return { + error: false, + message: "Opt-in was successful.", + }; + } catch (error) { + return { + error: true, + message: error.message, + }; + } + }, + + /** Add a browsing context to the weak set; + * this weak set keeps track of all contexts + * that are housing an about:studies page. + */ + addToWeakSet(browsingContext) { + BrowsingContexts.add(browsingContext); + }, + /** Remove a browsing context to the weak set; + * this weak set keeps track of all contexts + * that are housing an about:studies page. + */ + removeFromWeakSet(browsingContext) { + BrowsingContexts.delete(browsingContext); + }, + + /** + * Sends a message to every about:studies page, + * by iterating over the BrowsingContexts weakset. + * @param {string} message The message string to send to. + * @param {object} data The data object to send. + */ + _sendToAll(message, data) { + ChromeUtils.nondeterministicGetWeakSetKeys(BrowsingContexts).forEach( + browser => + browser.currentWindowGlobal + .getActor("ShieldFrame") + .sendAsyncMessage(message, data) + ); + }, + + /** + * Get if studies are enabled. This has to be in the parent process, + * since RecipeRunner is stateful, and can't be interacted with from + * content processes safely. + */ + async getStudiesEnabled() { + await lazy.RecipeRunner.initializedPromise.promise; + return lazy.RecipeRunner.enabled && lazy.gOptOutStudiesEnabled; + }, + + /** + * Disable an active add-on study and remove its add-on. + * @param {String} recipeId the id of the addon to remove + * @param {String} reason the reason for removal + */ + async removeAddonStudy(recipeId, reason) { + try { + const action = new lazy.BranchedAddonStudyAction(); + await action.unenroll(recipeId, reason); + } catch (err) { + // If the exception was that the study was already removed, that's ok. + // If not, rethrow the error. + if (!err.toString().includes("already inactive")) { + throw err; + } + } finally { + // Update any open tabs with the new study list now that it has changed, + // even if the above failed. + this.getAddonStudyList().then(list => + this._sendToAll("Shield:UpdateAddonStudyList", list) + ); + } + }, + + /** + * Disable an active preference study. + * @param {String} experimentName the name of the experiment to remove + * @param {String} reason the reason for removal + */ + async removePreferenceStudy(experimentName, reason) { + try { + await lazy.PreferenceExperiments.stop(experimentName, { + reason, + caller: "AboutPages.removePreferenceStudy", + }); + } catch (err) { + // If the exception was that the study was already removed, that's ok. + // If not, rethrow the error. + if (!err.toString().includes("already expired")) { + throw err; + } + } finally { + // Update any open tabs with the new study list now that it has changed, + // even if the above failed. + this.getPreferenceStudyList().then(list => + this._sendToAll("Shield:UpdatePreferenceStudyList", list) + ); + } + }, + + async removeMessagingSystemExperiment(slug, reason) { + lazy.ExperimentManager.unenroll(slug, reason); + this._sendToAll( + "Shield:UpdateMessagingSystemExperimentList", + lazy.ExperimentManager.store.getAll() + ); + }, + + openDataPreferences() { + const browserWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + browserWindow.openPreferences("privacy-reports"); + }, + + getShieldLearnMoreHref() { + return Services.urlFormatter.formatURLPref(SHIELD_LEARN_MORE_URL_PREF); + }, + }); + + return aboutStudies; +}); diff --git a/toolkit/components/normandy/content/ShieldFrameChild.sys.mjs b/toolkit/components/normandy/content/ShieldFrameChild.sys.mjs new file mode 100644 index 0000000000..8ca4110f6d --- /dev/null +++ b/toolkit/components/normandy/content/ShieldFrameChild.sys.mjs @@ -0,0 +1,172 @@ +/* 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/. */ + +/** + * Listen for DOM events bubbling up from the about:studies page, and perform + * privileged actions in response to them. If we need to do anything that the + * content process can't handle (such as reading IndexedDB), we send a message + * to the parent process and handle it there. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPages: "resource://normandy-content/AboutPages.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "gBrandBundle", function () { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gStringBundle", function () { + return Services.strings.createBundle( + "chrome://global/locale/aboutStudies.properties" + ); +}); + +const NIMBUS_DEBUG_PREF = "nimbus.debug"; + +/** + * Listen for DOM events bubbling up from the about:studies page, and perform + * privileged actions in response to them. If we need to do anything that the + * content process can't handle (such as reading IndexedDB), we send a message + * to the parent process and handle it there. + */ +export class ShieldFrameChild extends JSWindowActorChild { + async handleEvent(event) { + // On page show or page hide, + // add this child to the WeakSet in AboutStudies. + switch (event.type) { + case "pageshow": + this.sendAsyncMessage("Shield:AddToWeakSet"); + return; + + case "pagehide": + this.sendAsyncMessage("Shield:RemoveFromWeakSet"); + return; + } + switch (event.detail.action) { + // Actions that require the parent process + case "GetRemoteValue:AddonStudyList": + let addonStudies = await this.sendQuery("Shield:GetAddonStudyList"); + this.triggerPageCallback( + "ReceiveRemoteValue:AddonStudyList", + addonStudies + ); + break; + case "GetRemoteValue:PreferenceStudyList": + let prefStudies = await this.sendQuery("Shield:GetPreferenceStudyList"); + this.triggerPageCallback( + "ReceiveRemoteValue:PreferenceStudyList", + prefStudies + ); + break; + case "GetRemoteValue:MessagingSystemList": + let experiments = await this.sendQuery("Shield:GetMessagingSystemList"); + this.triggerPageCallback( + "ReceiveRemoteValue:MessagingSystemList", + experiments + ); + break; + case "RemoveAddonStudy": + this.sendAsyncMessage("Shield:RemoveAddonStudy", event.detail.data); + break; + case "RemovePreferenceStudy": + this.sendAsyncMessage( + "Shield:RemovePreferenceStudy", + event.detail.data + ); + break; + case "RemoveMessagingSystemExperiment": + this.sendAsyncMessage( + "Shield:RemoveMessagingSystemExperiment", + event.detail.data + ); + break; + case "GetRemoteValue:StudiesEnabled": + let studiesEnabled = await this.sendQuery("Shield:GetStudiesEnabled"); + this.triggerPageCallback( + "ReceiveRemoteValue:StudiesEnabled", + studiesEnabled + ); + break; + case "GetRemoteValue:DebugModeOn": + this.triggerPageCallback( + "ReceiveRemoteValue:DebugModeOn", + Services.prefs.getBoolPref(NIMBUS_DEBUG_PREF) + ); + break; + case "NavigateToDataPreferences": + this.sendAsyncMessage("Shield:OpenDataPreferences"); + break; + // Actions that can be performed in the content process + case "GetRemoteValue:ShieldLearnMoreHref": + this.triggerPageCallback( + "ReceiveRemoteValue:ShieldLearnMoreHref", + lazy.AboutPages.aboutStudies.getShieldLearnMoreHref() + ); + break; + case "GetRemoteValue:ShieldTranslations": + const strings = {}; + for (let str of lazy.gStringBundle.getSimpleEnumeration()) { + strings[str.key] = str.value; + } + const brandName = lazy.gBrandBundle.GetStringFromName("brandShortName"); + strings.enabledList = lazy.gStringBundle.formatStringFromName( + "enabledList", + [brandName] + ); + + this.triggerPageCallback( + "ReceiveRemoteValue:ShieldTranslations", + strings + ); + break; + case "ExperimentOptIn": + const message = await this.sendQuery( + "Shield:ExperimentOptIn", + event.detail.data + ); + this.triggerPageCallback("ReceiveRemoteValue:OptInMessage", message); + break; + } + } + + receiveMessage(msg) { + switch (msg.name) { + case "Shield:UpdateAddonStudyList": + this.triggerPageCallback("ReceiveRemoteValue:AddonStudyList", msg.data); + break; + case "Shield:UpdatePreferenceStudyList": + this.triggerPageCallback( + "ReceiveRemoteValue:PreferenceStudyList", + msg.data + ); + break; + case "Shield:UpdateMessagingSystemExperimentList": + this.triggerPageCallback( + "ReceiveRemoteValue:MessagingSystemList", + msg.data + ); + break; + } + } + /** + * Trigger an event to communicate with the unprivileged about:studies page. + * @param {String} type The type of event to trigger. + * @param {Object} detail The data to pass along to the event. + */ + triggerPageCallback(type, detail) { + // Clone details and use the event class from the unprivileged context. + const event = new this.document.defaultView.CustomEvent(type, { + bubbles: true, + detail: Cu.cloneInto(detail, this.document.defaultView), + }); + this.document.dispatchEvent(event); + } +} diff --git a/toolkit/components/normandy/content/ShieldFrameParent.sys.mjs b/toolkit/components/normandy/content/ShieldFrameParent.sys.mjs new file mode 100644 index 0000000000..73aa620474 --- /dev/null +++ b/toolkit/components/normandy/content/ShieldFrameParent.sys.mjs @@ -0,0 +1,53 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPages: "resource://normandy-content/AboutPages.sys.mjs", +}); + +export class ShieldFrameParent extends JSWindowActorParent { + async receiveMessage(msg) { + let { aboutStudies } = lazy.AboutPages; + switch (msg.name) { + case "Shield:AddToWeakSet": + aboutStudies.addToWeakSet(this.browsingContext); + break; + case "Shield:RemoveFromWeakSet": + aboutStudies.removeFromWeakSet(this.browsingContext); + break; + case "Shield:GetAddonStudyList": + return aboutStudies.getAddonStudyList(); + case "Shield:GetPreferenceStudyList": + return aboutStudies.getPreferenceStudyList(); + case "Shield:GetMessagingSystemList": + return aboutStudies.getMessagingSystemList(); + case "Shield:RemoveAddonStudy": + aboutStudies.removeAddonStudy(msg.data.recipeId, msg.data.reason); + break; + case "Shield:RemovePreferenceStudy": + aboutStudies.removePreferenceStudy( + msg.data.experimentName, + msg.data.reason + ); + break; + case "Shield:RemoveMessagingSystemExperiment": + aboutStudies.removeMessagingSystemExperiment( + msg.data.slug, + msg.data.reason + ); + break; + case "Shield:OpenDataPreferences": + aboutStudies.openDataPreferences(); + break; + case "Shield:GetStudiesEnabled": + return aboutStudies.getStudiesEnabled(); + case "Shield:ExperimentOptIn": + return aboutStudies.optInToExperiment(msg.data); + } + + return null; + } +} diff --git a/toolkit/components/normandy/content/about-studies/about-studies.css b/toolkit/components/normandy/content/about-studies/about-studies.css new file mode 100644 index 0000000000..2c072a7a1e --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.css @@ -0,0 +1,176 @@ +/* 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/. */ + +:root { + --icon-background-color-1: #0A84FF; + --icon-background-color-2: #008EA4; + --icon-background-color-3: #ED00B5; + --icon-background-color-4: #058B00; + --icon-background-color-5: #A47F00; + --icon-background-color-6: #FF0039; + --icon-background-disabled-color: #737373; + --body-text-disabled-color: #737373; + --study-status-active-color: #058B00; + --study-status-disabled-color: #737373; +} + +html, +body, +#app { + height: 100%; + width: 100%; +} + +button > .button-box { + padding-inline: 10px; +} + +.about-studies-container { + max-width: 960px; + margin: 0 auto; +} + +.info-box { + margin-bottom: 10px; + text-align: center; +} + +.info-box-content { + align-items: center; + background: var(--in-content-box-info-background); + border: 1px solid var(--in-content-border-color); + display: inline-flex; + padding: 10px 15px; +} + +.info-box-content > * { + margin-right: 10px; +} + +.info-box-content > *:last-child { + margin-right: 0; +} + +.study-list { + list-style-type: none; + margin: 0; + padding: 0; +} + +.study { + align-items: center; + border-bottom: 1px solid var(--in-content-border-color); + display: flex; + flex-direction: row; + padding: 10px; +} + +.study.disabled { + color: var(--body-text-disabled-color); +} + +.study .study-status { + color: var(--study-status-active-color); + font-weight: bold; +} + +.study.disabled .study-status { + color: var(--study-status-disabled-color); +} + +.study:last-child { + border-bottom: none; +} + +.study > * { + margin-right: 15px; +} + +.study > *:last-child { + margin-right: 0; +} + +.study-icon { + color: #FFF; + flex: 0 0 40px; + font-size: 26px; + height: 40px; + line-height: 40px; + text-align: center; + text-transform: capitalize; +} + +.study:nth-child(6n+0) .study-icon { + background: var(--icon-background-color-1); +} + +.study:nth-child(6n+1) .study-icon { + background: var(--icon-background-color-2); +} + +.study:nth-child(6n+2) .study-icon { + background: var(--icon-background-color-3); +} + +.study:nth-child(6n+3) .study-icon { + background: var(--icon-background-color-4); +} + +.study:nth-child(6n+4) .study-icon { + background: var(--icon-background-color-5); +} + +.study:nth-child(6n+5) .study-icon { + background: var(--icon-background-color-6); +} + +.study.disabled .study-icon { + background: var(--icon-background-disabled-color); +} + +.study-details { + flex: 1; + overflow: hidden; +} + +.study-name { + font-weight: bold; +} + +.study-header { + margin-bottom: .3em; +} + +.study-header > * { + margin-right: 5px; +} + +.study-header > *:last-child { + margin-right: 0; +} + +.study-description code { + background-color: rgb(128, 128, 128, 0.1); + border-radius: 3px; + box-sizing: border-box; + color: var(--in-content-text-color); + font-size: 85%; + font-family: 'Fira Mono', 'mono', monospace; + padding: .05em .4em; +} + +.study-actions { + flex: 0 0; +} + +.opt-in-box { + border-radius: 3px; + padding: 10px; + color: var(--study-status-active-color); + border: 1px solid; +} + +.opt-in-box.opt-in-error { + color: var(--in-content-error-text-color); +} diff --git a/toolkit/components/normandy/content/about-studies/about-studies.html b/toolkit/components/normandy/content/about-studies/about-studies.html new file mode 100644 index 0000000000..e6f1347227 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.html @@ -0,0 +1,29 @@ +<!-- 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/. --> +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="color-scheme" content="light dark" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; script-src resource:; style-src resource: chrome:; object-src 'none'" + /> + <title>about:studies</title> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="resource://normandy-content/about-studies/about-studies.css" + /> + </head> + <body> + <div id="app"></div> + <script src="resource://normandy-vendor/React.js"></script> + <script src="resource://normandy-vendor/ReactDOM.js"></script> + <script src="resource://normandy-vendor/PropTypes.js"></script> + <script src="resource://normandy-vendor/classnames.js"></script> + <script src="resource://normandy-content/about-studies/about-studies.js"></script> + </body> +</html> diff --git a/toolkit/components/normandy/content/about-studies/about-studies.js b/toolkit/components/normandy/content/about-studies/about-studies.js new file mode 100644 index 0000000000..c5a2319ac2 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.js @@ -0,0 +1,562 @@ +/* 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"; +/* global classnames PropTypes React ReactDOM */ + +/** + * Shorthand for creating elements (to avoid using a JSX preprocessor) + */ +const r = React.createElement; + +/** + * Dispatches a page event to the privileged frame script for this tab. + * @param {String} action + * @param {Object} data + */ +function sendPageEvent(action, data) { + const event = new CustomEvent("ShieldPageEvent", { + bubbles: true, + detail: { action, data }, + }); + document.dispatchEvent(event); +} + +function readOptinParams() { + let searchParams = new URLSearchParams(new URL(location).search); + return { + slug: searchParams.get("optin_slug"), + branch: searchParams.get("optin_branch"), + collection: searchParams.get("optin_collection"), + }; +} + +/** + * Handle basic layout and routing within about:studies. + */ +class AboutStudies extends React.Component { + constructor(props) { + super(props); + + this.remoteValueNameMap = { + AddonStudyList: "addonStudies", + PreferenceStudyList: "prefStudies", + MessagingSystemList: "experiments", + ShieldLearnMoreHref: "learnMoreHref", + StudiesEnabled: "studiesEnabled", + ShieldTranslations: "translations", + DebugModeOn: "debugMode", + }; + + this.state = {}; + for (const stateName of Object.values(this.remoteValueNameMap)) { + this.state[stateName] = null; + } + this.state.optInMessage = false; + } + + initializeData() { + for (const remoteName of Object.keys(this.remoteValueNameMap)) { + document.addEventListener(`ReceiveRemoteValue:${remoteName}`, this); + sendPageEvent(`GetRemoteValue:${remoteName}`); + } + } + + componentWillMount() { + let optinParams = readOptinParams(); + if (optinParams.branch && optinParams.slug) { + const onOptIn = ({ detail: value }) => { + this.setState({ optInMessage: value }); + this.initializeData(); + document.removeEventListener( + `ReceiveRemoteValue:OptInMessage`, + onOptIn + ); + }; + document.addEventListener(`ReceiveRemoteValue:OptInMessage`, onOptIn); + sendPageEvent(`ExperimentOptIn`, optinParams); + } else { + this.initializeData(); + } + } + + componentWillUnmount() { + for (const remoteName of Object.keys(this.remoteValueNameMap)) { + document.removeEventListener(`ReceiveRemoteValue:${remoteName}`, this); + } + } + + /** Event handle to receive remote values from documentAddEventListener */ + handleEvent({ type, detail: value }) { + const prefix = "ReceiveRemoteValue:"; + if (type.startsWith(prefix)) { + const name = type.substring(prefix.length); + this.setState({ [this.remoteValueNameMap[name]]: value }); + } + } + + render() { + const { + translations, + learnMoreHref, + studiesEnabled, + addonStudies, + prefStudies, + experiments, + optInMessage, + debugMode, + } = this.state; + // Wait for all values to be loaded before rendering. Some of the values may + // be falsey, so an explicit null check is needed. + if (Object.values(this.state).some(v => v === null)) { + return null; + } + + return r( + "div", + { className: "about-studies-container main-content" }, + r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }), + optInMessage && r(OptInBox, optInMessage), + r(StudyList, { + translations, + addonStudies, + prefStudies, + experiments, + debugMode, + }) + ); + } +} + +/** + * Explains the contents of the page, and offers a way to learn more and update preferences. + */ +class WhatsThisBox extends React.Component { + handleUpdateClick() { + sendPageEvent("NavigateToDataPreferences"); + } + + render() { + const { learnMoreHref, studiesEnabled, translations } = this.props; + + return r( + "div", + { className: "info-box" }, + r( + "div", + { className: "info-box-content" }, + r( + "span", + {}, + studiesEnabled ? translations.enabledList : translations.disabledList + ), + r( + "a", + { id: "shield-studies-learn-more", href: learnMoreHref }, + translations.learnMore + ), + + r( + "button", + { + id: "shield-studies-update-preferences", + onClick: this.handleUpdateClick, + }, + r( + "div", + { className: "button-box" }, + navigator.platform.includes("Win") + ? translations.updateButtonWin + : translations.updateButtonUnix + ) + ) + ) + ); + } +} +/**OptInMessage + * Explains the contents of the page, and offers a way to learn more and update preferences. + */ +function OptInBox({ error, message }) { + return r( + "div", + { className: "opt-in-box" + (error ? " opt-in-error" : "") }, + message + ); +} + +/** + * Shows a list of studies, with an option to end in-progress ones. + */ +class StudyList extends React.Component { + render() { + const { addonStudies, prefStudies, translations, experiments, debugMode } = + this.props; + + if (!addonStudies.length && !prefStudies.length && !experiments.length) { + return r("p", { className: "study-list-info" }, translations.noStudies); + } + + const activeStudies = []; + const inactiveStudies = []; + + // Since we are modifying the study objects, it is polite to make copies + for (const study of addonStudies) { + const clonedStudy = Object.assign({}, study, { + type: "addon", + sortDate: study.studyStartDate, + }); + if (study.active) { + activeStudies.push(clonedStudy); + } else { + inactiveStudies.push(clonedStudy); + } + } + + for (const study of prefStudies) { + const clonedStudy = Object.assign({}, study, { + type: "pref", + sortDate: new Date(study.lastSeen), + }); + if (study.expired) { + inactiveStudies.push(clonedStudy); + } else { + activeStudies.push(clonedStudy); + } + } + + for (const study of experiments) { + const clonedStudy = Object.assign({}, study, { + type: study.experimentType, + sortDate: new Date(study.lastSeen), + }); + if (!study.active && !study.isRollout) { + inactiveStudies.push(clonedStudy); + } else { + activeStudies.push(clonedStudy); + } + } + + activeStudies.sort((a, b) => b.sortDate - a.sortDate); + inactiveStudies.sort((a, b) => b.sortDate - a.sortDate); + return r( + "div", + {}, + r("h2", {}, translations.activeStudiesList), + r( + "ul", + { className: "study-list active-study-list" }, + activeStudies.map(study => { + if (study.type === "addon") { + return r(AddonStudyListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "nimbus" || study.type === "rollout") { + return r(MessagingSystemListItem, { + key: study.slug, + study, + translations, + debugMode, + }); + } + if (study.type === "pref") { + return r(PreferenceStudyListItem, { + key: study.slug, + study, + translations, + }); + } + return null; + }) + ), + r("h2", {}, translations.completedStudiesList), + r( + "ul", + { className: "study-list inactive-study-list" }, + inactiveStudies.map(study => { + if (study.type === "addon") { + return r(AddonStudyListItem, { + key: study.slug, + study, + translations, + }); + } + if ( + study.type === "nimbus" || + study.type === "messaging_experiment" + ) { + return r(MessagingSystemListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "pref") { + return r(PreferenceStudyListItem, { + key: study.slug, + study, + translations, + }); + } + return null; + }) + ) + ); + } +} +StudyList.propTypes = { + addonStudies: PropTypes.array.isRequired, + translations: PropTypes.object.isRequired, +}; + +class MessagingSystemListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemoveMessagingSystemExperiment", { + slug: this.props.study.slug, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations, debugMode } = this.props; + const userFacingName = study.userFacingName || study.slug; + const userFacingDescription = + study.userFacingDescription || "Nimbus experiment."; + if (study.isRollout && !debugMode) { + return null; + } + return r( + "li", + { + className: classnames("study nimbus", { + disabled: !study.active, + }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r("div", { className: "study-icon" }, userFacingName.slice(0, 1)), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r("span", { className: "study-name" }, userFacingName), + r("span", {}, "\u2022"), // • + r( + "span", + { className: "study-status" }, + study.active + ? translations.activeStatus + : translations.completeStatus + ) + ), + r("div", { className: "study-description" }, userFacingDescription) + ), + r( + "div", + { className: "study-actions" }, + study.active && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} + +/** + * Details about an individual add-on study, with an option to end it if it is active. + */ +class AddonStudyListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemoveAddonStudy", { + recipeId: this.props.study.recipeId, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + return r( + "li", + { + className: classnames("study addon-study", { disabled: !study.active }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r( + "div", + { className: "study-icon" }, + study.userFacingName + .replace(/-?add-?on-?/i, "") + .replace(/-?study-?/i, "") + .slice(0, 1) + ), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r("span", { className: "study-name" }, study.userFacingName), + r("span", {}, "\u2022"), // • + r( + "span", + { className: "study-status" }, + study.active + ? translations.activeStatus + : translations.completeStatus + ) + ), + r( + "div", + { className: "study-description" }, + study.userFacingDescription + ) + ), + r( + "div", + { className: "study-actions" }, + study.active && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} +AddonStudyListItem.propTypes = { + study: PropTypes.shape({ + recipeId: PropTypes.number.isRequired, + slug: PropTypes.string.isRequired, + userFacingName: PropTypes.string.isRequired, + active: PropTypes.bool.isRequired, + userFacingDescription: PropTypes.string.isRequired, + }).isRequired, + translations: PropTypes.object.isRequired, +}; + +/** + * Details about an individual preference study, with an option to end it if it is active. + */ +class PreferenceStudyListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemovePreferenceStudy", { + experimentName: this.props.study.slug, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + + let iconLetter = (study.userFacingName || study.slug) + .replace(/-?pref-?(flip|study)-?/, "") + .replace(/-?study-?/, "") + .slice(0, 1) + .toUpperCase(); + + let description = study.userFacingDescription; + if (!description) { + // Assume there is exactly one preference (old-style preference experiment). + const [preferenceName, { preferenceValue }] = Object.entries( + study.preferences + )[0]; + // Sanitize the values by setting them as the text content of an element, + // and then getting the HTML representation of that text. This will have the + // browser safely sanitize them. Use outerHTML to also include the <code> + // element in the string. + const sanitizer = document.createElement("code"); + sanitizer.textContent = preferenceName; + const sanitizedPreferenceName = sanitizer.outerHTML; + sanitizer.textContent = preferenceValue; + const sanitizedPreferenceValue = sanitizer.outerHTML; + description = translations.preferenceStudyDescription + .replace(/%(?:1\$)?S/, sanitizedPreferenceName) + .replace(/%(?:2\$)?S/, sanitizedPreferenceValue); + } + + return r( + "li", + { + className: classnames("study pref-study", { disabled: study.expired }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r("div", { className: "study-icon" }, iconLetter), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r( + "span", + { className: "study-name" }, + study.userFacingName || study.slug + ), + r("span", {}, "\u2022"), // • + r( + "span", + { className: "study-status" }, + study.expired + ? translations.completeStatus + : translations.activeStatus + ) + ), + r("div", { + className: "study-description", + dangerouslySetInnerHTML: { __html: description }, + }) + ), + r( + "div", + { className: "study-actions" }, + !study.expired && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} +PreferenceStudyListItem.propTypes = { + study: PropTypes.shape({ + slug: PropTypes.string.isRequired, + userFacingName: PropTypes.string, + userFacingDescription: PropTypes.string, + expired: PropTypes.bool.isRequired, + preferenceName: PropTypes.string.isRequired, + preferenceValue: PropTypes.oneOf( + PropTypes.string, + PropTypes.bool, + PropTypes.number + ).isRequired, + }).isRequired, + translations: PropTypes.object.isRequired, +}; + +ReactDOM.render(r(AboutStudies), document.getElementById("app")); diff --git a/toolkit/components/normandy/docs/data-collection.rst b/toolkit/components/normandy/docs/data-collection.rst new file mode 100644 index 0000000000..a2a2afb5a9 --- /dev/null +++ b/toolkit/components/normandy/docs/data-collection.rst @@ -0,0 +1,447 @@ +Data Collection +=============== +This document describes the types of data that Normandy collects. + +Uptake +------ +Normandy monitors the execution of recipes and reports to +:ref:`telemetry` the amount of successful and failed runs. This data +is reported using :ref:`telemetry/collection/uptake` under the +``normandy`` namespace. + +Runner Status +^^^^^^^^^^^^^ +Once per-fetch and execution of recipes, one of the following statuses is +reported under the key ``normandy/runner``: + +.. data:: RUNNER_SUCCESS + + :Telemetry value: success + + The operation completed successfully. Individual failures with actions and + recipes may have been reported separately. + +.. data:: RUNNER_SERVER_ERROR + + :Telemetry value: server_error + + The data returned by the server when fetching the recipe is invalid in some + way. + +Action Status +^^^^^^^^^^^^^ +For each action available from the Normandy service, one of the +following statuses is reported under the key +``normandy/action/<action name>``: + +.. data:: ACTION_NETWORK_ERROR + + :Telemetry value: network_error + + There was a network-related error while fetching actions + +.. data:: ACTION_PRE_EXECUTION_ERROR + + :Telemetry value: custom_1_error + + There was an error while running the pre-execution hook for the action. + +.. data:: ACTION_POST_EXECUTION_ERROR + + :Telemetry value: custom_2_error + + There was an error while running the post-execution hook for the action. + +.. data:: ACTION_SERVER_ERROR + + :Telemetry value: server_error + + The data returned by the server when fetching the action is invalid in some + way. + +.. data:: ACTION_SUCCESS + + :Telemetry value: success + + The operation completed successfully. Individual failures with recipes may + be reported separately. + +Recipe Status +^^^^^^^^^^^^^ +For each recipe that is fetched and executed, one of the following statuses is +reported under the key ``normandy/recipe/<recipe id>``: + +.. data:: RECIPE_ACTION_DISABLED + + :Telemetry value: custom_1_error + + The action for this recipe failed in some way and was disabled, so the recipe + could not be executed. + +.. data:: RECIPE_DIDNT_MATCH_FILTER + + :Telemetry value: backoff + + The recipe included a Jexl filter that the client did not match, so + the recipe was not executed. + +.. data:: RECIPE_EXECUTION_ERROR + + :Telemetry value: apply_error + + An error occurred while executing the recipe. + +.. data:: RECIPE_FILTER_BROKEN + + :Telemetry value: content_error + + An error occurred while evaluating the Jexl filter for this + recipe. Sometimes this represents a bug in the Jexl filter + parser/evaluator, such as in `bug 1477156 + <https://bugzilla.mozilla.org/show_bug.cgi?id=1477156>`_, or it can + represent an error fetching some data that a Jexl recipe needs such + as `bug 1447804 + <https://bugzilla.mozilla.org/show_bug.cgi?id=1447804>`_. + +.. data:: RECIPE_INVALID_ACTION + + :Telemetry value: download_error + + The action specified by the recipe was invalid and it could not be executed. + +.. data:: RECIPE_SUCCESS + + :Telemetry value: success + + The recipe was executed successfully. + +.. data:: RECIPE_SIGNATURE_INVALID + + :Telemetry value: signature_error + + Normandy failed to verify the signature of the recipe. + + +Additionally, Normandy reports a :ref:`keyed scalar <Scalars>` to measure recipe +freshness. This scalar is called ``normandy.recipe_freshness``, and it +corresponds to the ``last_modified`` date of each recipe (using its ID +as the key), reported as seconds since 1970 in UTC. + + +Enrollment +----------- +Normandy records enrollment and unenrollment of users into studies, and +records that data using :ref:`Telemetry Events <eventtelemetry>`. All data is stored in the +``normandy`` category. + + +Enrollment IDs +^^^^^^^^^^^^^^ + +Most Normandy telemetry carries an *enrollment ID*. These IDs are generated +when Normandy enrolls the client in a change, be it a study, rollout, or +something else. These enrollment IDs are used for the lifetime of that +change, and are only used for that change (not shared between similar +changes). Once a change ends (either via unenrollment, graduation, or another +method) the enrollment ID should not be used again. + +When Telemetry upload is disabled, we must clear these enrollment IDs. This +is done by replacing existing enrollment IDs with a filler value. New changes +continue to receive a enrollment IDs as normal. The only thing that +enrollment IDs are used for Telemetry, and so generated them while Telemetry +is disabled is fine. They don't correlate to anything else, and won't be sent +anywhere. + +Preference Studies +^^^^^^^^^^^^^^^^^^ +Enrollment + method + The string ``"enroll"`` + object + The string ``"preference_study"`` + value + The name of the study (``recipe.arguments.slug``). + extra + branch + The name of the branch the user was assigned to (example: + ``"control"`` or ``"experiment"``). + experimentType + The type of preference experiment. Currently this can take + values "exp" and "exp-highpop", the latter being for + experiments targeting large numbers of users. + enrollmentId + A UUID that is unique to this users enrollment in this study. It + will be included in all future telemetry for this user in this + study. + +Enrollment Failed + method + The string ``"enrollFailed"`` + object + The string ``"preference_study"`` + value + The slug of the study (``recipe.arguments.slug``) + extra + reason + The reason for unenrollment. Possible values are: + + * ``"invalid-branch"``: The recipe specifies an invalid preference + branch (not to be confused with the experiment branch). Valid values + are "default" and "user". + preferenceBranch + If the reason was ``"invalid-branch"``, the branch that was + specified, truncated to 80 characters. + +Unenrollment + method + The string ``"unenroll"``. + object + The string ``"preference_study"``. + value + The name of the study (``recipe.arguments.slug``). + extra + didResetValue + The string ``"true"`` if the preference was set back to its + original value, ``"false"`` if it was left as its current + value. This can happen when, for example, the user changes a + preference that was involved in a user-branch study. + reason + The reason for unenrollment. Possible values are: + + * ``"recipe-not-seen"``: The recipe was no longer + applicable to this client This can be because the recipe + was disabled, or the user no longer matches the recipe's + filter. + * ``"unknown"``: A reason was not specified. This should be + considered a bug. + enrollmentId + The ID that was generated at enrollment. + +Unenroll Failed + method + The string ``"unenrollFailed"``. + object + The string ``"preference_study"``. + value + The name of the study (``recipe.arguments.slug``) + extra + enrollmentId + The ID that was generated at enrollment. + reason + A code describing the reason the unenroll failed. Possible values are: + + * ``"does-not-exist"``: The system attempted to unenroll a study that + does not exist. This is a bug. + * ``"already-unenrolled"``: The system attempted to unenroll a study + that has already been unenrolled. This is likely a bug. + caller + On Nightly builds only, a string identifying the source of the requested stop. + originalReason + The code that would had been used for the unenrollment, had it not failed. + +Experimental Preference Changed + method + The string ``"expPrefChanged"`` + object + The string ``"preference_study"``. + value + The name of the study (``recipe.arguments.slug``) + extra + enrollmentId + The ID that was generated at enrollment. + preferenceName + The name of the preference that changed. Note that the value of the + preference (old or new) is not given. + reason + A code describing the reason that Normandy detected the preference + change. Possible values are: + + * ``"atEnroll"``: The preferences already had user value when the + experiment started. + * ``"sideload"``: A preference was changed while Normandy's observers + weren't active, likely while the browser was shut down. + * ``"observer"``: The preference was observed to change by Normandy at + runtime. + +Preference Rollouts +^^^^^^^^^^^^^^^^^^^ +Enrollment + Sent when a user first enrolls in a rollout. + + method + The string ``"enroll"`` + object + The string ``"preference_rollout"`` + value + The slug of the rollout (``recipe.arguments.slug``) + extra + enrollmentId + A UUID that is unique to this user's enrollment in this rollout. It + will be included in all future telemetry for this user in this + rollout. + +Enroll Failed + Sent when a user attempts to enroll in a rollout, but the enrollment process fails. + + method + The string ``"enrollFailed"`` + object + The string ``"preference_rollout"`` + value + The slug of the rollout (``recipe.arguments.slug``) + extra + reason + A code describing the reason the unenroll failed. Possible values are: + + * ``"invalid type"``: The preferences specified in the rollout do not + match the preferences built in to the browser. The represents a + misconfiguration of the preferences in the recipe on the server. + * ``"would-be-no-op"``: All of the preference specified in the rollout + already have the given values. This represents an error in targeting + on the server. + * ``"conflict"``: At least one of the preferences specified in the + rollout is already managed by another active rollout. + preference + For ``reason="invalid type"``, the first preference that was invalid. + For ``reason="conflict"``, the first preference that is conflicting. + +Update + Sent when the preferences specified in the recipe have changed, and the + client updates the preferences of the browser to match. + + method + The string ``"update"`` + object + The string ``"preference_rollout"`` + value + The slug of the rollout (``recipe.arguments.slug``) + extra + previousState + The state the rollout was in before this update (such as ``"active"`` or ``"graduated"``). + enrollmentId + The ID that was generated at enrollment. + +Graduation + Sent when Normandy determines that further intervention is no longer + needed for this rollout. After this point, Normandy will stop making + changes to the browser for this rollout, unless the rollout recipe changes + to specify preferences different than the built-in. + + method + The string ``"graduate"`` + object + The string ``"preference_rollout"`` + value + The slug of the rollout (``recipe.arguments.slug``) + extra + reason + A code describing the reason for the graduation. Possible values are: + + * ``"all-prefs-match"``: All preferences specified in the rollout now + have built-in values that match the rollouts values. + ``"in-graduation-set"``: The browser has changed versions (usually + updated) to one that specifies this rollout no longer applies and + should be graduated regardless of the built-in preference values. + This behavior is controlled by the constant + ``PreferenceRollouts.GRADUATION_SET``. + enrollmentId + The ID that was generated at enrollment. + +Add-on Studies +^^^^^^^^^^^^^^ +Enrollment + method + The string ``"enroll"`` + object + The string ``"addon_study"`` + value + The name of the study (``recipe.arguments.name``). + extra + addonId + The add-on's ID (example: ``"feature-study@shield.mozilla.com"``). + addonVersion + The add-on's version (example: ``"1.2.3"``). + enrollmentId + A UUID that is unique to this users enrollment in this study. It + will be included in all future telemetry for this user in this + study. + +Enroll Failure + method + The string ``"enrollFailed"`` + object + The string ``"addon_study"`` + value + The name of the study (``recipe.arguments.name``). + reason + A string containing the filename and line number of the code + that failed, and the name of the error thrown. This information + is purposely limited to avoid leaking personally identifiable + information. This should be considered a bug. + +Update + method + The string ``"update"``, + object + The string ``"addon_study"``, + value + The name of the study (``recipe.arguments.name``). + extra + addonId + The add-on's ID (example: ``"feature-study@shield.mozilla.com"``). + addonVersion + The add-on's version (example: ``"1.2.3"``). + enrollmentId + The ID that was generated at enrollment. + +Update Failure + method + The string ``"updateFailed"`` + object + The string ``"addon_study"`` + value + The name of the study (``recipe.arguments.name``). + extra + reason + A string containing the filename and line number of the code + that failed, and the name of the error thrown. This information + is purposely limited to avoid leaking personally identifiable + information. This should be considered a bug. + enrollmentId + The ID that was generated at enrollment. + +Unenrollment + method + The string ``"unenroll"``. + object + The string ``"addon_study"``. + value + The name of the study (``recipe.arguments.name``). + extra + addonId + The add-on's ID (example: ``"feature-study@shield.mozilla.com"``). + addonVersion + The add-on's version (example: ``"1.2.3"``). + reason + The reason for unenrollment. Possible values are: + + * ``"install-failure"``: The add-on failed to install. + * ``"individual-opt-out"``: The user opted-out of this + particular study. + * ``"general-opt-out"``: The user opted-out of studies in + general. + * ``"recipe-not-seen"``: The recipe was no longer applicable + to this client. This can be because the recipe was + disabled, or the user no longer matches the recipe's + filter. + * ``"uninstalled"``: The study's add-on as uninstalled by some + mechanism. For example, this could be a user action or the + add-on self-uninstalling. + * ``"uninstalled-sideload"``: The study's add-on was + uninstalled while Normandy was inactive. This could be that + the add-on is no longer compatible, or was manually removed + from a profile. + * ``"unknown"``: A reason was not specified. This should be + considered a bug. + enrollmentId + The ID that was generated at enrollment. diff --git a/toolkit/components/normandy/docs/execution-model.rst b/toolkit/components/normandy/docs/execution-model.rst new file mode 100644 index 0000000000..6d5b16870c --- /dev/null +++ b/toolkit/components/normandy/docs/execution-model.rst @@ -0,0 +1,95 @@ +Execution Model +=============== +This document describes the execution model of the Normandy Client. + +The basic unit of instruction from the server is a *recipe*, which contains +instructions for filtering, and arguments for a given action. See below for +details. + +One iteration through all of these steps is called a *Normandy session*. This +happens at least once every 6 hours, and possibly more often if Remote +Settings syncs new changes. + +1. Fetching +----------- +A list of all active recipes is retrieved from Remote Settings, which has +likely been syncing them in the background. + +2. Suitability +-------------- + +Once recipes have been retrieved, they go through several checks to determine +their suitability for this client. Recipes contain information about which +clients should execute the recipe. All recipes are processed by all clients, +and all filtering happens in the client. + +For more information, see `the suitabilities docs <./suitabilities.html>`_. + +Signature +~~~~~~~~~ + +First, recipes are validated using a signature generated by Autograph_ that +is included with the recipe. This signature validates both the contents of +the recipe as well as its source. + +This signature is separate and distinct from the signing that happens on the +Remote Settings collection. This provides additional assurance that this +recipe is legitimate and intended to run on this client. + +.. _Autograph: https://github.com/mozilla-services/autograph + +Capabilities +~~~~~~~~~~~~ +Next a recipe is checked for compatibility using *capabilities*. +Capabilities are simple strings, such as ``"action:show-heartbeat"``. A +recipe contains a list of required capabilities, and the Normandy Client has +a list of capabilities that it supports. If any of the capabilities required +by the recipe are not compatible with the client, then the recipe does not +execute. + +Capabilities are used to avoid running recipes on a client that are so +incompatible as to be harmful. For example, some changes to filter expression +handling cannot be detected by filter expressions, and so older clients that +receive filters using these new features would break. + +.. note:: + + Capabilities were first introduced in Firefox 70. Clients prior to this + do not check capabilities, and run all recipes provided. To accommodate + this, the server splits recipes into two Remote Settings collections, + ``normandy-recipes``, and ``normandy-recipes-capabilities``. Clients + prior to Firefox 70 use the former, whereas Firefox 70 and above use the + latter. Recipes that only require "baseline" capabilities are published + to both, and those that require advanced capabilities are only published + to the capabilities aware collection. + +Filter Expressions +~~~~~~~~~~~~~~~~~~ +Finally the recipe's filter expression is checked. Filter expressions are +written in an expression language named JEXL_ that is similar to JavaScript, +but far simpler. It is intended to be as safe to evaluate as possible. + +.. _JEXL: https://github.com/mozilla/mozjexl + +Filters are evaluated in a context that contains details about the client +including browser versions, installed add-ons, and Telemetry data. Filters +have access to "transforms" which are simple functions that can do things like +check preference values or parse strings into ``Date`` objects. Filters don't +have access to change any state in the browser, and are generally +idempotent. However, filters are *not* considered to be "pure functions", +because they have access to state that may change, such as time and location. + +3. Execution +------------ +After a recipe's suitability is determined, that recipe is executed. The +recipe specifies an *action* by name, as well as arguments to pass to that +action. The arguments are validated against an expected schema. + +All action have a pre- and post-step that runs once each Normandy session. +The pre-step is run before any recipes are executed, and once the post-step +is executed, no more recipes will be executed on that action in this session. + +Each recipe is passed to the action, along with its suitability. Individual +actions have their own semantics about what to do with recipes. Many actions +maintain their own life cycle of events for new recipes, existing recipes, +and recipes that stop applying to this client. diff --git a/toolkit/components/normandy/docs/index.rst b/toolkit/components/normandy/docs/index.rst new file mode 100644 index 0000000000..eb77000ee3 --- /dev/null +++ b/toolkit/components/normandy/docs/index.rst @@ -0,0 +1,30 @@ +.. _components/normandy: + +==================== +Shield Recipe Client +==================== + +Normandy (aka the Shield Recipe Client) is a targeted change control +system, allowing small changes to be made within a release of Firefox, +such as studies. + +It downloads recipes and actions from :ref:`Remote Settings <services/remotesettings>` +and then executes them. + +.. note:: + + Previously, the recipes were fetched from the `recipe server`_, but in `Bug 1513854`_ + the source was changed to *Remote Settings*. The cryptographic signatures are verified + at the *Remote Settings* level (integrity) and at the *Normandy* level + (authenticity of publisher). + +.. _recipe server: https://github.com/mozilla/normandy/ +.. _Bug 1513854: https://bugzilla.mozilla.org/show_bug.cgi?id=1513854 + +.. toctree:: + :maxdepth: 1 + + data-collection + execution-model + suitabilities + services diff --git a/toolkit/components/normandy/docs/services.rst b/toolkit/components/normandy/docs/services.rst new file mode 100644 index 0000000000..6bec61a751 --- /dev/null +++ b/toolkit/components/normandy/docs/services.rst @@ -0,0 +1,22 @@ +Normandy Services +================= +The Normandy Client relies on several external services for correct operation. + +Normandy Server +--------------- +This is the place where recipes are created, edited and approved. Normandy +Client interacts with it to get an index of services, and to fetch extensions +and their metadata for add-on studies and rollout. + +Remote Settings +--------------- +This is the primary way that recipes are loaded from the internet by +Normandy. It manages keeping the local list of recipes on the client up to +date and notifying Normandy Client of changes. + +Classify Client +--------------- +This is a service that helps Normandy with filtering. It determines the +region a user is in, based on IP. It also includes an up-to-date time and +date. This allows Normandy to perform location and time based filtering +without having to rely on the local clock. diff --git a/toolkit/components/normandy/docs/suitabilities.rst b/toolkit/components/normandy/docs/suitabilities.rst new file mode 100644 index 0000000000..b199fc2938 --- /dev/null +++ b/toolkit/components/normandy/docs/suitabilities.rst @@ -0,0 +1,73 @@ +Suitabilities +============= + +When Normandy's core passes a recipe to an action, it also passes a +*suitability*, which is the result of evaluating a recipe's filters, +compatibility, signatures, and other checks. This gives actions more +information to make decisions with, which is especially important for +experiments. + +Temporary errors +---------------- +Some of the suitabilities below represent *temporary errors*. These could be +caused by infrastructure problems that prevent the recipe from working +correctly, or are otherwise not the fault of the recipe itself. These +suitabilities should not immediately cause a change in state. If the problem +persists, then eventually it should be considered permanent and state should +be changed. + +In the case of a permanent failure, action such as unenrollment should happen +immediately. For temporary failures, that action should be delayed until the +failure persists longer than some threshold. It is up to individual actions +to track and manage this transition. + +List of Suitabilities +--------------------- + +``FILTER_MATCH`` +~~~~~~~~~~~~~~~~ +All checks have passed, and the recipe is suitable to execute in this client. +Experiments and Rollouts should enroll or update. Heartbeats should be shown +to the user, etc. + +``SIGNATURE_ERROR`` +~~~~~~~~~~~~~~~~~~~ +The recipe's signature is not valid. If any action is taken this recipe +should be treated with extreme suspicion. + +This should be considered a temporary error, because it may be related to +server errors, local clocks, or other temporary problems. + +``CAPABILITIES_MISMATCH`` +~~~~~~~~~~~~~~~~~~~~~~~~~ +The recipe requires capabilities that this recipe runner does not have. Use +caution when interacting with this recipe, as it may not match the expected +schema. + +This should be considered a permanent error, because it is the result of a +choice made on the server. + +``FILTER_MISMATCH`` +~~~~~~~~~~~~~~~~~~~ +This client does not match the recipe's filter, but it is otherwise a +suitable recipe. + +This should be considered a permanent error, since the filter explicitly does +not match the client. + +``FILTER_ERROR`` +~~~~~~~~~~~~~~~~ +There was an error while evaluating the filter. It is unknown if this client +matches this filter. This may be temporary, due to network errors, or +permanent due to syntax errors. + +This should be considered a temporary error, because it may be the result of +infrastructure, such as `Classify Client <./services.html#classify-client>`_, +temporarily failing. + +``ARGUMENTS_INVALID`` +~~~~~~~~~~~~~~~~~~~~~ +The arguments of the recipe do not match the expected schema for the named +action. + +This should be considered a permanent error, since the arguments are generally validated by the server. This likely represents an unrecogonized compatibility error. diff --git a/toolkit/components/normandy/jar.mn b/toolkit/components/normandy/jar.mn new file mode 100644 index 0000000000..ff3dbfb319 --- /dev/null +++ b/toolkit/components/normandy/jar.mn @@ -0,0 +1,19 @@ +# 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 normandy %res/normandy/ + res/normandy/Normandy.sys.mjs (./Normandy.sys.mjs) + res/normandy/NormandyMigrations.sys.mjs (./NormandyMigrations.sys.mjs) + res/normandy/lib/ (./lib/*) + res/normandy/skin/ (./skin/*) + res/normandy/actions/ (./actions/*.sys.mjs) + res/normandy/actions/schemas/index.sys.mjs (./actions/schemas/index.sys.mjs) + res/normandy/schemas/ (./schemas/*.schema.json) + +% resource normandy-content %res/normandy/content/ contentaccessible=yes + res/normandy/content/ (./content/*) + +% resource normandy-vendor %res/normandy/vendor/ contentaccessible=yes + res/normandy/vendor/ (./vendor/*) diff --git a/toolkit/components/normandy/lib/ActionsManager.sys.mjs b/toolkit/components/normandy/lib/ActionsManager.sys.mjs new file mode 100644 index 0000000000..6f811613af --- /dev/null +++ b/toolkit/components/normandy/lib/ActionsManager.sys.mjs @@ -0,0 +1,100 @@ +/* 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/. */ + +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonRollbackAction: + "resource://normandy/actions/AddonRollbackAction.sys.mjs", + AddonRolloutAction: "resource://normandy/actions/AddonRolloutAction.sys.mjs", + BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.sys.mjs", + MessagingExperimentAction: + "resource://normandy/actions/MessagingExperimentAction.sys.mjs", + PreferenceExperimentAction: + "resource://normandy/actions/PreferenceExperimentAction.sys.mjs", + PreferenceRollbackAction: + "resource://normandy/actions/PreferenceRollbackAction.sys.mjs", + PreferenceRolloutAction: + "resource://normandy/actions/PreferenceRolloutAction.sys.mjs", + ShowHeartbeatAction: + "resource://normandy/actions/ShowHeartbeatAction.sys.mjs", + Uptake: "resource://normandy/lib/Uptake.sys.mjs", +}); + +const log = LogManager.getLogger("recipe-runner"); + +/** + * A class to manage the actions that recipes can use in Normandy. + */ +export class ActionsManager { + constructor() { + this.finalized = false; + + this.localActions = {}; + for (const [name, Constructor] of Object.entries( + ActionsManager.actionConstructors + )) { + this.localActions[name] = new Constructor(); + } + } + + static actionConstructors = { + "addon-rollback": lazy.AddonRollbackAction, + "addon-rollout": lazy.AddonRolloutAction, + "branched-addon-study": lazy.BranchedAddonStudyAction, + "console-log": lazy.ConsoleLogAction, + "messaging-experiment": lazy.MessagingExperimentAction, + "multi-preference-experiment": lazy.PreferenceExperimentAction, + "preference-rollback": lazy.PreferenceRollbackAction, + "preference-rollout": lazy.PreferenceRolloutAction, + "show-heartbeat": lazy.ShowHeartbeatAction, + }; + + static getCapabilities() { + // Prefix each action name with "action." to turn it into a capability name. + let capabilities = new Set(); + for (const actionName of Object.keys(ActionsManager.actionConstructors)) { + capabilities.add(`action.${actionName}`); + } + return capabilities; + } + + async processRecipe(recipe, suitability) { + let actionName = recipe.action; + + if (actionName in this.localActions) { + log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`); + const action = this.localActions[actionName]; + await action.processRecipe(recipe, suitability); + + // If the recipe doesn't have matching capabilities, then a missing action + // is expected. In this case, don't send an error + } else if ( + suitability !== lazy.BaseAction.suitability.CAPABILITIES_MISMATCH + ) { + log.error( + `Could not execute recipe ${recipe.name}:`, + `Action ${recipe.action} is either missing or invalid.` + ); + await lazy.Uptake.reportRecipe(recipe, lazy.Uptake.RECIPE_INVALID_ACTION); + } + } + + async finalize(options) { + if (this.finalized) { + throw new Error("ActionsManager has already been finalized"); + } + this.finalized = true; + + // Finalize local actions + for (const action of Object.values(this.localActions)) { + action.finalize(options); + } + } +} diff --git a/toolkit/components/normandy/lib/AddonRollouts.sys.mjs b/toolkit/components/normandy/lib/AddonRollouts.sys.mjs new file mode 100644 index 0000000000..6bfc2a70a7 --- /dev/null +++ b/toolkit/components/normandy/lib/AddonRollouts.sys.mjs @@ -0,0 +1,224 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +/** + * AddonRollouts store info about an active or expired addon rollouts. + * @typedef {object} AddonRollout + * @property {int} recipeId + * The ID of the recipe. + * @property {string} slug + * Unique slug of the rollout. + * @property {string} state + * The current state of the rollout: "active", or "rolled-back". + * Active means that Normandy is actively managing therollout. Rolled-back + * means that the rollout was previously active, but has been rolled back for + * this user. + * @property {int} extensionApiId + * The ID used to look up the extension in Normandy's API. + * @property {string} addonId + * The add-on ID for this particular rollout. + * @property {string} addonVersion + * The rollout add-on version number + * @property {string} xpiUrl + * URL that the add-on was installed from. + * @property {string} xpiHash + * The hash of the XPI file. + * @property {string} xpiHashAlgorithm + * The algorithm used to hash the XPI file. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this rollout. It should not be re-used by other + * rollouts, or any other purpose. May be null on old rollouts. + */ + +const DB_NAME = "normandy-addon-rollout"; +const STORE_NAME = "addon-rollouts"; +const DB_OPTIONS = { version: 1 }; + +/** + * Create a new connection to the database. + */ +function openDatabase() { + return lazy.IndexedDB.open(DB_NAME, DB_OPTIONS, db => { + db.createObjectStore(STORE_NAME, { + keyPath: "slug", + }); + }); +} + +/** + * Cache the database connection so that it is shared among multiple operations. + */ +let databasePromise; +function getDatabase() { + if (!databasePromise) { + databasePromise = openDatabase(); + } + return databasePromise; +} + +/** + * Get a transaction for interacting with the rollout store. + * + * @param {IDBDatabase} db + * @param {String} mode Either "readonly" or "readwrite" + * + * NOTE: Methods on the store returned by this function MUST be called + * synchronously, otherwise the transaction with the store will expire. + * This is why the helper takes a database as an argument; if we fetched the + * database in the helper directly, the helper would be async and the + * transaction would expire before methods on the store were called. + */ +function getStore(db, mode) { + if (!mode) { + throw new Error("mode is required"); + } + return db.objectStore(STORE_NAME, mode); +} + +export const AddonRollouts = { + STATE_ACTIVE: "active", + STATE_ROLLED_BACK: "rolled-back", + + async init() { + for (const rollout of await this.getAllActive()) { + lazy.TelemetryEnvironment.setExperimentActive( + rollout.slug, + rollout.state, + { + type: "normandy-addonrollout", + } + ); + } + }, + + /** When Telemetry is disabled, clear all identifiers from the stored rollouts. */ + async onTelemetryDisabled() { + const rollouts = await this.getAll(); + for (const rollout of rollouts) { + rollout.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + await this.updateMany(rollouts); + }, + + /** + * Add a new rollout + * @param {AddonRollout} rollout + */ + async add(rollout) { + const db = await getDatabase(); + return getStore(db, "readwrite").add(rollout); + }, + + /** + * Update an existing rollout + * @param {AddonRollout} rollout + * @throws If a matching rollout does not exist. + */ + async update(rollout) { + if (!(await this.has(rollout.slug))) { + throw new Error( + `Tried to update ${rollout.slug}, but it doesn't already exist.` + ); + } + const db = await getDatabase(); + return getStore(db, "readwrite").put(rollout); + }, + + /** + * Update many existing rollouts. More efficient than calling `update` many + * times in a row. + * @param {Array<PreferenceRollout>} rollouts + * @throws If any of the passed rollouts have a slug that doesn't exist in the database already. + */ + async updateMany(rollouts) { + // Don't touch the database if there is nothing to do + if (!rollouts.length) { + return; + } + + // Both of the below operations use .map() instead of a normal loop becaues + // once we get the object store, we can't let it expire by spinning the + // event loop. This approach queues up all the interactions with the store + // immediately, preventing it from expiring too soon. + + const db = await getDatabase(); + let store = await getStore(db, "readonly"); + await Promise.all( + rollouts.map(async ({ slug }) => { + let existingRollout = await store.get(slug); + if (!existingRollout) { + throw new Error(`Tried to update ${slug}, but it doesn't exist.`); + } + }) + ); + + // awaiting spun the event loop, so the store is now invalid. Get a new + // store. This is also a chance to get it in readwrite mode. + store = await getStore(db, "readwrite"); + await Promise.all(rollouts.map(rollout => store.put(rollout))); + }, + + /** + * Test whether there is a rollout in storage with the given slug. + * @param {string} slug + * @returns {Promise<boolean>} + */ + async has(slug) { + const db = await getDatabase(); + const rollout = await getStore(db, "readonly").get(slug); + return !!rollout; + }, + + /** + * Get a rollout by slug + * @param {string} slug + */ + async get(slug) { + const db = await getDatabase(); + return getStore(db, "readonly").get(slug); + }, + + /** Get all rollouts in the database. */ + async getAll() { + const db = await getDatabase(); + return getStore(db, "readonly").getAll(); + }, + + /** Get all rollouts in the "active" state. */ + async getAllActive() { + const rollouts = await this.getAll(); + return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE); + }, + + /** + * Test wrapper that temporarily replaces the stored rollout data with fake + * data for testing. + */ + withTestMock() { + return function (testFunction) { + return async function inner(...args) { + let db = await getDatabase(); + const oldData = await getStore(db, "readonly").getAll(); + await getStore(db, "readwrite").clear(); + try { + await testFunction(...args); + } finally { + db = await getDatabase(); + await getStore(db, "readwrite").clear(); + const store = getStore(db, "readwrite"); + await Promise.all(oldData.map(d => store.add(d))); + } + }; + }; + }, +}; diff --git a/toolkit/components/normandy/lib/AddonStudies.sys.mjs b/toolkit/components/normandy/lib/AddonStudies.sys.mjs new file mode 100644 index 0000000000..233bd4b715 --- /dev/null +++ b/toolkit/components/normandy/lib/AddonStudies.sys.mjs @@ -0,0 +1,485 @@ +/* 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/. */ + +/** + * @typedef {Object} Study + * @property {Number} recipeId + * ID of the recipe that created the study. Used as the primary key of the + * study. + * @property {Number} slug + * String code used to identify the study for use in Telemetry and logging. + * @property {string} userFacingName + * Name of the study to show to the user + * @property {string} userFacingDescription + * Description of the study and its intent. + * @property {string} branch + * The branch the user is enrolled in + * @property {boolean} active + * Is the study still running? + * @property {string} addonId + * Add-on ID for this particular study. + * @property {string} addonUrl + * URL that the study add-on was installed from. + * @property {string} addonVersion + * Study add-on version number + * @property {int} extensionApiId + * The ID used to look up the extension in Normandy's API. + * @property {string} extensionHash + * The hash of the XPI file. + * @property {string} extensionHashAlgorithm + * The algorithm used to hash the XPI file. + * @property {Date} studyStartDate + * Date when the study was started. + * @property {Date|null} studyEndDate + * Date when the study was ended. + * @property {Date|null} temporaryErrorDeadline + * Date of when temporary errors with this experiment should no longer be + * considered temporary. After this point, further errors will result in + * unenrollment. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this study. It should not be re-used by other studies, + * or any other purpose. May be null on old study. + */ + +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const DB_NAME = "shield"; +const STORE_NAME = "addon-studies"; +const VERSION_STORE_NAME = "addon-studies-version"; +const DB_VERSION = 2; +const STUDY_ENDED_TOPIC = "shield-study-ended"; +const log = LogManager.getLogger("addon-studies"); + +/** + * Create a new connection to the database. + */ +function openDatabase() { + return lazy.IndexedDB.open(DB_NAME, DB_VERSION, async (db, event) => { + if (event.oldVersion < 1) { + db.createObjectStore(STORE_NAME, { + keyPath: "recipeId", + }); + } + + if (event.oldVersion < 2) { + db.createObjectStore(VERSION_STORE_NAME); + } + }); +} + +/** + * Cache the database connection so that it is shared among multiple operations. + */ +let databasePromise; +async function getDatabase() { + if (!databasePromise) { + databasePromise = openDatabase(); + } + return databasePromise; +} + +/** + * Get a transaction for interacting with the study store. + * + * @param {IDBDatabase} db + * @param {String} mode Either "readonly" or "readwrite" + * + * NOTE: Methods on the store returned by this function MUST be called + * synchronously, otherwise the transaction with the store will expire. + * This is why the helper takes a database as an argument; if we fetched the + * database in the helper directly, the helper would be async and the + * transaction would expire before methods on the store were called. + */ +function getStore(db, mode) { + if (!mode) { + throw new Error("mode is required"); + } + return db.objectStore(STORE_NAME, mode); +} + +export var AddonStudies = { + /** + * Test wrapper that temporarily replaces the stored studies with the given + * ones. The original stored studies are restored upon completion. + * + * This is defined here instead of in test code since it needs to access the + * getDatabase, which we don't expose to avoid outside modules relying on the + * type of storage used for studies. + * + * @param {Array} [addonStudies=[]] + */ + withStudies(addonStudies = []) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const oldStudies = await AddonStudies.getAll(); + let db = await getDatabase(); + await AddonStudies.clear(); + const store = getStore(db, "readwrite"); + await Promise.all(addonStudies.map(study => store.add(study))); + + try { + await testFunction({ ...args, addonStudies }); + } finally { + db = await getDatabase(); + await AddonStudies.clear(); + const store = getStore(db, "readwrite"); + await Promise.all(oldStudies.map(study => store.add(study))); + } + }; + }; + }, + + async init() { + for (const study of await this.getAllActive()) { + // If an active study's add-on has been removed since we last ran, stop it. + const addon = await lazy.AddonManager.getAddonByID(study.addonId); + if (!addon) { + await this.markAsEnded(study, "uninstalled-sideload"); + continue; + } + + // Otherwise mark that study as active in Telemetry + lazy.TelemetryEnvironment.setExperimentActive(study.slug, study.branch, { + type: "normandy-addonstudy", + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } + + // Listen for add-on uninstalls so we can stop the corresponding studies. + lazy.AddonManager.addAddonListener(this); + lazy.CleanupManager.addCleanupHandler(() => { + lazy.AddonManager.removeAddonListener(this); + }); + }, + + /** When Telemetry is disabled, clear all identifiers from the stored studies. */ + async onTelemetryDisabled() { + const studies = await this.getAll(); + for (const study of studies) { + study.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + await this.updateMany(studies); + }, + + /** + * These migrations should only be called from `NormandyMigrations.jsm` and + * tests. + */ + migrations: { + /** + * Change from "name" and "description" to "slug", "userFacingName", + * and "userFacingDescription". + */ + async migration01AddonStudyFieldsToSlugAndUserFacingFields() { + const db = await getDatabase(); + const studies = await db.objectStore(STORE_NAME, "readonly").getAll(); + + // If there are no studies, stop here to avoid opening the DB again. + if (studies.length === 0) { + return; + } + + // Object stores expire after `await`, so this method accumulates a bunch of + // promises, and then awaits them at the end. + const writePromises = []; + const objectStore = db.objectStore(STORE_NAME, "readwrite"); + + for (const study of studies) { + // use existing name as slug + if (!study.slug) { + study.slug = study.name; + } + + // Rename `name` and `description` as `userFacingName` and `userFacingDescription` + if (study.name && !study.userFacingName) { + study.userFacingName = study.name; + } + delete study.name; + if (study.description && !study.userFacingDescription) { + study.userFacingDescription = study.description; + } + delete study.description; + + // Specify that existing recipes don't have branches + if (!study.branch) { + study.branch = AddonStudies.NO_BRANCHES_MARKER; + } + + writePromises.push(objectStore.put(study)); + } + + await Promise.all(writePromises); + }, + + async migration02RemoveOldAddonStudyAction() { + const studies = await AddonStudies.getAllActive({ + branched: AddonStudies.FILTER_NOT_BRANCHED, + }); + if (!studies.length) { + return; + } + const action = new lazy.BranchedAddonStudyAction(); + for (const study of studies) { + try { + await action.unenroll( + study.recipeId, + "migration-removing-unbranched-action" + ); + } catch (e) { + log.error( + `Stopping add-on study ${study.slug} during migration failed: ${e}` + ); + } + } + }, + }, + + /** + * If a study add-on is uninstalled, mark the study as having ended. + * @param {Addon} addon + */ + async onUninstalled(addon) { + const activeStudies = (await this.getAll()).filter(study => study.active); + const matchingStudy = activeStudies.find( + study => study.addonId === addon.id + ); + if (matchingStudy) { + await this.markAsEnded(matchingStudy, "uninstalled"); + } + }, + + /** + * Remove all stored studies. + */ + async clear() { + const db = await getDatabase(); + await getStore(db, "readwrite").clear(); + }, + + /** + * Test whether there is a study in storage for the given recipe ID. + * @param {Number} recipeId + * @returns {Boolean} + */ + async has(recipeId) { + const db = await getDatabase(); + const study = await getStore(db, "readonly").get(recipeId); + return !!study; + }, + + /** + * Fetch a study from storage. + * @param {Number} recipeId + * @return {Study} The requested study, or null if none with that ID exist. + */ + async get(recipeId) { + const db = await getDatabase(); + return getStore(db, "readonly").get(recipeId); + }, + + FILTER_BRANCHED_ONLY: Symbol("FILTER_BRANCHED_ONLY"), + FILTER_NOT_BRANCHED: Symbol("FILTER_NOT_BRANCHED"), + FILTER_ALL: Symbol("FILTER_ALL"), + + /** + * Fetch all studies in storage. + * @return {Array<Study>} + */ + async getAll({ branched = AddonStudies.FILTER_ALL } = {}) { + const db = await getDatabase(); + let results = await getStore(db, "readonly").getAll(); + + if (branched == AddonStudies.FILTER_BRANCHED_ONLY) { + results = results.filter( + study => study.branch != AddonStudies.NO_BRANCHES_MARKER + ); + } else if (branched == AddonStudies.FILTER_NOT_BRANCHED) { + results = results.filter( + study => study.branch == AddonStudies.NO_BRANCHES_MARKER + ); + } + return results; + }, + + /** + * Fetch all studies in storage. + * @return {Array<Study>} + */ + async getAllActive(options) { + return (await this.getAll(options)).filter(study => study.active); + }, + + /** + * Add a study to storage. + * @return {Promise<void, Error>} Resolves when the study is stored, or rejects with an error. + */ + async add(study) { + const db = await getDatabase(); + return getStore(db, "readwrite").add(study); + }, + + /** + * Update a study in storage. + * @return {Promise<void, Error>} Resolves when the study is updated, or rejects with an error. + */ + async update(study) { + const db = await getDatabase(); + return getStore(db, "readwrite").put(study); + }, + + /** + * Update many existing studies. More efficient than calling `update` many + * times in a row. + * @param {Array<AddonStudy>} studies + * @throws If any of the passed studies have a slug that doesn't exist in the database already. + */ + async updateMany(studies) { + // Don't touch the database if there is nothing to do + if (!studies.length) { + return; + } + + // Both of the below operations use .map() instead of a normal loop becaues + // once we get the object store, we can't let it expire by spinning the + // event loop. This approach queues up all the interactions with the store + // immediately, preventing it from expiring too soon. + + const db = await getDatabase(); + let store = await getStore(db, "readonly"); + await Promise.all( + studies.map(async ({ recipeId }) => { + let existingStudy = await store.get(recipeId); + if (!existingStudy) { + throw new Error( + `Tried to update addon study ${recipeId}, but it doesn't exist.` + ); + } + }) + ); + + // awaiting spun the event loop, so the store is now invalid. Get a new + // store. This is also a chance to get it in readwrite mode. + store = await getStore(db, "readwrite"); + await Promise.all(studies.map(study => store.put(study))); + }, + + /** + * Remove a study from storage + * @param recipeId The recipeId of the study to delete + * @return {Promise<void, Error>} Resolves when the study is deleted, or rejects with an error. + */ + async delete(recipeId) { + const db = await getDatabase(); + return getStore(db, "readwrite").delete(recipeId); + }, + + /** + * Mark a study object as having ended. Modifies the study in-place. + * @param {IDBDatabase} db + * @param {Study} study + * @param {String} reason Why the study is ending. + */ + async markAsEnded(study, reason = "unknown") { + if (reason === "unknown") { + log.warn(`Study ${study.slug} ending for unknown reason.`); + } + + study.active = false; + study.temporaryErrorDeadline = null; + study.studyEndDate = new Date(); + const db = await getDatabase(); + await getStore(db, "readwrite").put(study); + + Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`); + lazy.TelemetryEvents.sendEvent("unenroll", "addon_study", study.slug, { + addonId: study.addonId || AddonStudies.NO_ADDON_MARKER, + addonVersion: study.addonVersion || AddonStudies.NO_ADDON_MARKER, + reason, + branch: study.branch, + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + lazy.TelemetryEnvironment.setExperimentInactive(study.slug); + + await this.callUnenrollListeners(study.addonId, reason); + }, + + // Maps extension id -> Set(callbacks) + _unenrollListeners: new Map(), + + /** + * Register a callback to be invoked when a given study ends. + * + * @param {string} id The extension id + * @param {function} listener The callback + */ + addUnenrollListener(id, listener) { + let listeners = this._unenrollListeners.get(id); + if (!listeners) { + listeners = new Set(); + this._unenrollListeners.set(id, listeners); + } + listeners.add(listener); + }, + + /** + * Unregister a callback to be invoked when a given study ends. + * + * @param {string} id The extension id + * @param {function} listener The callback + */ + removeUnenrollListener(id, listener) { + let listeners = this._unenrollListeners.get(id); + if (listeners) { + listeners.delete(listener); + } + }, + + /** + * Invoke the unenroll callback (if any) for the given extension + * + * @param {string} id The extension id + * @param {string} reason Why the study is ending + * + * @returns {Promise} A Promise resolved after the unenroll listener + * (if any) has finished its unenroll tasks. + */ + async callUnenrollListeners(id, reason) { + let callbacks = this._unenrollListeners.get(id) || []; + + async function callCallback(cb, reason) { + try { + await cb(reason); + } catch (err) { + console.error(err); + } + } + + let promises = []; + for (let callback of callbacks) { + promises.push(callCallback(callback, reason)); + } + + // Wait for all the promises to be settled. This won't throw even if some of + // the listeners fail. + await Promise.all(promises); + }, +}; + +AddonStudies.NO_BRANCHES_MARKER = "__NO_BRANCHES__"; +AddonStudies.NO_ADDON_MARKER = "__NO_ADDON__"; diff --git a/toolkit/components/normandy/lib/CleanupManager.sys.mjs b/toolkit/components/normandy/lib/CleanupManager.sys.mjs new file mode 100644 index 0000000000..9101f0f63c --- /dev/null +++ b/toolkit/components/normandy/lib/CleanupManager.sys.mjs @@ -0,0 +1,49 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", +}); + +class CleanupManagerClass { + constructor() { + this.handlers = new Set(); + this.cleanupPromise = null; + } + + addCleanupHandler(handler) { + this.handlers.add(handler); + } + + removeCleanupHandler(handler) { + this.handlers.delete(handler); + } + + async cleanup() { + if (this.cleanupPromise === null) { + this.cleanupPromise = (async () => { + for (const handler of this.handlers) { + try { + await handler(); + } catch (ex) { + console.error(ex); + } + } + })(); + + // Block shutdown to ensure any cleanup tasks that write data are + // finished. + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "ShieldRecipeClient: Cleaning up", + this.cleanupPromise + ); + } + + return this.cleanupPromise; + } +} + +export var CleanupManager = new CleanupManagerClass(); diff --git a/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs b/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs new file mode 100644 index 0000000000..16645dd3b2 --- /dev/null +++ b/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs @@ -0,0 +1,123 @@ +/* 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/. */ + +import { ClientEnvironmentBase } from "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs", + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", + PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs", +}); + +// Cached API request for client attributes that are determined by the Normandy +// service. +let _classifyRequest = null; + +export class ClientEnvironment extends ClientEnvironmentBase { + /** + * Fetches information about the client that is calculated on the server, + * like geolocation and the current time. + * + * The server request is made lazily and is cached for the entire browser + * session. + */ + static async getClientClassification() { + if (!_classifyRequest) { + _classifyRequest = lazy.NormandyApi.classifyClient(); + } + return _classifyRequest; + } + + static clearClassifyCache() { + _classifyRequest = null; + } + + /** + * Test wrapper that mocks the server request for classifying the client. + * @param {Object} data Fake server data to use + * @param {Function} testFunction Test function to execute while mock data is in effect. + */ + static withMockClassify(data, testFunction) { + return async function inner() { + const oldRequest = _classifyRequest; + _classifyRequest = Promise.resolve(data); + await testFunction(); + _classifyRequest = oldRequest; + }; + } + + static get userId() { + return ClientEnvironment.randomizationId; + } + + static get country() { + return (async () => { + const { country } = await ClientEnvironment.getClientClassification(); + return country; + })(); + } + + static get request_time() { + return (async () => { + const { request_time } = + await ClientEnvironment.getClientClassification(); + return request_time; + })(); + } + + static get experiments() { + return (async () => { + const names = { all: [], active: [], expired: [] }; + + for (const { + slug, + expired, + } of await lazy.PreferenceExperiments.getAll()) { + names.all.push(slug); + if (expired) { + names.expired.push(slug); + } else { + names.active.push(slug); + } + } + + return names; + })(); + } + + static get studies() { + return (async () => { + const rv = { pref: {}, addon: {} }; + for (const prefStudy of await lazy.PreferenceExperiments.getAll()) { + rv.pref[prefStudy.slug] = prefStudy; + } + for (const addonStudy of await lazy.AddonStudies.getAll()) { + rv.addon[addonStudy.slug] = addonStudy; + } + return rv; + })(); + } + + static get rollouts() { + return (async () => { + const rv = { pref: {}, addon: {} }; + for (const prefRollout of await lazy.PreferenceRollouts.getAll()) { + rv.pref[prefRollout.slug] = prefRollout; + } + for (const addonRollout of await lazy.AddonRollouts.getAll()) { + rv.addon[addonRollout.slug] = addonRollout; + } + return rv; + })(); + } + + static get isFirstRun() { + return Services.prefs.getBoolPref("app.normandy.first_run", true); + } +} diff --git a/toolkit/components/normandy/lib/EventEmitter.sys.mjs b/toolkit/components/normandy/lib/EventEmitter.sys.mjs new file mode 100644 index 0000000000..551fc4d6b2 --- /dev/null +++ b/toolkit/components/normandy/lib/EventEmitter.sys.mjs @@ -0,0 +1,59 @@ +/* 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/. */ + +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const log = LogManager.getLogger("event-emitter"); + +export var EventEmitter = function () { + const listeners = {}; + + return { + emit(eventName, event) { + // Fire events async + Promise.resolve().then(() => { + if (!(eventName in listeners)) { + log.debug( + `EventEmitter: Event fired with no listeners: ${eventName}` + ); + return; + } + // Clone callbacks array to avoid problems with mutation while iterating + const callbacks = Array.from(listeners[eventName]); + for (const cb of callbacks) { + // Clone event so it can't by modified by the handler + let eventToPass = event; + if (typeof event === "object") { + eventToPass = Object.assign({}, event); + } + cb(eventToPass); + } + }); + }, + + on(eventName, callback) { + if (!(eventName in listeners)) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + + off(eventName, callback) { + if (eventName in listeners) { + const index = listeners[eventName].indexOf(callback); + if (index !== -1) { + listeners[eventName].splice(index, 1); + } + } + }, + + once(eventName, callback) { + const inner = event => { + callback(event); + this.off(eventName, inner); + }; + this.on(eventName, inner); + }, + }; +}; diff --git a/toolkit/components/normandy/lib/Heartbeat.sys.mjs b/toolkit/components/normandy/lib/Heartbeat.sys.mjs new file mode 100644 index 0000000000..fa66844acb --- /dev/null +++ b/toolkit/components/normandy/lib/Heartbeat.sys.mjs @@ -0,0 +1,381 @@ +/* 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/. */ + +import { Preferences } from "resource://gre/modules/Preferences.sys.mjs"; +import { TelemetryController } from "resource://gre/modules/TelemetryController.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { CleanupManager } from "resource://normandy/lib/CleanupManager.sys.mjs"; +import { EventEmitter } from "resource://normandy/lib/EventEmitter.sys.mjs"; +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration"; +const NOTIFICATION_TIME = 3000; +const HEARTBEAT_CSS_URI = Services.io.newURI( + "resource://normandy/skin/shared/Heartbeat.css" +); +const log = LogManager.getLogger("heartbeat"); +const windowsWithInjectedCss = new WeakSet(); +let anyWindowsWithInjectedCss = false; + +// Add cleanup handler for CSS injected into windows by Heartbeat +CleanupManager.addCleanupHandler(() => { + if (anyWindowsWithInjectedCss) { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (windowsWithInjectedCss.has(window)) { + const utils = window.windowUtils; + utils.removeSheet(HEARTBEAT_CSS_URI, window.AGENT_SHEET); + windowsWithInjectedCss.delete(window); + } + } + } +}); + +/** + * Show the Heartbeat UI to request user feedback. + * + * @param chromeWindow + * The chrome window that the heartbeat notification is displayed in. + * @param {Object} options Options object. + * @param {String} options.message + * The message, or question, to display on the notification. + * @param {String} options.thanksMessage + * The thank you message to display after user votes. + * @param {String} options.flowId + * An identifier for this rating flow. Please note that this is only used to + * identify the notification box. + * @param {String} [options.engagementButtonLabel=null] + * The text of the engagement button to use instead of stars. If this is null + * or invalid, rating stars are used. + * @param {String} [options.learnMoreMessage=null] + * The label of the learn more link. No link will be shown if this is null. + * @param {String} [options.learnMoreUrl=null] + * The learn more URL to open when clicking on the learn more link. No learn more + * will be shown if this is an invalid URL. + * @param {String} [options.surveyId] + * An ID for the survey, reflected in the Telemetry ping. + * @param {Number} [options.surveyVersion] + * Survey's version number, reflected in the Telemetry ping. + * @param {boolean} [options.testing] + * Whether this is a test survey, reflected in the Telemetry ping. + * @param {String} [options.postAnswerURL=null] + * The url to visit after the user answers the question. + */ +export var Heartbeat = class { + constructor(chromeWindow, options) { + if (typeof options.flowId !== "string") { + throw new Error( + `flowId must be a string, but got ${JSON.stringify( + options.flowId + )}, a ${typeof options.flowId}` + ); + } + + if (!options.flowId) { + throw new Error("flowId must not be an empty string"); + } + + if (typeof options.message !== "string") { + throw new Error( + `message must be a string, but got ${JSON.stringify( + options.message + )}, a ${typeof options.message}` + ); + } + + if (!options.message) { + throw new Error("message must not be an empty string"); + } + + if (options.postAnswerUrl) { + options.postAnswerUrl = new URL(options.postAnswerUrl); + } else { + options.postAnswerUrl = null; + } + + if (options.learnMoreUrl) { + try { + options.learnMoreUrl = new URL(options.learnMoreUrl); + } catch (e) { + options.learnMoreUrl = null; + } + } + + this.chromeWindow = chromeWindow; + this.eventEmitter = new EventEmitter(); + this.options = options; + this.surveyResults = {}; + this.buttons = []; + + if (!windowsWithInjectedCss.has(chromeWindow)) { + windowsWithInjectedCss.add(chromeWindow); + const utils = chromeWindow.windowUtils; + utils.loadSheet(HEARTBEAT_CSS_URI, chromeWindow.AGENT_SHEET); + anyWindowsWithInjectedCss = true; + } + + // so event handlers are consistent + this.handleWindowClosed = this.handleWindowClosed.bind(this); + this.close = this.close.bind(this); + + // Add Learn More Link + if (this.options.learnMoreMessage && this.options.learnMoreUrl) { + this.buttons.push({ + link: this.options.learnMoreUrl.toString(), + label: this.options.learnMoreMessage, + callback: () => { + this.maybeNotifyHeartbeat("LearnMore"); + return true; + }, + }); + } + + if (this.options.engagementButtonLabel) { + this.buttons.push({ + label: this.options.engagementButtonLabel, + callback: () => { + // Let the consumer know user engaged. + this.maybeNotifyHeartbeat("Engaged"); + + this.userEngaged({ + type: "button", + flowId: this.options.flowId, + }); + + // Return true so that the notification bar doesn't close itself since + // we have a thank you message to show. + return true; + }, + }); + } + + this.notificationBox = this.chromeWindow.gNotificationBox; + this.notice = this.notificationBox.appendNotification( + "heartbeat-" + this.options.flowId, + { + label: this.options.message, + image: "resource://normandy/skin/shared/heartbeat-icon.svg", + priority: this.notificationBox.PRIORITY_SYSTEM, + eventCallback: eventType => { + if (eventType !== "removed") { + return; + } + this.maybeNotifyHeartbeat("NotificationClosed"); + }, + }, + this.buttons + ); + this.notice.classList.add("heartbeat"); + this.notice.messageText.classList.add("heartbeat"); + + // Build the heartbeat stars + if (!this.options.engagementButtonLabel) { + const numStars = this.options.engagementButtonLabel ? 0 : 5; + this.ratingContainer = this.chromeWindow.document.createElement("span"); + this.ratingContainer.id = "star-rating-container"; + + for (let i = 0; i < numStars; i++) { + // create a star rating element + const ratingElement = + this.chromeWindow.document.createXULElement("toolbarbutton"); + + // style it + const starIndex = numStars - i; + ratingElement.className = "plain star-x"; + ratingElement.id = "star" + starIndex; + ratingElement.setAttribute("data-score", starIndex); + + // Add the click handler + ratingElement.addEventListener("click", ev => { + const rating = parseInt(ev.target.getAttribute("data-score")); + this.maybeNotifyHeartbeat("Voted", { score: rating }); + this.userEngaged({ + type: "stars", + score: rating, + flowId: this.options.flowId, + }); + }); + + this.ratingContainer.appendChild(ratingElement); + } + + this.notice.buttonContainer.append(this.ratingContainer); + } + + // Let the consumer know the notification was shown. + this.maybeNotifyHeartbeat("NotificationOffered"); + this.chromeWindow.addEventListener( + "SSWindowClosing", + this.handleWindowClosed + ); + + const surveyDuration = Preferences.get(PREF_SURVEY_DURATION, 300) * 1000; + this.surveyEndTimer = setTimeout(() => { + this.maybeNotifyHeartbeat("SurveyExpired"); + this.close(); + }, surveyDuration); + + CleanupManager.addCleanupHandler(this.close); + } + + maybeNotifyHeartbeat(name, data = {}) { + if (this.pingSent) { + log.warn( + "Heartbeat event received after Telemetry ping sent. name:", + name, + "data:", + data + ); + return; + } + + const timestamp = Date.now(); + let sendPing = false; + let cleanup = false; + + const phases = { + NotificationOffered: () => { + this.surveyResults.flowId = this.options.flowId; + this.surveyResults.offeredTS = timestamp; + }, + LearnMore: () => { + if (!this.surveyResults.learnMoreTS) { + this.surveyResults.learnMoreTS = timestamp; + } + }, + Engaged: () => { + this.surveyResults.engagedTS = timestamp; + }, + Voted: () => { + this.surveyResults.votedTS = timestamp; + this.surveyResults.score = data.score; + }, + SurveyExpired: () => { + this.surveyResults.expiredTS = timestamp; + }, + NotificationClosed: () => { + this.surveyResults.closedTS = timestamp; + cleanup = true; + sendPing = true; + }, + WindowClosed: () => { + this.surveyResults.windowClosedTS = timestamp; + cleanup = true; + sendPing = true; + }, + default: () => { + log.error("Unrecognized Heartbeat event:", name); + }, + }; + + (phases[name] || phases.default)(); + + data.timestamp = timestamp; + data.flowId = this.options.flowId; + this.eventEmitter.emit(name, data); + + if (sendPing) { + // Send the ping to Telemetry + const payload = Object.assign({ version: 1 }, this.surveyResults); + for (const meta of ["surveyId", "surveyVersion", "testing"]) { + if (this.options.hasOwnProperty(meta)) { + payload[meta] = this.options[meta]; + } + } + + log.debug("Sending telemetry"); + TelemetryController.submitExternalPing("heartbeat", payload, { + addClientId: true, + addEnvironment: true, + }); + + // only for testing + this.eventEmitter.emit("TelemetrySent", payload); + + // Survey is complete, clear out the expiry timer & survey configuration + this.endTimerIfPresent("surveyEndTimer"); + + this.pingSent = true; + this.surveyResults = null; + } + + if (cleanup) { + this.cleanup(); + } + } + + userEngaged(engagementParams) { + // Make the heartbeat icon pulse twice + this.notice.label = this.options.thanksMessage; + this.notice.messageImage.classList.remove("pulse-onshow"); + this.notice.messageImage.classList.add("pulse-twice"); + + // Remove the custom contents of the notice and the buttons + if (this.ratingContainer) { + this.ratingContainer.remove(); + } + for (let button of this.notice.buttonContainer.querySelectorAll("button")) { + button.remove(); + } + + // Open the engagement tab if we have a valid engagement URL. + if (this.options.postAnswerUrl) { + for (const key in engagementParams) { + this.options.postAnswerUrl.searchParams.append( + key, + engagementParams[key] + ); + } + // Open the engagement URL in a new tab. + let { gBrowser } = this.chromeWindow; + gBrowser.selectedTab = gBrowser.addWebTab( + this.options.postAnswerUrl.toString(), + { + triggeringPrincipal: + Services.scriptSecurityManager.createNullPrincipal({}), + } + ); + } + + this.endTimerIfPresent("surveyEndTimer"); + + this.engagementCloseTimer = setTimeout( + () => this.close(), + NOTIFICATION_TIME + ); + } + + endTimerIfPresent(timerName) { + if (this[timerName]) { + clearTimeout(this[timerName]); + this[timerName] = null; + } + } + + handleWindowClosed() { + this.maybeNotifyHeartbeat("WindowClosed"); + } + + close() { + this.notificationBox.removeNotification(this.notice); + } + + cleanup() { + // Kill the timers which might call things after we've cleaned up: + this.endTimerIfPresent("surveyEndTimer"); + this.endTimerIfPresent("engagementCloseTimer"); + // remove listeners + this.chromeWindow.removeEventListener( + "SSWindowClosing", + this.handleWindowClosed + ); + // remove references for garbage collection + this.chromeWindow = null; + this.notificationBox = null; + this.notice = null; + this.ratingContainer = null; + this.eventEmitter = null; + // Ensure we don't re-enter and release the CleanupManager's reference to us: + CleanupManager.removeCleanupHandler(this.close); + } +}; diff --git a/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs b/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs new file mode 100644 index 0000000000..501c9f70af --- /dev/null +++ b/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs @@ -0,0 +1,48 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +const FEATURE_ID = "legacyHeartbeat"; + +/** + * A bridge between Nimbus and Normandy's Heartbeat implementation. + */ +export const LegacyHeartbeat = { + getHeartbeatRecipe() { + const survey = lazy.NimbusFeatures.legacyHeartbeat.getVariable("survey"); + + if (typeof survey == "undefined") { + return null; + } + + let isRollout = false; + let enrollmentData = lazy.ExperimentAPI.getExperimentMetaData({ + featureId: FEATURE_ID, + }); + + if (!enrollmentData) { + enrollmentData = lazy.ExperimentAPI.getRolloutMetaData({ + featureId: FEATURE_ID, + }); + isRollout = true; + } + + return { + id: `nimbus:${enrollmentData.slug}`, + name: `Nimbus legacyHeartbeat ${isRollout ? "rollout" : "experiment"} ${ + enrollmentData.slug + }`, + action: "show-heartbeat", + arguments: survey, + capabilities: ["action.show-heartbeat"], + filter_expression: "true", + use_only_baseline_capabilities: true, + }; + }, +}; diff --git a/toolkit/components/normandy/lib/LogManager.sys.mjs b/toolkit/components/normandy/lib/LogManager.sys.mjs new file mode 100644 index 0000000000..9c1fd0f8fa --- /dev/null +++ b/toolkit/components/normandy/lib/LogManager.sys.mjs @@ -0,0 +1,34 @@ +/* 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/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const ROOT_LOGGER_NAME = "app.normandy"; +let rootLogger = null; + +export var LogManager = { + /** + * Configure the root logger for the Recipe Client. Must be called at + * least once before using any loggers created via getLogger. + * @param {Number} loggingLevel + * Logging level to use as defined in Log.sys.mjs + */ + configure(loggingLevel) { + if (!rootLogger) { + rootLogger = Log.repository.getLogger(ROOT_LOGGER_NAME); + rootLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + } + rootLogger.level = loggingLevel; + }, + + /** + * Obtain a named logger with the recipe client logger as its parent. + * @param {String} name + * Name of the logger to obtain. + * @return {Logger} + */ + getLogger(name) { + return Log.repository.getLogger(`${ROOT_LOGGER_NAME}.${name}`); + }, +}; diff --git a/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs b/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs new file mode 100644 index 0000000000..cf57662b2d --- /dev/null +++ b/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs @@ -0,0 +1,112 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", +}); + +export const NormandyAddonManager = { + async downloadAndInstall({ + createError, + extensionDetails, + applyNormandyChanges, + undoNormandyChanges, + onInstallStarted, + reportError, + }) { + const { extension_id, hash, hash_algorithm, version, xpi } = + extensionDetails; + + const downloadDeferred = lazy.PromiseUtils.defer(); + const installDeferred = lazy.PromiseUtils.defer(); + + const install = await lazy.AddonManager.getInstallForURL(xpi, { + hash: `${hash_algorithm}:${hash}`, + telemetryInfo: { source: "internal" }, + }); + + const listener = { + onInstallStarted(cbInstall) { + const versionMatches = cbInstall.addon.version === version; + const idMatches = cbInstall.addon.id === extension_id; + + if (!versionMatches || !idMatches) { + installDeferred.reject(createError("metadata-mismatch")); + return false; // cancel the installation, server metadata does not match downloaded add-on + } + + if (onInstallStarted) { + return onInstallStarted(cbInstall, installDeferred); + } + + return true; + }, + + onDownloadFailed() { + downloadDeferred.reject( + createError("download-failure", { + detail: lazy.AddonManager.errorToString(install.error), + }) + ); + }, + + onDownloadEnded() { + downloadDeferred.resolve(); + return false; // temporarily pause installation for Normandy bookkeeping + }, + + onInstallFailed() { + installDeferred.reject( + createError("install-failure", { + detail: lazy.AddonManager.errorToString(install.error), + }) + ); + }, + + onInstallEnded() { + installDeferred.resolve(); + }, + }; + + install.addListener(listener); + + // Download the add-on + try { + install.install(); + await downloadDeferred.promise; + } catch (err) { + reportError(err); + install.removeListener(listener); + throw err; + } + + // Complete any book-keeping + try { + await applyNormandyChanges(install); + } catch (err) { + reportError(err); + install.removeListener(listener); + install.cancel(); + throw err; + } + + // Finish paused installation + try { + install.install(); + await installDeferred.promise; + } catch (err) { + reportError(err); + install.removeListener(listener); + await undoNormandyChanges(); + throw err; + } + + install.removeListener(listener); + + return [install.addon.id, install.addon.version]; + }, +}; diff --git a/toolkit/components/normandy/lib/NormandyApi.sys.mjs b/toolkit/components/normandy/lib/NormandyApi.sys.mjs new file mode 100644 index 0000000000..cbb1af4bb3 --- /dev/null +++ b/toolkit/components/normandy/lib/NormandyApi.sys.mjs @@ -0,0 +1,157 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "CanonicalJSON", + "resource://gre/modules/CanonicalJSON.jsm" +); + +const prefs = Services.prefs.getBranch("app.normandy."); + +let indexPromise = null; + +function getChainRootIdentifier() { + const normandy_url = Services.prefs.getCharPref("app.normandy.api_url"); + if (normandy_url == "https://normandy.cdn.mozilla.net/api/v1") { + return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot; + } + if (normandy_url.includes("stage.")) { + return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot; + } + if (normandy_url.includes("dev.")) { + return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot; + } + if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + return Ci.nsIX509CertDB.AppXPCShellRoot; + } + return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot; +} + +export var NormandyApi = { + InvalidSignatureError: class InvalidSignatureError extends Error {}, + + clearIndexCache() { + indexPromise = null; + }, + + get(endpoint, data) { + const url = new URL(endpoint); + if (data) { + for (const key of Object.keys(data)) { + url.searchParams.set(key, data[key]); + } + } + return fetch(url.href, { + method: "get", + headers: { Accept: "application/json" }, + credentials: "omit", + }); + }, + + absolutify(url) { + if (url.startsWith("http")) { + return url; + } + const apiBase = prefs.getCharPref("api_url"); + const server = new URL(apiBase).origin; + if (url.startsWith("/")) { + return server + url; + } + throw new Error("Can't use relative urls"); + }, + + async getApiUrl(name) { + if (!indexPromise) { + const apiBase = new URL(prefs.getCharPref("api_url")); + if (!apiBase.pathname.endsWith("/")) { + apiBase.pathname += "/"; + } + indexPromise = this.get(apiBase.toString()).then(res => res.json()); + } + const index = await indexPromise; + if (!(name in index)) { + throw new Error(`API endpoint with name "${name}" not found.`); + } + const url = index[name]; + return this.absolutify(url); + }, + + /** + * Verify content signature, by serializing the specified `object` as + * canonical JSON, and using the Normandy signer verifier to check that + * it matches the signature specified in `signaturePayload`. + * + * If the the signature is not valid, an error is thrown. Otherwise this + * function returns undefined. + * + * @param {object|String} data The object (or string) to be checked + * @param {object} signaturePayload The signature information + * @param {String} signaturePayload.x5u The certificate chain URL + * @param {String} signaturePayload.signature base64 signature bytes + * @param {String} type The object type (eg. `"recipe"`, `"action"`) + * @returns {Promise<undefined>} If the signature is valid, this function returns without error + * @throws {NormandyApi.InvalidSignatureError} if signature is invalid. + */ + async verifyObjectSignature(data, signaturePayload, type) { + const { signature, x5u } = signaturePayload; + const certChainResponse = await this.get(this.absolutify(x5u)); + const certChain = await certChainResponse.text(); + const builtSignature = `p384ecdsa=${signature}`; + + const serialized = + typeof data == "string" ? data : lazy.CanonicalJSON.stringify(data); + + const verifier = Cc[ + "@mozilla.org/security/contentsignatureverifier;1" + ].createInstance(Ci.nsIContentSignatureVerifier); + + let valid; + try { + valid = await verifier.asyncVerifyContentSignature( + serialized, + builtSignature, + certChain, + "normandy.content-signature.mozilla.org", + getChainRootIdentifier() + ); + } catch (err) { + throw new NormandyApi.InvalidSignatureError( + `${type} signature validation failed: ${err}` + ); + } + + if (!valid) { + throw new NormandyApi.InvalidSignatureError( + `${type} signature is not valid` + ); + } + }, + + /** + * Fetch metadata about this client determined by the server. + * @return {object} Metadata specified by the server + */ + async classifyClient() { + const classifyClientUrl = await this.getApiUrl("classify-client"); + const response = await this.get(classifyClientUrl); + const clientData = await response.json(); + clientData.request_time = new Date(clientData.request_time); + return clientData; + }, + + /** + * Fetch details for an extension from the server. + * @param extensionId {integer} The ID of the extension to look up + * @resolves {Object} + */ + async fetchExtensionDetails(extensionId) { + const baseUrl = await this.getApiUrl("extension-list"); + const extensionDetailsUrl = `${baseUrl}${extensionId}/`; + const response = await this.get(extensionDetailsUrl); + return response.json(); + }, +}; diff --git a/toolkit/components/normandy/lib/NormandyUtils.sys.mjs b/toolkit/components/normandy/lib/NormandyUtils.sys.mjs new file mode 100644 index 0000000000..7db12c59b7 --- /dev/null +++ b/toolkit/components/normandy/lib/NormandyUtils.sys.mjs @@ -0,0 +1,10 @@ +/* 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/. */ + +export var NormandyUtils = { + generateUuid() { + // Generate a random UUID, convert it to a string, and slice the braces off the ends. + return Services.uuid.generateUUID().toString().slice(1, -1); + }, +}; diff --git a/toolkit/components/normandy/lib/PrefUtils.sys.mjs b/toolkit/components/normandy/lib/PrefUtils.sys.mjs new file mode 100644 index 0000000000..445744d88f --- /dev/null +++ b/toolkit/components/normandy/lib/PrefUtils.sys.mjs @@ -0,0 +1,132 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LogManager: "resource://normandy/lib/LogManager.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LogManager.getLogger("preference-experiments"); +}); + +const kPrefBranches = { + user: Services.prefs, + default: Services.prefs.getDefaultBranch(""), +}; + +export var PrefUtils = { + /** + * Get a preference of any type from the named branch. + * @param {string} pref + * @param {object} [options] + * @param {"default"|"user"} [options.branchName="user"] One of "default" or "user" + * @param {string|boolean|integer|null} [options.defaultValue] + * The value to return if the preference does not exist. Defaults to null. + */ + getPref(pref, { branch = "user", defaultValue = null } = {}) { + const branchObj = kPrefBranches[branch]; + if (!branchObj) { + throw new this.UnexpectedPreferenceBranch( + `"${branch}" is not a valid preference branch` + ); + } + const type = branchObj.getPrefType(pref); + + try { + switch (type) { + case Services.prefs.PREF_BOOL: { + return branchObj.getBoolPref(pref); + } + case Services.prefs.PREF_STRING: { + return branchObj.getStringPref(pref); + } + case Services.prefs.PREF_INT: { + return branchObj.getIntPref(pref); + } + case Services.prefs.PREF_INVALID: { + return defaultValue; + } + } + } catch (e) { + if (branch === "default" && e.result === Cr.NS_ERROR_UNEXPECTED) { + // There is a value for the pref on the user branch but not on the default branch. This is ok. + return defaultValue; + } + // Unexpected error, re-throw it + throw e; + } + + // If `type` isn't any of the above, throw an error. Don't do this in a + // default branch of switch so that error handling is easier. + throw new TypeError(`Unknown preference type (${type}) for ${pref}.`); + }, + + /** + * Set a preference on the named branch + * @param {string} pref + * @param {string|boolean|integer|null} value The value to set. + * @param {object} options + * @param {"user"|"default"} options.branchName The branch to make the change on. + */ + setPref(pref, value, { branch = "user" } = {}) { + if (value === null) { + this.clearPref(pref, { branch }); + return; + } + const branchObj = kPrefBranches[branch]; + if (!branchObj) { + throw new this.UnexpectedPreferenceBranch( + `"${branch}" is not a valid preference branch` + ); + } + switch (typeof value) { + case "boolean": { + branchObj.setBoolPref(pref, value); + break; + } + case "string": { + branchObj.setStringPref(pref, value); + break; + } + case "number": { + branchObj.setIntPref(pref, value); + break; + } + default: { + throw new TypeError( + `Unexpected value type (${typeof value}) for ${pref}.` + ); + } + } + }, + + /** + * Remove a preference from a branch. Note that default branch preferences + * cannot effectively be cleared. If "default" is passed for a branch, an + * error will be logged and nothing else will happen. + * + * @param {string} pref + * @param {object} options + * @param {"user"|"default"} options.branchName The branch to clear + */ + clearPref(pref, { branch = "user" } = {}) { + if (branch === "user") { + kPrefBranches.user.clearUserPref(pref); + } else if (branch === "default") { + lazy.log.warn( + `Cannot reset pref ${pref} on the default branch. Pref will be cleared at next restart.` + ); + } else { + throw new this.UnexpectedPreferenceBranch( + `"${branch}" is not a valid preference branch` + ); + } + }, + + UnexpectedPreferenceType: class extends Error {}, + UnexpectedPreferenceBranch: class extends Error {}, +}; diff --git a/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs b/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs new file mode 100644 index 0000000000..0c96745df9 --- /dev/null +++ b/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs @@ -0,0 +1,1069 @@ +/* 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/. */ + +/** + * Preference Experiments temporarily change a preference to one of several test + * values for the duration of the experiment. Telemetry packets are annotated to + * show what experiments are active, and we use this data to measure the + * effectiveness of the preference change. + * + * Info on active and past experiments is stored in a JSON file in the profile + * folder. + * + * Active preference experiments are stopped if they aren't active on the recipe + * server. They also expire if Firefox isn't able to contact the recipe server + * after a period of time, as well as if the user modifies the preference during + * an active experiment. + */ + +/** + * Experiments store info about an active or expired preference experiment. + * @typedef {Object} Experiment + * @property {string} slug + * A string uniquely identifying the experiment. Used for telemetry, and other + * machine-oriented use cases. Used as a display name if `userFacingName` is + * null. + * @property {string|null} userFacingName + * A user-friendly name for the experiment. Null on old-style single-preference + * experiments, which do not have a userFacingName. + * @property {string|null} userFacingDescription + * A user-friendly description of the experiment. Null on old-style + * single-preference experiments, which do not have a userFacingDescription. + * @property {string} branch + * Experiment branch that the user was matched to + * @property {boolean} expired + * If false, the experiment is active. + * ISO-formatted date string of when the experiment was last seen from the + * recipe server. + * @property {string|null} temporaryErrorDeadline + * ISO-formatted date string of when temporary errors with this experiment + * should not longer be considered temporary. After this point, further errors + * will result in unenrollment. + * @property {Object} preferences + * An object consisting of all the preferences that are set by this experiment. + * Keys are the name of each preference affected by this experiment. Values are + * Preference Objects, about which see below. + * @property {string} experimentType + * The type to report to Telemetry's experiment marker API. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this experiment. It should not be re-used by other + * studies, or any other purpose. May be null on old experiments. + * @property {string} actionName + * The action who knows about this experiment and is responsible for cleaning + * it up. This should correspond to the `name` of some BaseAction subclass. + */ + +/** + * Each Preference stores information about a preference that an + * experiment sets. + * @property {string|integer|boolean} preferenceValue + * Value to change the preference to during the experiment. + * @property {string} preferenceType + * Type of the preference value being set. + * @property {string|integer|boolean|undefined} previousPreferenceValue + * Value of the preference prior to the experiment, or undefined if it was + * unset. + * @property {PreferenceBranchType} preferenceBranchType + * Controls how we modify the preference to affect the client. + * + * If "default", when the experiment is active, the default value for the + * preference is modified on startup of the add-on. If "user", the user value + * for the preference is modified when the experiment starts, and is reset to + * its original value when the experiment ends. + * @property {boolean} overridden + * Tracks if this preference has been changed away from the experimental value. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { CleanupManager } from "resource://normandy/lib/CleanupManager.sys.mjs"; +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const EXPERIMENT_FILE = "shield-preference-experiments.json"; +const STARTUP_EXPERIMENT_PREFS_BRANCH = "app.normandy.startupExperimentPrefs."; + +const MAX_EXPERIMENT_TYPE_LENGTH = 20; // enforced by TelemetryEnvironment +const EXPERIMENT_TYPE_PREFIX = "normandy-"; +const MAX_EXPERIMENT_SUBTYPE_LENGTH = + MAX_EXPERIMENT_TYPE_LENGTH - EXPERIMENT_TYPE_PREFIX.length; + +const PREFERENCE_TYPE_MAP = { + boolean: Services.prefs.PREF_BOOL, + string: Services.prefs.PREF_STRING, + integer: Services.prefs.PREF_INT, +}; + +const UserPreferences = Services.prefs; +const DefaultPreferences = Services.prefs.getDefaultBranch(""); + +/** + * Enum storing Preference modules for each type of preference branch. + * @enum {Object} + */ +const PreferenceBranchType = { + user: UserPreferences, + default: DefaultPreferences, +}; + +/** + * Asynchronously load the JSON file that stores experiment status in the profile. + */ +let gStorePromise; +function ensureStorage() { + if (gStorePromise === undefined) { + const path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + EXPERIMENT_FILE + ); + const storage = new lazy.JSONFile({ path }); + // `storage.load()` is defined as being infallible: It won't ever throw an + // error. However, if there are are I/O errors, such as a corrupt, missing, + // or unreadable file the data loaded will be an empty object. This can + // happen ever after our migrations have run. If that happens, edit the + // storage to match our expected schema before returning it to the rest of + // the module. + gStorePromise = storage.load().then(() => { + if (!storage.data.experiments) { + storage.data = { ...storage.data, experiments: {} }; + } + return storage; + }); + } + return gStorePromise; +} + +const log = LogManager.getLogger("preference-experiments"); + +// List of active preference observers. Cleaned up on shutdown. +let experimentObservers = new Map(); +CleanupManager.addCleanupHandler(() => + PreferenceExperiments.stopAllObservers() +); + +export var PreferenceExperiments = { + /** + * Update the the experiment storage with changes that happened during early startup. + * @param {object} studyPrefsChanged Map from pref name to previous pref value + */ + async recordOriginalValues(studyPrefsChanged) { + const store = await ensureStorage(); + + for (const experiment of Object.values(store.data.experiments)) { + for (const [prefName, prefInfo] of Object.entries( + experiment.preferences + )) { + if (studyPrefsChanged.hasOwnProperty(prefName)) { + if (experiment.expired) { + log.warn( + "Expired preference experiment changed value during startup" + ); + } + if (prefInfo.preferenceBranch !== "default") { + log.warn( + "Non-default branch preference experiment changed value during startup" + ); + } + prefInfo.previousPreferenceValue = studyPrefsChanged[prefName]; + } + } + } + + // not calling store.saveSoon() because if the data doesn't get + // written, it will get updated with fresher data next time the + // browser starts. + }, + + /** + * Set the default preference value for active experiments that use the + * default preference branch. + */ + async init() { + CleanupManager.addCleanupHandler(() => this.saveStartupPrefs()); + + for (const experiment of await this.getAllActive()) { + // Check that the current value of the preference is still what we set it to + for (const [preferenceName, spec] of Object.entries( + experiment.preferences + )) { + if ( + !spec.overridden && + lazy.PrefUtils.getPref(preferenceName) !== spec.preferenceValue + ) { + // if not, record the difference + await this.recordPrefChange({ + experiment, + preferenceName, + reason: "sideload", + }); + } + } + + // Notify Telemetry of experiments we're running, since they don't persist between restarts + lazy.TelemetryEnvironment.setExperimentActive( + experiment.slug, + experiment.branch, + { + type: EXPERIMENT_TYPE_PREFIX + experiment.experimentType, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + + // Watch for changes to the experiment's preference + this.startObserver(experiment.slug, experiment.preferences); + } + }, + + /** + * Save in-progress, default-branch preference experiments in a sub-branch of + * the normandy preferences. On startup, we read these to set the + * experimental values. + * + * This is needed because the default branch does not persist between Firefox + * restarts. To compensate for that, Normandy sets the default branch to the + * experiment values again every startup. The values to set the preferences + * to are stored in user-branch preferences because preferences have minimal + * impact on the performance of startup. + */ + async saveStartupPrefs() { + const prefBranch = Services.prefs.getBranch( + STARTUP_EXPERIMENT_PREFS_BRANCH + ); + for (const pref of prefBranch.getChildList("")) { + prefBranch.clearUserPref(pref); + } + + // Only store prefs to set on the default branch. + // Be careful not to store user branch prefs here, because this + // would cause the default branch to match the user branch, + // causing the user branch pref to get cleared. + const allExperiments = await this.getAllActive(); + const defaultBranchPrefs = allExperiments + .flatMap(exp => Object.entries(exp.preferences)) + .filter( + ([preferenceName, preferenceInfo]) => + preferenceInfo.preferenceBranchType === "default" + ); + for (const [preferenceName, { preferenceValue }] of defaultBranchPrefs) { + switch (typeof preferenceValue) { + case "string": + prefBranch.setCharPref(preferenceName, preferenceValue); + break; + + case "number": + prefBranch.setIntPref(preferenceName, preferenceValue); + break; + + case "boolean": + prefBranch.setBoolPref(preferenceName, preferenceValue); + break; + + default: + throw new Error(`Invalid preference type ${typeof preferenceValue}`); + } + } + }, + + /** + * Test wrapper that temporarily replaces the stored experiment data with fake + * data for testing. + */ + withMockExperiments(prefExperiments = []) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const experiments = {}; + + for (const exp of prefExperiments) { + if (exp.name) { + throw new Error( + "Preference experiments 'name' field has been replaced by 'slug' and 'userFacingName', please update." + ); + } + + experiments[exp.slug] = exp; + } + const data = { experiments }; + + const oldPromise = gStorePromise; + gStorePromise = Promise.resolve({ + data, + saveSoon() {}, + }); + const oldObservers = experimentObservers; + experimentObservers = new Map(); + try { + await testFunction({ ...args, prefExperiments }); + } finally { + gStorePromise = oldPromise; + PreferenceExperiments.stopAllObservers(); + experimentObservers = oldObservers; + } + }; + }; + }, + + /** When Telemetry is disabled, clear all identifiers from the stored experiments. */ + async onTelemetryDisabled() { + const store = await ensureStorage(); + for (const experiment of Object.values(store.data.experiments)) { + experiment.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + store.saveSoon(); + }, + + /** + * Clear all stored data about active and past experiments. + */ + async clearAllExperimentStorage() { + const store = await ensureStorage(); + store.data = { + experiments: {}, + }; + store.saveSoon(); + }, + + /** + * Start a new preference experiment. + * @param {Object} experiment + * @param {string} experiment.slug + * @param {string} experiment.actionName The action who knows about this + * experiment and is responsible for cleaning it up. This should + * correspond to the name of some BaseAction subclass. + * @param {string} experiment.branch + * @param {string} experiment.preferenceName + * @param {string|integer|boolean} experiment.preferenceValue + * @param {PreferenceBranchType} experiment.preferenceBranchType + * @returns {Experiment} The experiment object stored in the data store + * @rejects {Error} + * - If an experiment with the given name already exists + * - if an experiment for the given preference is active + * - If the given preferenceType does not match the existing stored preference + */ + async start({ + name = null, // To check if old code is still using `name` instead of `slug`, and provide a nice error message + slug, + actionName, + branch, + preferences, + experimentType = "exp", + userFacingName = null, + userFacingDescription = null, + }) { + if (name) { + throw new Error( + "Preference experiments 'name' field has been replaced by 'slug' and 'userFacingName', please update." + ); + } + + log.debug(`PreferenceExperiments.start(${slug}, ${branch})`); + + const store = await ensureStorage(); + if (slug in store.data.experiments) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "name-conflict", + }); + throw new Error( + `A preference experiment with the slug "${slug}" already exists.` + ); + } + + const activeExperiments = Object.values(store.data.experiments).filter( + e => !e.expired + ); + const preferencesWithConflicts = Object.keys(preferences).filter( + preferenceName => { + return activeExperiments.some(e => + e.preferences.hasOwnProperty(preferenceName) + ); + } + ); + + if (preferencesWithConflicts.length) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "pref-conflict", + }); + throw new Error( + `Another preference experiment for the pref "${preferencesWithConflicts[0]}" is currently active.` + ); + } + + if (experimentType.length > MAX_EXPERIMENT_SUBTYPE_LENGTH) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "experiment-type-too-long", + }); + throw new Error( + `experimentType must be less than ${MAX_EXPERIMENT_SUBTYPE_LENGTH} characters. ` + + `"${experimentType}" is ${experimentType.length} long.` + ); + } + + // Sanity check each preference + for (const [preferenceName, preferenceInfo] of Object.entries( + preferences + )) { + // Ensure preferenceBranchType is set, using the default from + // the schema. This also modifies the preferenceInfo for use in + // the rest of the function. + preferenceInfo.preferenceBranchType = + preferenceInfo.preferenceBranchType || "default"; + const { preferenceBranchType, preferenceType } = preferenceInfo; + if ( + !(preferenceBranchType === "user" || preferenceBranchType === "default") + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-branch", + prefBranch: preferenceBranchType.slice(0, 80), + } + ); + throw new Error( + `Invalid value for preferenceBranchType: ${preferenceBranchType}` + ); + } + + const prevPrefType = Services.prefs.getPrefType(preferenceName); + const givenPrefType = PREFERENCE_TYPE_MAP[preferenceType]; + + if (!preferenceType || !givenPrefType) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-type", + } + ); + throw new Error( + `Invalid preferenceType provided (given "${preferenceType}")` + ); + } + + if ( + prevPrefType !== Services.prefs.PREF_INVALID && + prevPrefType !== givenPrefType + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-type", + } + ); + throw new Error( + `Previous preference value is of type "${prevPrefType}", but was given ` + + `"${givenPrefType}" (${preferenceType})` + ); + } + + preferenceInfo.previousPreferenceValue = lazy.PrefUtils.getPref( + preferenceName, + { branch: preferenceBranchType } + ); + } + + const alreadyOverriddenPrefs = new Set(); + for (const [preferenceName, preferenceInfo] of Object.entries( + preferences + )) { + const { preferenceValue, preferenceBranchType } = preferenceInfo; + + if (preferenceBranchType === "default") { + // Only set the pref if there is no user-branch value, because + // changing the default-branch value to the same value as the + // user-branch will effectively delete the user value. + if (Services.prefs.prefHasUserValue(preferenceName)) { + alreadyOverriddenPrefs.add(preferenceName); + } else { + lazy.PrefUtils.setPref(preferenceName, preferenceValue, { + branch: preferenceBranchType, + }); + } + } else if (preferenceBranchType === "user") { + // The original value was already backed up above. + lazy.PrefUtils.setPref(preferenceName, preferenceValue, { + branch: preferenceBranchType, + }); + } else { + log.error(`Unexpected preference branch type ${preferenceBranchType}`); + } + } + PreferenceExperiments.startObserver(slug, preferences); + + const enrollmentId = lazy.NormandyUtils.generateUuid(); + + /** @type {Experiment} */ + const experiment = { + slug, + actionName, + branch, + expired: false, + lastSeen: new Date().toJSON(), + preferences, + experimentType, + userFacingName, + userFacingDescription, + enrollmentId, + }; + + store.data.experiments[slug] = experiment; + store.saveSoon(); + + // Record telemetry that the experiment started + lazy.TelemetryEnvironment.setExperimentActive(slug, branch, { + type: EXPERIMENT_TYPE_PREFIX + experimentType, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + lazy.TelemetryEvents.sendEvent("enroll", "preference_study", slug, { + experimentType, + branch, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + + // Send events for any default branch preferences set that already had user + // values overriding them. + for (const preferenceName of alreadyOverriddenPrefs) { + await this.recordPrefChange({ + experiment, + preferenceName, + reason: "onEnroll", + }); + } + await this.saveStartupPrefs(); + + return experiment; + }, + + /** + * Register a preference observer that stops an experiment when the user + * modifies the preference. + * @param {string} experimentSlug + * @param {string} preferenceName + * @param {string|integer|boolean} preferenceValue + * @throws {Error} + * If an observer for the experiment is already active. + */ + startObserver(experimentSlug, preferences) { + log.debug(`PreferenceExperiments.startObserver(${experimentSlug})`); + + if (experimentObservers.has(experimentSlug)) { + throw new Error( + `An observer for the preference experiment ${experimentSlug} is already active.` + ); + } + + const observerInfo = { + preferences, + observe(aSubject, aTopic, preferenceName) { + const prefInfo = preferences[preferenceName]; + // if `preferenceName` is one of the experiment prefs but with more on + // the end (ie, foo.bar vs foo.bar.baz) then this can be triggered for + // changes we don't care about. Check for that. + if (!prefInfo) { + return; + } + const originalValue = prefInfo.preferenceValue; + const newValue = lazy.PrefUtils.getPref(preferenceName); + if (newValue !== originalValue) { + PreferenceExperiments.recordPrefChange({ + experimentSlug, + preferenceName, + reason: "observer", + }); + Services.prefs.removeObserver(preferenceName, observerInfo); + } + }, + }; + experimentObservers.set(experimentSlug, observerInfo); + for (const [preferenceName, spec] of Object.entries(preferences)) { + if (!spec.overridden) { + Services.prefs.addObserver(preferenceName, observerInfo); + } + } + }, + + /** + * Check if a preference observer is active for an experiment. + * @param {string} experimentSlug + * @return {Boolean} + */ + hasObserver(experimentSlug) { + log.debug(`PreferenceExperiments.hasObserver(${experimentSlug})`); + return experimentObservers.has(experimentSlug); + }, + + /** + * Disable a preference observer for an experiment. + * @param {string} experimentSlug + * @throws {Error} + * If there is no active observer for the experiment. + */ + stopObserver(experimentSlug) { + log.debug(`PreferenceExperiments.stopObserver(${experimentSlug})`); + + if (!experimentObservers.has(experimentSlug)) { + throw new Error( + `No observer for the preference experiment ${experimentSlug} found.` + ); + } + + const observer = experimentObservers.get(experimentSlug); + for (const preferenceName of Object.keys(observer.preferences)) { + Services.prefs.removeObserver(preferenceName, observer); + } + experimentObservers.delete(experimentSlug); + }, + + /** + * Disable all currently-active preference observers for experiments. + */ + stopAllObservers() { + log.debug("PreferenceExperiments.stopAllObservers()"); + for (const observer of experimentObservers.values()) { + for (const preferenceName of Object.keys(observer.preferences)) { + Services.prefs.removeObserver(preferenceName, observer); + } + } + experimentObservers.clear(); + }, + + /** + * Update the timestamp storing when Normandy last sent a recipe for the + * experiment. + * @param {string} experimentSlug + * @rejects {Error} + * If there is no stored experiment with the given slug. + */ + async markLastSeen(experimentSlug) { + log.debug(`PreferenceExperiments.markLastSeen(${experimentSlug})`); + + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + throw new Error( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + store.data.experiments[experimentSlug].lastSeen = new Date().toJSON(); + store.saveSoon(); + }, + + /** + * Called when an experimental pref has changed away from its experimental + * value for the first time. + * + * One of `experiment` or `slug` must be passed. + * + * @param {object} options + * @param {Experiment} [options.experiment] + * The experiment that had a pref change. If this is passed, slug is ignored. + * @param {string} [options.slug] + * The slug of the experiment that had a pref change. This will be used to + * fetch an experiment if none was passed. + * @param {string} options.preferenceName The preference changed. + * @param {string} options.reason The reason the preference change was detected. + */ + async recordPrefChange({ + experiment = null, + experimentSlug = null, + preferenceName, + reason, + }) { + if (!experiment) { + experiment = await PreferenceExperiments.get(experimentSlug); + } + let preferenceSpecification = experiment.preferences[preferenceName]; + if (!preferenceSpecification) { + throw new PreferenceExperiments.InvalidPreferenceName( + `Preference "${preferenceName}" is not a part of experiment "${experimentSlug}"` + ); + } + + preferenceSpecification.overridden = true; + await this.update(experiment); + + lazy.TelemetryEvents.sendEvent( + "expPrefChanged", + "preference_study", + experiment.slug, + { + preferenceName, + reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + }, + + /** + * Stop an active experiment, deactivate preference watchers, and optionally + * reset the associated preference to its previous value. + * @param {string} experimentSlug + * @param {Object} options + * @param {boolean} [options.resetValue = true] + * If true, reset the preference to its original value prior to + * the experiment. Optional, defaults to true. + * @param {String} [options.reason = "unknown"] + * Reason that the experiment is ending. Optional, defaults to + * "unknown". + * @rejects {Error} + * If there is no stored experiment with the given slug, or if the + * experiment has already expired. + */ + async stop( + experimentSlug, + { resetValue = true, reason = "unknown", changedPref, caller } = {} + ) { + log.debug( + `PreferenceExperiments.stop(${experimentSlug}, {resetValue: ${resetValue}, reason: ${reason}, changedPref: ${changedPref}, caller: ${caller}})` + ); + if (reason === "unknown") { + log.warn(`experiment ${experimentSlug} ending for unknown reason`); + } + + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_study", + experimentSlug, + { + reason: "does-not-exist", + originalReason: reason, + ...(changedPref ? { changedPref } : {}), + } + ); + throw new Error( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + const experiment = store.data.experiments[experimentSlug]; + if (experiment.expired) { + const extra = { + reason: "already-unenrolled", + originalReason: reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }; + if (changedPref) { + extra.changedPref = changedPref; + } + if (caller && AppConstants.NIGHTLY_BUILD) { + extra.caller = caller; + } + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_study", + experimentSlug, + extra + ); + throw new Error( + `Cannot stop preference experiment "${experimentSlug}" because it is already expired` + ); + } + + if (PreferenceExperiments.hasObserver(experimentSlug)) { + PreferenceExperiments.stopObserver(experimentSlug); + } + + if (resetValue) { + for (const [ + preferenceName, + { previousPreferenceValue, preferenceBranchType, overridden }, + ] of Object.entries(experiment.preferences)) { + // Overridden user prefs should keep their new value, even if that value + // is the same as the experimental value, since it is the value the user + // chose. + if (overridden && preferenceBranchType === "user") { + continue; + } + + const preferences = PreferenceBranchType[preferenceBranchType]; + + if (previousPreferenceValue !== null) { + lazy.PrefUtils.setPref(preferenceName, previousPreferenceValue, { + branch: preferenceBranchType, + }); + } else if (preferenceBranchType === "user") { + // Remove the "user set" value (which Shield set), but leave the default intact. + preferences.clearUserPref(preferenceName); + } else { + log.warn( + `Can't revert pref ${preferenceName} for experiment ${experimentSlug} ` + + `because it had no default value. ` + + `Preference will be reset at the next restart.` + ); + // It would seem that Services.prefs.deleteBranch() could be used for + // this, but in Normandy's case it does not work. See bug 1502410. + } + } + } + + experiment.expired = true; + if (experiment.temporaryErrorDeadline) { + experiment.temporaryErrorDeadline = null; + } + await store.saveSoon(); + + lazy.TelemetryEnvironment.setExperimentInactive(experimentSlug); + lazy.TelemetryEvents.sendEvent( + "unenroll", + "preference_study", + experimentSlug, + { + didResetValue: resetValue ? "true" : "false", + branch: experiment.branch, + reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + ...(changedPref ? { changedPref } : {}), + } + ); + await this.saveStartupPrefs(); + Services.obs.notifyObservers( + null, + "normandy:preference-experiment:stopped", + experimentSlug + ); + }, + + /** + * Clone an experiment using knowledge of its structure to avoid + * having to serialize/deserialize it. + * + * We do this in places where return experiments so clients can't + * accidentally mutate our data underneath us. + */ + _cloneExperiment(experiment) { + return { + ...experiment, + preferences: { + ...experiment.preferences, + }, + }; + }, + + /** + * Get the experiment object for the experiment. + * @param {string} experimentSlug + * @resolves {Experiment} + * @rejects {Error} + * If no preference experiment exists with the given slug. + */ + async get(experimentSlug) { + log.debug(`PreferenceExperiments.get(${experimentSlug})`); + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + throw new PreferenceExperiments.NotFoundError( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + return this._cloneExperiment(store.data.experiments[experimentSlug]); + }, + + /** + * Get a list of all stored experiment objects. + * @resolves {Experiment[]} + */ + async getAll() { + const store = await ensureStorage(); + return Object.values(store.data.experiments).map(experiment => + this._cloneExperiment(experiment) + ); + }, + + /** + * Get a list of experiment objects for all active experiments. + * @resolves {Experiment[]} + */ + async getAllActive() { + const store = await ensureStorage(); + return Object.values(store.data.experiments) + .filter(e => !e.expired) + .map(e => this._cloneExperiment(e)); + }, + + /** + * Check if an experiment exists with the given slug. + * @param {string} experimentSlug + * @resolves {boolean} True if the experiment exists, false if it doesn't. + */ + async has(experimentSlug) { + log.debug(`PreferenceExperiments.has(${experimentSlug})`); + const store = await ensureStorage(); + return experimentSlug in store.data.experiments; + }, + + /** + * Update an experiment in the data store. If an experiment with the given + * slug is not already in the store, an error will be thrown. + * + * @param experiment {Experiment} The experiment to update + * @param experiment.slug {String} The experiment must have a slug + */ + async update(experiment) { + const store = await ensureStorage(); + + if (!(experiment.slug in store.data.experiments)) { + throw new Error( + `Could not update a preference experiment with the slug "${experiment.slug}"` + ); + } + + store.data.experiments[experiment.slug] = experiment; + store.saveSoon(); + }, + + NotFoundError: class extends Error {}, + InvalidPreferenceName: class extends Error {}, + + /** + * These migrations should only be called from `NormandyMigrations.jsm` and tests. + */ + migrations: { + /** Move experiments into a specific key. */ + async migration01MoveExperiments(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + if (Object.hasOwnProperty.call(storage.data, "experiments")) { + return; + } + storage.data = { + experiments: storage.data, + }; + delete storage.data.experiments.__version; + storage.saveSoon(); + }, + + /** Migrate storage.data to multi-preference format */ + async migration02MultiPreference(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + + const oldExperiments = storage.data.experiments; + const v2Experiments = {}; + + for (let [expName, oldExperiment] of Object.entries(oldExperiments)) { + if (expName == "__version") { + // A stray "__version" entry snuck in, likely from old migrations. + // Ignore it and continue. It won't be propagated to future + // migrations, since `v2Experiments` won't have it. + continue; + } + if (oldExperiment.preferences) { + // experiment is already migrated + v2Experiments[expName] = oldExperiment; + continue; + } + v2Experiments[expName] = { + name: oldExperiment.name, + branch: oldExperiment.branch, + expired: oldExperiment.expired, + lastSeen: oldExperiment.lastSeen, + preferences: { + [oldExperiment.preferenceName]: { + preferenceBranchType: oldExperiment.preferenceBranchType, + preferenceType: oldExperiment.preferenceType, + preferenceValue: oldExperiment.preferenceValue, + previousPreferenceValue: oldExperiment.previousPreferenceValue, + }, + }, + experimentType: oldExperiment.experimentType, + }; + } + storage.data.experiments = v2Experiments; + storage.saveSoon(); + }, + + /** Add "actionName" field for experiments that don't have it. */ + async migration03AddActionName(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + + for (const experiment of Object.values(storage.data.experiments)) { + if (!experiment.actionName) { + // Assume SinglePreferenceExperimentAction because as of this + // writing, no multi-pref experiment recipe has launched. + experiment.actionName = "SinglePreferenceExperimentAction"; + } + } + storage.saveSoon(); + }, + + async migration04RenameNameToSlug(storage = null) { + if (!storage) { + storage = await ensureStorage(); + } + // Rename "name" to "slug" to match the intended purpose of the field. + for (const experiment of Object.values(storage.data.experiments)) { + if (experiment.name && !experiment.slug) { + experiment.slug = experiment.name; + delete experiment.name; + } + } + storage.saveSoon(); + }, + + async migration05RemoveOldAction() { + const experiments = await PreferenceExperiments.getAllActive(); + for (const experiment of experiments) { + if (experiment.actionName == "SinglePreferenceExperimentAction") { + try { + await PreferenceExperiments.stop(experiment.slug, { + resetValue: true, + reason: "migration-removing-single-pref-action", + caller: "migration05RemoveOldAction", + }); + } catch (e) { + log.error( + `Stopping preference experiment ${experiment.slug} during migration failed: ${e}` + ); + } + } + } + }, + + async migration06TrackOverriddenPrefs(storage = null) { + if (!storage) { + storage = await ensureStorage(); + } + for (const experiment of Object.values(storage.data.experiments)) { + for (const [preferenceName, specification] of Object.entries( + experiment.preferences + )) { + if (specification.overridden !== undefined) { + continue; + } + specification.overridden = + lazy.PrefUtils.getPref(preferenceName) !== + specification.preferenceValue; + } + } + storage.saveSoon(); + }, + }, +}; diff --git a/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs new file mode 100644 index 0000000000..52204d2fa5 --- /dev/null +++ b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs @@ -0,0 +1,350 @@ +/* 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/. */ + +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const log = LogManager.getLogger("recipe-runner"); + +/** + * PreferenceRollouts store info about an active or expired preference rollout. + * @typedef {object} PreferenceRollout + * @property {string} slug + * Unique slug of the experiment + * @property {string} state + * The current state of the rollout: "active", "rolled-back", "graduated". + * Active means that Normandy is actively managing therollout. Rolled-back + * means that the rollout was previously active, but has been rolled back for + * this user. Graduated means that the built-in default now matches the + * rollout value, and so Normandy is no longer managing the preference. + * @property {Array<PreferenceSpec>} preferences + * An array of preferences specifications involved in the rollout. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this rollout. It should not be re-used by other + * rollouts, or any other purpose. May be null on old rollouts. + */ + +/** + * PreferenceSpec describe how a preference should change during a rollout. + * @typedef {object} PreferenceSpec + * @property {string} preferenceName + * The preference to modify. + * @property {string} preferenceType + * Type of the preference being set. + * @property {string|integer|boolean} value + * The value to change the preference to. + * @property {string|integer|boolean} previousValue + * The value the preference would have on the default branch if this rollout + * were not active. + */ + +const STARTUP_PREFS_BRANCH = "app.normandy.startupRolloutPrefs."; +const DB_NAME = "normandy-preference-rollout"; +const STORE_NAME = "preference-rollouts"; +const DB_VERSION = 1; + +/** + * Create a new connection to the database. + */ +function openDatabase() { + return lazy.IndexedDB.open(DB_NAME, DB_VERSION, db => { + db.createObjectStore(STORE_NAME, { + keyPath: "slug", + }); + }); +} + +/** + * Cache the database connection so that it is shared among multiple operations. + */ +let databasePromise; +function getDatabase() { + if (!databasePromise) { + databasePromise = openDatabase(); + } + return databasePromise; +} + +/** + * Get a transaction for interacting with the rollout store. + * + * @param {IDBDatabase} db + * @param {String} mode Either "readonly" or "readwrite" + * + * NOTE: Methods on the store returned by this function MUST be called + * synchronously, otherwise the transaction with the store will expire. + * This is why the helper takes a database as an argument; if we fetched the + * database in the helper directly, the helper would be async and the + * transaction would expire before methods on the store were called. + */ +function getStore(db, mode) { + if (!mode) { + throw new Error("mode is required"); + } + return db.objectStore(STORE_NAME, mode); +} + +export var PreferenceRollouts = { + STATE_ACTIVE: "active", + STATE_ROLLED_BACK: "rolled-back", + STATE_GRADUATED: "graduated", + + // A set of rollout slugs that are obsolete based on the code in this build of + // Firefox. This may include things like the preference no longer being + // applicable, or the feature changing in such a way that Normandy's automatic + // graduation system cannot detect that the rollout should hand off to the + // built-in code. + GRADUATION_SET: new Set([ + "pref-webrender-intel-rollout-70-release", + "bug-1703186-rollout-http3-support-release-88-89", + "rollout-doh-nightly-rollout-to-all-us-desktop-users-nightly-74-80-bug-1613481", + "rollout-doh-beta-rollout-to-all-us-desktop-users-v2-beta-74-80-bug-1613489", + "rollout-doh-us-staged-rollout-to-all-us-desktop-users-release-73-77-bug-1586331", + "bug-1648229-rollout-comcast-steering-rollout-release-78-80", + "bug-1732206-rollout-fission-release-rollout-release-94-95", + "bug-1745237-rollout-fission-beta-96-97-rollout-beta-96-97", + "bug-1750601-rollout-doh-steering-in-canada-staggered-starting-for-release-97-98", + "bug-1758988-rollout-doh-enablment-to-new-countries-staggered-st-release-98-100", + "bug-1758818-rollout-enabling-doh-in-new-countries-staggered-sta-release-98-100", + ]), + + /** + * Update the rollout database with changes that happened during early startup. + * @param {object} rolloutPrefsChanged Map from pref name to previous pref value + */ + async recordOriginalValues(originalPreferences) { + for (const rollout of await this.getAllActive()) { + let shouldSaveRollout = false; + + // Count the number of preferences in this rollout that are now redundant. + let prefMatchingDefaultCount = 0; + + for (const prefSpec of rollout.preferences) { + const builtInDefault = originalPreferences[prefSpec.preferenceName]; + if (prefSpec.value === builtInDefault) { + prefMatchingDefaultCount++; + } + // Store the current built-in default. That way, if the preference is + // rolled back during the current session (ie, until the browser is + // shut down), the correct value will be used. + if (prefSpec.previousValue !== builtInDefault) { + prefSpec.previousValue = builtInDefault; + shouldSaveRollout = true; + } + } + + if (prefMatchingDefaultCount === rollout.preferences.length) { + // Firefox's builtin defaults have caught up to the rollout, making all + // of the rollout's changes redundant, so graduate the rollout. + await this.graduate(rollout, "all-prefs-match"); + // `this.graduate` writes the rollout to the db, so we don't need to do it anymore. + shouldSaveRollout = false; + } + + if (shouldSaveRollout) { + const db = await getDatabase(); + await getStore(db, "readwrite").put(rollout); + } + } + }, + + async init() { + lazy.CleanupManager.addCleanupHandler(() => this.saveStartupPrefs()); + + for (const rollout of await this.getAllActive()) { + if (this.GRADUATION_SET.has(rollout.slug)) { + await this.graduate(rollout, "in-graduation-set"); + continue; + } + lazy.TelemetryEnvironment.setExperimentActive( + rollout.slug, + rollout.state, + { + type: "normandy-prefrollout", + enrollmentId: + rollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + } + }, + + /** When Telemetry is disabled, clear all identifiers from the stored rollouts. */ + async onTelemetryDisabled() { + const rollouts = await this.getAll(); + for (const rollout of rollouts) { + rollout.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + await this.updateMany(rollouts); + }, + + /** + * Test wrapper that temporarily replaces the stored rollout data with fake + * data for testing. + */ + withTestMock({ + graduationSet = new Set(), + rollouts: prefRollouts = [], + } = {}) { + return testFunction => { + return async args => { + let db = await getDatabase(); + const oldData = await getStore(db, "readonly").getAll(); + await getStore(db, "readwrite").clear(); + await Promise.all(prefRollouts.map(r => this.add(r))); + const oldGraduationSet = this.GRADUATION_SET; + this.GRADUATION_SET = graduationSet; + + try { + await testFunction({ ...args, prefRollouts }); + } finally { + this.GRADUATION_SET = oldGraduationSet; + db = await getDatabase(); + await getStore(db, "readwrite").clear(); + const store = getStore(db, "readwrite"); + await Promise.all(oldData.map(d => store.add(d))); + } + }; + }; + }, + + /** + * Add a new rollout + * @param {PreferenceRollout} rollout + */ + async add(rollout) { + if (!rollout.enrollmentId) { + throw new Error("Rollout must have an enrollment ID"); + } + const db = await getDatabase(); + return getStore(db, "readwrite").add(rollout); + }, + + /** + * Update an existing rollout + * @param {PreferenceRollout} rollout + * @throws If a matching rollout does not exist. + */ + async update(rollout) { + if (!(await this.has(rollout.slug))) { + throw new Error( + `Tried to update ${rollout.slug}, but it doesn't already exist.` + ); + } + const db = await getDatabase(); + return getStore(db, "readwrite").put(rollout); + }, + + /** + * Update many existing rollouts. More efficient than calling `update` many + * times in a row. + * @param {Array<PreferenceRollout>} rollouts + * @throws If any of the passed rollouts have a slug that doesn't exist in the database already. + */ + async updateMany(rollouts) { + // Don't touch the database if there is nothing to do + if (!rollouts.length) { + return; + } + + // Both of the below operations use .map() instead of a normal loop becaues + // once we get the object store, we can't let it expire by spinning the + // event loop. This approach queues up all the interactions with the store + // immediately, preventing it from expiring too soon. + + const db = await getDatabase(); + let store = await getStore(db, "readonly"); + await Promise.all( + rollouts.map(async ({ slug }) => { + let existingRollout = await store.get(slug); + if (!existingRollout) { + throw new Error(`Tried to update ${slug}, but it doesn't exist.`); + } + }) + ); + + // awaiting spun the event loop, so the store is now invalid. Get a new + // store. This is also a chance to get it in readwrite mode. + store = await getStore(db, "readwrite"); + await Promise.all(rollouts.map(rollout => store.put(rollout))); + }, + + /** + * Test whether there is a rollout in storage with the given slug. + * @param {string} slug + * @returns {boolean} + */ + async has(slug) { + const db = await getDatabase(); + const rollout = await getStore(db, "readonly").get(slug); + return !!rollout; + }, + + /** + * Get a rollout by slug + * @param {string} slug + */ + async get(slug) { + const db = await getDatabase(); + return getStore(db, "readonly").get(slug); + }, + + /** Get all rollouts in the database. */ + async getAll() { + const db = await getDatabase(); + return getStore(db, "readonly").getAll(); + }, + + /** Get all rollouts in the "active" state. */ + async getAllActive() { + const rollouts = await this.getAll(); + return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE); + }, + + /** + * Save in-progress preference rollouts in a sub-branch of the normandy prefs. + * On startup, we read these to set the rollout values. + */ + async saveStartupPrefs() { + const prefBranch = Services.prefs.getBranch(STARTUP_PREFS_BRANCH); + for (const pref of prefBranch.getChildList("")) { + prefBranch.clearUserPref(pref); + } + + for (const rollout of await this.getAllActive()) { + for (const prefSpec of rollout.preferences) { + lazy.PrefUtils.setPref( + STARTUP_PREFS_BRANCH + prefSpec.preferenceName, + prefSpec.value + ); + } + } + }, + + async graduate(rollout, reason) { + log.debug(`Graduating rollout: ${rollout.slug}`); + rollout.state = this.STATE_GRADUATED; + const db = await getDatabase(); + await getStore(db, "readwrite").put(rollout); + lazy.TelemetryEvents.sendEvent( + "graduate", + "preference_rollout", + rollout.slug, + { + reason, + enrollmentId: + rollout.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + }, +}; diff --git a/toolkit/components/normandy/lib/RecipeRunner.sys.mjs b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs new file mode 100644 index 0000000000..ac55328a01 --- /dev/null +++ b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs @@ -0,0 +1,645 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; +import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "timerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +ChromeUtils.defineESModuleGetters(lazy, { + ActionsManager: "resource://normandy/lib/ActionsManager.sys.mjs", + BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + FilterExpressions: + "resource://gre/modules/components-utils/FilterExpressions.sys.mjs", + LegacyHeartbeat: "resource://normandy/lib/LegacyHeartbeat.sys.mjs", + Normandy: "resource://normandy/Normandy.sys.mjs", + NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + RemoteSettingsClient: + "resource://services-settings/RemoteSettingsClient.sys.mjs", + Storage: "resource://normandy/lib/Storage.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", + Uptake: "resource://normandy/lib/Uptake.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const log = LogManager.getLogger("recipe-runner"); +const TIMER_NAME = "recipe-client-addon-run"; +const REMOTE_SETTINGS_COLLECTION = "normandy-recipes-capabilities"; +const PREF_CHANGED_TOPIC = "nsPref:changed"; + +const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds"; +const FIRST_RUN_PREF = "app.normandy.first_run"; +const SHIELD_ENABLED_PREF = "app.normandy.enabled"; +const DEV_MODE_PREF = "app.normandy.dev_mode"; +const API_URL_PREF = "app.normandy.api_url"; +const LAZY_CLASSIFY_PREF = "app.normandy.experiments.lazy_classify"; +const ONSYNC_SKEW_SEC_PREF = "app.normandy.onsync_skew_sec"; + +// Timer last update preference. +// see https://searchfox.org/mozilla-central/rev/11cfa0462/toolkit/components/timermanager/UpdateTimerManager.jsm#8 +const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`; + +const PREFS_TO_WATCH = [RUN_INTERVAL_PREF, SHIELD_ENABLED_PREF, API_URL_PREF]; + +XPCOMUtils.defineLazyGetter(lazy, "gRemoteSettingsClient", () => { + return lazy.RemoteSettings(REMOTE_SETTINGS_COLLECTION); +}); + +/** + * cacheProxy returns an object Proxy that will memoize properties of the target. + */ +function cacheProxy(target) { + const cache = new Map(); + return new Proxy(target, { + get(target, prop, receiver) { + if (!cache.has(prop)) { + cache.set(prop, target[prop]); + } + return cache.get(prop); + }, + set(target, prop, value, receiver) { + cache.set(prop, value); + return true; + }, + has(target, prop) { + return cache.has(prop) || prop in target; + }, + }); +} + +export var RecipeRunner = { + initializedPromise: PromiseUtils.defer(), + + async init() { + this.running = false; + this.enabled = null; + this.loadFromRemoteSettings = false; + this._syncSkewTimeout = null; + + this.checkPrefs(); // sets this.enabled + this.watchPrefs(); + this.setUpRemoteSettings(); + + // Here "first run" means the first run this profile has ever done. This + // preference is set to true at the end of this function, and never reset to + // false. + const firstRun = Services.prefs.getBoolPref(FIRST_RUN_PREF, true); + + // If we've seen a build ID from a previous run that doesn't match the + // current build ID, run immediately. This is probably an upgrade or + // downgrade, which may cause recipe eligibility to change. + let hasNewBuildID = + Services.appinfo.lastAppBuildID != null && + Services.appinfo.lastAppBuildID != Services.appinfo.appBuildID; + + // Dev mode is a mode used for development and QA that bypasses the normal + // timer function of Normandy, to make testing more convenient. + const devMode = Services.prefs.getBoolPref(DEV_MODE_PREF, false); + + if (this.enabled && (devMode || firstRun || hasNewBuildID)) { + // In dev mode, if remote settings is enabled, force an immediate sync + // before running. This ensures that the latest data is used for testing. + // This is not needed for the first run case, because remote settings + // already handles empty collections well. + if (devMode) { + await lazy.gRemoteSettingsClient.sync(); + } + let trigger; + if (devMode) { + trigger = "devMode"; + } else if (firstRun) { + trigger = "firstRun"; + } else if (hasNewBuildID) { + trigger = "newBuildID"; + } + + await this.run({ trigger }); + } + + // Update the firstRun pref, to indicate that Normandy has run at least once + // on this profile. + if (firstRun) { + Services.prefs.setBoolPref(FIRST_RUN_PREF, false); + } + + this.initializedPromise.resolve(); + }, + + enable() { + if (this.enabled) { + return; + } + this.registerTimer(); + this.enabled = true; + }, + + disable() { + if (this.enabled) { + this.unregisterTimer(); + } + // this.enabled may be null, so always set it to false + this.enabled = false; + }, + + /** Watch for prefs to change, and call this.observer when they do */ + watchPrefs() { + for (const pref of PREFS_TO_WATCH) { + Services.prefs.addObserver(pref, this); + } + + lazy.CleanupManager.addCleanupHandler(this.unwatchPrefs.bind(this)); + }, + + unwatchPrefs() { + for (const pref of PREFS_TO_WATCH) { + Services.prefs.removeObserver(pref, this); + } + }, + + /** When prefs change, this is fired */ + observe(subject, topic, data) { + switch (topic) { + case PREF_CHANGED_TOPIC: { + const prefName = data; + + switch (prefName) { + case RUN_INTERVAL_PREF: + this.updateRunInterval(); + break; + + // explicit fall-through + case SHIELD_ENABLED_PREF: + case API_URL_PREF: + this.checkPrefs(); + break; + + default: + log.debug( + `Observer fired with unexpected pref change: ${prefName}` + ); + } + + break; + } + } + }, + + checkPrefs() { + if (!Services.prefs.getBoolPref(SHIELD_ENABLED_PREF)) { + log.debug( + `Disabling Shield because ${SHIELD_ENABLED_PREF} is set to false` + ); + this.disable(); + return; + } + + const apiUrl = Services.prefs.getCharPref(API_URL_PREF); + if (!apiUrl) { + log.warn(`Disabling Shield because ${API_URL_PREF} is not set.`); + this.disable(); + return; + } + if (!apiUrl.startsWith("https://")) { + log.warn( + `Disabling Shield because ${API_URL_PREF} is not an HTTPS url: ${apiUrl}.` + ); + this.disable(); + return; + } + + log.debug(`Enabling Shield`); + this.enable(); + }, + + registerTimer() { + this.updateRunInterval(); + lazy.CleanupManager.addCleanupHandler(() => + lazy.timerManager.unregisterTimer(TIMER_NAME) + ); + }, + + unregisterTimer() { + lazy.timerManager.unregisterTimer(TIMER_NAME); + }, + + setUpRemoteSettings() { + if (this._alreadySetUpRemoteSettings) { + return; + } + this._alreadySetUpRemoteSettings = true; + + if (!this._onSync) { + this._onSync = this.onSync.bind(this); + } + lazy.gRemoteSettingsClient.on("sync", this._onSync); + + lazy.CleanupManager.addCleanupHandler(() => { + lazy.gRemoteSettingsClient.off("sync", this._onSync); + this._alreadySetUpRemoteSettings = false; + }); + }, + + /** Called when our Remote Settings collection is updated */ + async onSync() { + if (!this.enabled) { + return; + } + + // Delay the Normandy run by a random amount, determined by preference. + // This helps alleviate server load, since we don't have a thundering + // herd of users trying to update all at once. + if (this._syncSkewTimeout) { + lazy.clearTimeout(this._syncSkewTimeout); + } + let minSkewSec = 1; // this is primarily is to avoid race conditions in tests + let maxSkewSec = Services.prefs.getIntPref(ONSYNC_SKEW_SEC_PREF, 0); + if (maxSkewSec >= minSkewSec) { + let skewMillis = + (minSkewSec + Math.random() * (maxSkewSec - minSkewSec)) * 1000; + log.debug( + `Delaying on-sync Normandy run for ${Math.floor( + skewMillis / 1000 + )} seconds` + ); + this._syncSkewTimeout = lazy.setTimeout( + () => this.run({ trigger: "sync" }), + skewMillis + ); + } else { + log.debug(`Not skewing on-sync Normandy run`); + await this.run({ trigger: "sync" }); + } + }, + + updateRunInterval() { + // Run once every `runInterval` wall-clock seconds. This is managed by setting a "last ran" + // timestamp, and running if it is more than `runInterval` seconds ago. Even with very short + // intervals, the timer will only fire at most once every few minutes. + const runInterval = Services.prefs.getIntPref(RUN_INTERVAL_PREF); + lazy.timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval); + }, + + async run({ trigger = "timer" } = {}) { + if (this.running) { + // Do nothing if already running. + return; + } + this.running = true; + + await lazy.Normandy.defaultPrefsHaveBeenApplied.promise; + + try { + this.running = true; + Services.obs.notifyObservers(null, "recipe-runner:start"); + + if (this._syncSkewTimeout) { + lazy.clearTimeout(this._syncSkewTimeout); + this._syncSkewTimeout = null; + } + + this.clearCaches(); + // Unless lazy classification is enabled, prep the classify cache. + if (!Services.prefs.getBoolPref(LAZY_CLASSIFY_PREF, false)) { + try { + await lazy.ClientEnvironment.getClientClassification(); + } catch (err) { + // Try to go on without this data; the filter expressions will + // gracefully fail without this info if they need it. + } + } + + // Fetch recipes before execution in case we fail and exit early. + let recipesAndSignatures; + try { + recipesAndSignatures = await lazy.gRemoteSettingsClient.get({ + // Do not return an empty list if an error occurs. + emptyListFallback: false, + }); + } catch (e) { + await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SERVER_ERROR); + return; + } + + const actionsManager = new lazy.ActionsManager(); + + const legacyHeartbeat = lazy.LegacyHeartbeat.getHeartbeatRecipe(); + const noRecipes = + !recipesAndSignatures.length && legacyHeartbeat === null; + + // Execute recipes, if we have any. + if (noRecipes) { + log.debug("No recipes to execute"); + } else { + for (const { recipe, signature } of recipesAndSignatures) { + let suitability = await this.getRecipeSuitability(recipe, signature); + await actionsManager.processRecipe(recipe, suitability); + } + + if (legacyHeartbeat !== null) { + await actionsManager.processRecipe( + legacyHeartbeat, + lazy.BaseAction.suitability.FILTER_MATCH + ); + } + } + + await actionsManager.finalize({ noRecipes }); + + await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SUCCESS); + Services.obs.notifyObservers(null, "recipe-runner:end"); + } finally { + this.running = false; + if (trigger != "timer") { + // `run()` was executed outside the scheduled timer. + // Update the last time it ran to make sure it is rescheduled later. + const lastUpdateTime = Math.round(Date.now() / 1000); + Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime); + } + } + }, + + getFilterContext(recipe) { + const environment = cacheProxy(lazy.ClientEnvironment); + environment.recipe = { + id: recipe.id, + arguments: recipe.arguments, + }; + return { + env: environment, + // Backwards compatibility -- see bug 1477255. + normandy: environment, + }; + }, + + /** + * Return the set of capabilities this runner has. + * + * This is used to pre-filter recipes that aren't compatible with this client. + * + * @returns {Set<String>} The capabilities supported by this client. + */ + getCapabilities() { + let capabilities = new Set([ + "capabilities-v1", // The initial version of the capabilities system. + ]); + + // Get capabilities from ActionsManager. + for (const actionCapability of lazy.ActionsManager.getCapabilities()) { + capabilities.add(actionCapability); + } + + // Add a capability for each transform available to JEXL. + for (const transform of lazy.FilterExpressions.getAvailableTransforms()) { + capabilities.add(`jexl.transform.${transform}`); + } + + // Add two capabilities for each top level key available in the context: one + // for the `normandy.` namespace, and another for the `env.` namespace. + capabilities.add("jexl.context.env"); + capabilities.add("jexl.context.normandy"); + let env = lazy.ClientEnvironment; + while (env && env.name) { + // Walk up the class chain for ClientEnvironment, collecting applicable + // properties as we go. Stop when we get to an unnamed object, which is + // usually just a plain function is the super class of a class that doesn't + // extend anything. Also stop if we get to an undefined object, just in + // case. + for (const [name, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(env) + )) { + // All of the properties we are looking for are are static getters (so + // will have a truthy `get` property) and are defined on the class, so + // will be configurable + if (descriptor.configurable && descriptor.get) { + capabilities.add(`jexl.context.env.${name}`); + capabilities.add(`jexl.context.normandy.${name}`); + } + } + // Check for the next parent + env = Object.getPrototypeOf(env); + } + + return capabilities; + }, + + /** + * Decide if a recipe is suitable to run, and returns a value from + * `BaseAction.suitability`. + * + * This checks several things in order: + * - recipe signature + * - capabilities + * - filter expression + * + * If the provided signature does not match the provided recipe, then + * `SIGNATURE_ERROR` is returned. Recipes with this suitability should not be + * trusted. These recipes are included so that temporary signature errors on + * the server can be handled intelligently by actions. + * + * Capabilities are a simple set of strings in the recipe. If the Normandy + * client has all of the capabilities listed, then execution continues. If + * not, then `CAPABILITY_MISMATCH` is returned. Recipes with this suitability + * should be considered incompatible and treated with caution. + * + * If the capabilities check passes, then the filter expression is evaluated + * against the current environment. The result of the expression is cast to a + * boolean. If it is true, then `FILTER_MATCH` is returned. If not, then + * `FILTER_MISMATCH` is returned. + * + * If there is an error while evaluating the recipe's filter, `FILTER_ERROR` + * is returned instead. + * + * @param {object} recipe + * @param {object} signature + * @param {string} recipe.filter_expression The expression to evaluate against the environment. + * @param {Set<String>} runnerCapabilities The capabilities provided by this runner. + * @return {Promise<BaseAction.suitability>} The recipe's suitability + */ + async getRecipeSuitability(recipe, signature) { + let generator = this.getAllSuitabilities(recipe, signature); + // For our purposes, only the first suitability matters, so pull the first + // value out of the async generator. This additionally guarantees if we fail + // a security or compatibility check, we won't continue to run other checks, + // which is good for the general case of running recipes. + let { value: suitability } = await generator.next(); + switch (suitability) { + case lazy.BaseAction.suitability.SIGNATURE_ERROR: { + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_INVALID_SIGNATURE + ); + break; + } + + case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: { + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES + ); + break; + } + + case lazy.BaseAction.suitability.FILTER_MATCH: { + // No telemetry needs to be sent for this right now. + break; + } + + case lazy.BaseAction.suitability.FILTER_MISMATCH: { + // This represents a terminal state for the given recipe, so + // report its outcome. Others are reported when executed in + // ActionsManager. + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_DIDNT_MATCH_FILTER + ); + break; + } + + case lazy.BaseAction.suitability.FILTER_ERROR: { + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_FILTER_BROKEN + ); + break; + } + + case lazy.BaseAction.suitability.ARGUMENTS_INVALID: { + // This shouldn't ever occur, since the arguments schema is checked by + // BaseAction itself. + throw new Error(`Shouldn't get ${suitability} in RecipeRunner`); + } + + default: { + throw new Error(`Unexpected recipe suitability ${suitability}`); + } + } + + return suitability; + }, + + /** + * Some uses cases, such as Normandy Devtools, want the status of all + * suitabilities, not only the most important one. This checks the cases of + * suitabilities in order from most blocking to least blocking. The first + * yielded is the "primary" suitability to pass on to actions. + * + * If this function yields only [FILTER_MATCH], then the recipe fully matches + * and should be executed. If any other statuses are yielded, then the recipe + * should not be executed as normal. + * + * This is a generator so that the execution can be halted as needed. For + * example, after receiving a signature error, a caller can stop advancing + * the iterator to avoid exposing the browser to unneeded risk. + */ + async *getAllSuitabilities(recipe, signature) { + try { + await lazy.NormandyApi.verifyObjectSignature(recipe, signature, "recipe"); + } catch (e) { + yield lazy.BaseAction.suitability.SIGNATURE_ERROR; + } + + const runnerCapabilities = this.getCapabilities(); + if (Array.isArray(recipe.capabilities)) { + for (const recipeCapability of recipe.capabilities) { + if (!runnerCapabilities.has(recipeCapability)) { + log.debug( + `Recipe "${recipe.name}" requires unknown capabilities. ` + + `Recipe capabilities: ${JSON.stringify(recipe.capabilities)}. ` + + `Local runner capabilities: ${JSON.stringify( + Array.from(runnerCapabilities) + )}` + ); + yield lazy.BaseAction.suitability.CAPABILITIES_MISMATCH; + } + } + } + + const context = this.getFilterContext(recipe); + const targetingContext = new lazy.TargetingContext(); + try { + if (await targetingContext.eval(recipe.filter_expression, context)) { + yield lazy.BaseAction.suitability.FILTER_MATCH; + } else { + yield lazy.BaseAction.suitability.FILTER_MISMATCH; + } + } catch (err) { + log.error( + `Error checking filter for "${recipe.name}". Filter: [${recipe.filter_expression}]. Error: "${err}"` + ); + yield lazy.BaseAction.suitability.FILTER_ERROR; + } + }, + + /** + * Clear all caches of systems used by RecipeRunner, in preparation + * for a clean run. + */ + clearCaches() { + lazy.ClientEnvironment.clearClassifyCache(); + lazy.NormandyApi.clearIndexCache(); + }, + + /** + * Clear out cached state and fetch/execute recipes from the given + * API url. This is used mainly by the mock-recipe-server JS that is + * executed in the browser console. + */ + async testRun(baseApiUrl) { + const oldApiUrl = Services.prefs.getCharPref(API_URL_PREF); + Services.prefs.setCharPref(API_URL_PREF, baseApiUrl); + + try { + lazy.Storage.clearAllStorage(); + this.clearCaches(); + await this.run(); + } finally { + Services.prefs.setCharPref(API_URL_PREF, oldApiUrl); + this.clearCaches(); + } + }, + + /** + * Offer a mechanism to get access to the lazily-instantiated + * gRemoteSettingsClient, because if someone instantiates it + * themselves, it won't have the options we provided in this module, + * and it will prevent instantiation by this module later. + * + * This is only meant to be used in testing, where it is a + * convenient hook to store data in the underlying remote-settings + * collection. + */ + get _remoteSettingsClientForTesting() { + return lazy.gRemoteSettingsClient; + }, + + migrations: { + /** + * Delete the now-unused collection of recipes, since we are using the + * "normandy-recipes-capabilities" collection now. + */ + async migration01RemoveOldRecipesCollection() { + // Don't bother to open IDB and clear on clean profiles. + const lastCheckPref = + "services.settings.main.normandy-recipes.last_check"; + if (Services.prefs.prefHasUserValue(lastCheckPref)) { + // We instantiate a client, but it won't take part of sync. + const client = new lazy.RemoteSettingsClient("normandy-recipes"); + await client.db.clear(); + Services.prefs.clearUserPref(lastCheckPref); + } + }, + }, +}; diff --git a/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs b/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs new file mode 100644 index 0000000000..730febe975 --- /dev/null +++ b/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs @@ -0,0 +1,78 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", +}); + +const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; // from modules/libpref/nsIPrefBranch.idl +const PREF_OPT_OUT_STUDIES_ENABLED = "app.shield.optoutstudies.enabled"; + +/** + * Handles Shield-specific preferences, including their UI. + */ +export var ShieldPreferences = { + init() { + // Watch for changes to the Opt-out pref + Services.prefs.addObserver(PREF_OPT_OUT_STUDIES_ENABLED, this); + + lazy.CleanupManager.addCleanupHandler(() => { + Services.prefs.removeObserver(PREF_OPT_OUT_STUDIES_ENABLED, this); + }); + }, + + observe(subject, topic, data) { + switch (topic) { + case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID: + this.observePrefChange(data); + break; + } + }, + + async observePrefChange(prefName) { + let prefValue; + switch (prefName) { + // If the opt-out pref changes to be false, disable all current studies. + case PREF_OPT_OUT_STUDIES_ENABLED: { + prefValue = Services.prefs.getBoolPref(PREF_OPT_OUT_STUDIES_ENABLED); + if (!prefValue) { + const action = new lazy.BranchedAddonStudyAction(); + const studyPromises = (await lazy.AddonStudies.getAll()).map( + study => { + if (!study.active) { + return null; + } + return action.unenroll(study.recipeId, "general-opt-out"); + } + ); + + const experimentPromises = ( + await lazy.PreferenceExperiments.getAll() + ).map(experiment => { + if (experiment.expired) { + return null; + } + return lazy.PreferenceExperiments.stop(experiment.slug, { + reason: "general-opt-out", + caller: "observePrefChange::general-opt-out", + }); + }); + + const allPromises = studyPromises + .concat(experimentPromises) + .map(p => p && p.catch(err => console.error(err))); + await Promise.all(allPromises); + } + break; + } + } + }, +}; diff --git a/toolkit/components/normandy/lib/Storage.sys.mjs b/toolkit/components/normandy/lib/Storage.sys.mjs new file mode 100644 index 0000000000..8d563db24c --- /dev/null +++ b/toolkit/components/normandy/lib/Storage.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +// Lazy-load JSON file that backs Storage instances. +XPCOMUtils.defineLazyGetter(lazy, "lazyStore", async function () { + const path = PathUtils.join( + PathUtils.profileDir, + "shield-recipe-client.json" + ); + const store = new lazy.JSONFile({ path }); + await store.load(); + return store; +}); + +export var Storage = class { + constructor(prefix) { + this.prefix = prefix; + } + + /** + * Clear ALL storage data and save to the disk. + */ + static async clearAllStorage() { + const store = await lazy.lazyStore; + store.data = {}; + store.saveSoon(); + } + + /** + * Sets an item in the prefixed storage. + * @returns {Promise} + * @resolves With the stored value, or null. + * @rejects Javascript exception. + */ + async getItem(name) { + const store = await lazy.lazyStore; + const namespace = store.data[this.prefix] || {}; + return namespace[name] || null; + } + + /** + * Sets an item in the prefixed storage. + * @returns {Promise} + * @resolves When the operation is completed successfully + * @rejects Javascript exception. + */ + async setItem(name, value) { + const store = await lazy.lazyStore; + if (!(this.prefix in store.data)) { + store.data[this.prefix] = {}; + } + store.data[this.prefix][name] = value; + store.saveSoon(); + } + + /** + * Removes a single item from the prefixed storage. + * @returns {Promise} + * @resolves When the operation is completed successfully + * @rejects Javascript exception. + */ + async removeItem(name) { + const store = await lazy.lazyStore; + if (this.prefix in store.data) { + delete store.data[this.prefix][name]; + store.saveSoon(); + } + } + + /** + * Clears all storage for the prefix. + * @returns {Promise} + * @resolves When the operation is completed successfully + * @rejects Javascript exception. + */ + async clear() { + const store = await lazy.lazyStore; + store.data[this.prefix] = {}; + store.saveSoon(); + } +}; diff --git a/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs b/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs new file mode 100644 index 0000000000..003b004e26 --- /dev/null +++ b/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs @@ -0,0 +1,30 @@ +/* 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/. */ + +const TELEMETRY_CATEGORY = "normandy"; + +export const TelemetryEvents = { + NO_ENROLLMENT_ID_MARKER: "__NO_ENROLLMENT_ID__", + + init() { + Services.telemetry.setEventRecordingEnabled(TELEMETRY_CATEGORY, true); + }, + + sendEvent(method, object, value, extra) { + for (const val of Object.values(extra)) { + if (val == null) { + throw new Error( + "Extra parameters in telemetry events must not be null" + ); + } + } + Services.telemetry.recordEvent( + TELEMETRY_CATEGORY, + method, + object, + value, + extra + ); + }, +}; diff --git a/toolkit/components/normandy/lib/Uptake.sys.mjs b/toolkit/components/normandy/lib/Uptake.sys.mjs new file mode 100644 index 0000000000..73f3eb2068 --- /dev/null +++ b/toolkit/components/normandy/lib/Uptake.sys.mjs @@ -0,0 +1,67 @@ +/* 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/. */ + +import { UptakeTelemetry } from "resource://services-common/uptake-telemetry.sys.mjs"; + +const COMPONENT = "normandy"; + +export var Uptake = { + // Action uptake + ACTION_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR, + ACTION_PRE_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_1_ERROR, + ACTION_POST_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_2_ERROR, + ACTION_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR, + ACTION_SUCCESS: UptakeTelemetry.STATUS.SUCCESS, + + // Per-recipe uptake + RECIPE_ACTION_DISABLED: UptakeTelemetry.STATUS.CUSTOM_1_ERROR, + RECIPE_DIDNT_MATCH_FILTER: UptakeTelemetry.STATUS.BACKOFF, + RECIPE_INCOMPATIBLE_CAPABILITIES: UptakeTelemetry.STATUS.BACKOFF, + RECIPE_EXECUTION_ERROR: UptakeTelemetry.STATUS.APPLY_ERROR, + RECIPE_FILTER_BROKEN: UptakeTelemetry.STATUS.CONTENT_ERROR, + RECIPE_ARGUMENTS_INVALID: UptakeTelemetry.STATUS.CONTENT_ERROR, + RECIPE_INVALID_ACTION: UptakeTelemetry.STATUS.DOWNLOAD_ERROR, + RECIPE_SUCCESS: UptakeTelemetry.STATUS.SUCCESS, + RECIPE_INVALID_SIGNATURE: UptakeTelemetry.STATUS.SIGNATURE_ERROR, + + // Uptake for the runner as a whole + RUNNER_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR, + RUNNER_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR, + RUNNER_SUCCESS: UptakeTelemetry.STATUS.SUCCESS, + + async _report(status, source) { + // Telemetry doesn't help us much with error detection, so do some here. + if (!status) { + throw new Error( + `Uptake status is required (got "${JSON.stringify(status)}"` + ); + } + if (!source) { + throw new Error( + `Uptake source is required (got "${JSON.stringify(status)}` + ); + } + await UptakeTelemetry.report(COMPONENT, status, { + source: `${COMPONENT}/${source}`, + }); + }, + + async reportRunner(status) { + await Uptake._report(status, "runner"); + }, + + async reportRecipe(recipe, status) { + await Uptake._report(status, `recipe/${recipe.id}`); + const revisionId = parseInt(recipe.revision_id, 10); + Services.telemetry.keyedScalarSet( + "normandy.recipe_freshness", + recipe.id, + revisionId + ); + }, + + async reportAction(actionName, status) { + await Uptake._report(status, `action/${actionName}`); + }, +}; diff --git a/toolkit/components/normandy/moz.build b/toolkit/components/normandy/moz.build new file mode 100644 index 0000000000..5171c3d1aa --- /dev/null +++ b/toolkit/components/normandy/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", "Normandy Client") + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "ShieldContentProcess.sys.mjs", +] + +TESTING_JS_MODULES += [ + "test/NormandyTestUtils.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +SPHINX_TREES["normandy"] = "docs" + +TEST_DIRS += ["test/browser"] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] diff --git a/toolkit/components/normandy/schemas/LegacyHeartbeat.schema.json b/toolkit/components/normandy/schemas/LegacyHeartbeat.schema.json new file mode 100644 index 0000000000..6915fc9b71 --- /dev/null +++ b/toolkit/components/normandy/schemas/LegacyHeartbeat.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Legacy (Normandy) Heartbeat, via Nimbus", + "description": "The schema for the Legacy Heartbeat Nimbus feature.", + "type": "object", + "properties": { + "survey": { + "$comment": "Hearbeat arguments are nested under survey to prevent simultaneous rollouts and experiments from overriding eachothers optional variables", + "type": "object", + "properties": { + "repeatOption": { + "type": "string", + "enum": ["once", "xdays", "nag"], + "description": "Determines how often a prompt is shown executes.", + "default": "once" + }, + "repeatEvery": { + "description": "For repeatOption=xdays, how often (in days) the prompt is displayed.", + "default": null, + "type": ["number", "null"] + }, + "includeTelemetryUUID": { + "type": "boolean", + "description": "Include unique user ID in post-answer-url and Telemetry", + "default": false + }, + "surveyId": { + "description": "Slug uniquely identifying this survey in telemetry", + "type": "string" + }, + "message": { + "description": "Message to show to the user", + "type": "string" + }, + "engagementButtonLabel": { + "description": "Text for the engagement button. If specified, this button will be shown instead of rating stars.", + "default": null, + "type": ["string", "null"] + }, + "thanksMessage": { + "description": "Thanks message to show to the user after they've rated Firefox", + "type": "string" + }, + "postAnswerUrl": { + "description": "URL to redirect the user to after rating Firefox or clicking the engagement button", + "default": null, + "type": ["string", "null"] + }, + "learnMoreMessage": { + "description": "Message to show to the user to learn more", + "default": null, + "type": ["string", "null"] + }, + "learnMoreUrl": { + "description": "URL to show to the user when they click Learn More", + "default": null, + "type": ["string", "null"] + } + }, + "required": [ + "surveyId", + "message", + "thanksMessage", + "postAnswerUrl", + "learnMoreMessage", + "learnMoreUrl" + ], + "additionalProperties": false + } + }, + "required": ["survey"], + "additionalProperties": false +} diff --git a/toolkit/components/normandy/skin/shared/Heartbeat.css b/toolkit/components/normandy/skin/shared/Heartbeat.css new file mode 100644 index 0000000000..fdf5f1b75c --- /dev/null +++ b/toolkit/components/normandy/skin/shared/Heartbeat.css @@ -0,0 +1,106 @@ +/* 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/. */ + +/* Notification overrides for Heartbeat UI */ + +@keyframes pulse-onshow { + 0% { + opacity: 0; + transform: scale(1); + } + + 25% { + opacity: 1; + transform: scale(1.1); + } + + 50% { + transform: scale(1); + } + + 75% { + transform: scale(1.1); + } + + 100% { + transform: scale(1); + } +} + +@keyframes pulse-twice { + 0% { + transform: scale(1.1); + } + + 50% { + transform: scale(0.8); + } + + 100% { + transform: scale(1); + } +} + +.messageImage.heartbeat { + /* Needed for the animation to not get clipped when pulsing. */ + margin-inline: 8px; +} + +.messageImage.heartbeat.pulse-onshow { + animation-duration: 1.5s; + animation-iteration-count: 1; + animation-name: pulse-onshow; + animation-timing-function: cubic-bezier(0.7, 1.8, 0.9, 1.1); +} + +.messageImage.heartbeat.pulse-twice { + animation-duration: 1s; + animation-iteration-count: 2; + animation-name: pulse-twice; + animation-timing-function: linear; +} + +/* Learn More link styles */ +.heartbeat > hbox > .text-link { + margin-inline-start: 0 !important; +} + +.heartbeat > hbox > .text-link:hover { + text-decoration: none !important; +} + +/* Heartbeat UI Rating Star Classes */ +#star-rating-container { + display: flex; + margin-bottom: 4px; +} + +#star-rating-container > #star5 { + order: 5; +} + +#star-rating-container > #star4 { + order: 4; +} + +#star-rating-container > #star3 { + order: 3; +} + +#star-rating-container > #star2 { + order: 2; +} + +#star-rating-container > .star-x { + background: url("resource://normandy/skin/shared/heartbeat-star-off.svg"); + cursor: pointer; + height: 16px; + margin-inline-end: 4px !important; /* Overrides the margin-inline-end for all platforms defined in the .plain class */ + width: 16px; +} + +#star-rating-container > .star-x:hover, +#star-rating-container > .star-x:hover ~ .star-x { + background: url("resource://normandy/skin/shared/heartbeat-star-lit.svg"); +} diff --git a/toolkit/components/normandy/skin/shared/heartbeat-icon.svg b/toolkit/components/normandy/skin/shared/heartbeat-icon.svg new file mode 100644 index 0000000000..db2cb5f034 --- /dev/null +++ b/toolkit/components/normandy/skin/shared/heartbeat-icon.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<svg width="288px" height="248px" viewBox="0 0 288 248" xmlns="http://www.w3.org/2000/svg"> + <path fill="#d74345" d="M144,248.571429 C141.214272,248.571429 138.857152,247.607152 136.928571,245.678571 L36.6428571,148.928571 C35.5714232,148.071424 34.0982237,146.678581 32.2232143,144.75 C30.3482049,142.821419 27.3750204,139.312525 23.3035714,134.223214 C19.2321225,129.133903 15.5893018,123.910741 12.375,118.553571 C9.16069821,113.196402 6.29465545,106.714324 3.77678571,99.1071429 C1.25891598,91.499962 0,84.1071788 0,76.9285714 C0,53.357025 6.80350339,34.9286379 20.4107143,21.6428571 C34.0179252,8.35707643 52.8213086,1.71428571 76.8214286,1.71428571 C83.4643189,1.71428571 90.2410369,2.86605991 97.1517857,5.16964286 C104.062535,7.4732258 110.491042,10.5803376 116.4375,14.4910714 C122.383958,18.4018053 127.499979,22.0714114 131.785714,25.5 C136.07145,28.9285886 140.142838,32.5714093 144,36.4285714 C147.857162,32.5714093 151.92855,28.9285886 156.214286,25.5 C160.500021,22.0714114 165.616042,18.4018053 171.5625,14.4910714 C177.508958,10.5803376 183.937465,7.4732258 190.848214,5.16964286 C197.758963,2.86605991 204.535681,1.71428571 211.178571,1.71428571 C235.178691,1.71428571 253.982075,8.35707643 267.589286,21.6428571 C281.196497,34.9286379 288,53.357025 288,76.9285714 C288,100.607261 275.732266,124.714163 251.196429,149.25 L151.071429,245.678571 C149.142847,247.607152 146.785728,248.571429 144,248.571429 L144,248.571429 Z" transform="translate(0,-1)"/> + <g transform="translate(0,-0.29)"> + <mask id="mask" fill="#fff"> + <path d="M144,246.857143 C141.214272,246.857143 138.857152,245.892867 136.928571,243.964286 L36.6428571,147.214286 C35.5714232,146.357139 34.0982237,144.964295 32.2232143,143.035714 C30.3482049,141.107133 27.3750204,137.59824 23.3035714,132.508929 C19.2321225,127.419617 15.5893018,122.196455 12.375,116.839286 C9.16069821,111.482116 6.29465545,105.000038 3.77678571,97.3928571 C1.25891598,89.7856763 0,82.392893 0,75.2142857 C0,51.6427393 6.80350339,33.2143521 20.4107143,19.9285714 C34.0179252,6.64279071 52.8213086,0 76.8214286,0 C83.4643189,0 90.2410369,1.1517742 97.1517857,3.45535714 C104.062535,5.75894009 110.491042,8.86605187 116.4375,12.7767857 C122.383958,16.6875196 127.499979,20.3571257 131.785714,23.7857143 C136.07145,27.2143029 140.142838,30.8571236 144,34.7142857 C147.857162,30.8571236 151.92855,27.2143029 156.214286,23.7857143 C160.500021,20.3571257 165.616042,16.6875196 171.5625,12.7767857 C177.508958,8.86605187 183.937465,5.75894009 190.848214,3.45535714 C197.758963,1.1517742 204.535681,0 211.178571,0 C235.178691,0 253.982075,6.64279071 267.589286,19.9285714 C281.196497,33.2143521 288,51.6427393 288,75.2142857 C288,98.8929755 275.732266,122.999877 251.196429,147.535714 L151.071429,243.964286 C149.142847,245.892867 146.785728,246.857143 144,246.857143 L144,246.857143 Z"/> + </mask> + <path fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" mask="url(#mask)" d="M-166,115.135254 C-166,115.135254 0.595052083,115.135254 2.9765625,115.135254 L91.9101562,115.135254 L97.9638977,100.101562 L105.430695,115.135254 L114.893585,115.135254 L131.129913,189.53125 L148.161163,57 L165.348663,131.027344 L172.272491,115.135254 L250.84967,115.135254 L428.259813,115.135254"/> + </g> +</svg> diff --git a/toolkit/components/normandy/skin/shared/heartbeat-star-lit.svg b/toolkit/components/normandy/skin/shared/heartbeat-star-lit.svg new file mode 100644 index 0000000000..3f59099b98 --- /dev/null +++ b/toolkit/components/normandy/skin/shared/heartbeat-star-lit.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="100%" height="100%"> + <path fill="#0095dd" d="M8,0C7.7,0,7.4,0.2,7.2,0.7l-2,4.1L0.9,5.5c-1,0.2-1.2,0.9-0.5,1.6l3.1,3.3l-0.7,4.6C2.7,15.6,3,16,3.4,16c0.2,0,0.4-0.1,0.6-0.2L8,13.7l3.9,2.1c0.2,0.1,0.5,0.2,0.6,0.2c0.5,0,0.8-0.4,0.7-1.1l-0.7-4.6l3.1-3.3c0.7-0.7,0.4-1.4-0.5-1.6l-4.3-0.7l-2-4.1C8.6,0.2,8.3,0,8,0L8,0z"/> +</svg> diff --git a/toolkit/components/normandy/skin/shared/heartbeat-star-off.svg b/toolkit/components/normandy/skin/shared/heartbeat-star-off.svg new file mode 100644 index 0000000000..143fe48e22 --- /dev/null +++ b/toolkit/components/normandy/skin/shared/heartbeat-star-off.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="100%" height="100%"> + <path fill="#c0c0c0" d="M8,0C7.7,0,7.4,0.2,7.2,0.7l-2,4.1L0.9,5.5c-1,0.2-1.2,0.9-0.5,1.6l3.1,3.3l-0.7,4.6C2.7,15.6,3,16,3.4,16c0.2,0,0.4-0.1,0.6-0.2L8,13.7l3.9,2.1c0.2,0.1,0.5,0.2,0.6,0.2c0.5,0,0.8-0.4,0.7-1.1l-0.7-4.6l3.1-3.3c0.7-0.7,0.4-1.4-0.5-1.6l-4.3-0.7l-2-4.1C8.6,0.2,8.3,0,8,0L8,0z"/> +</svg> diff --git a/toolkit/components/normandy/test/.eslintrc.js b/toolkit/components/normandy/test/.eslintrc.js new file mode 100644 index 0000000000..0ee96759d4 --- /dev/null +++ b/toolkit/components/normandy/test/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + rules: { + "require-yield": 0, + }, +}; diff --git a/toolkit/components/normandy/test/NormandyTestUtils.sys.mjs b/toolkit/components/normandy/test/NormandyTestUtils.sys.mjs new file mode 100644 index 0000000000..0a635a4eae --- /dev/null +++ b/toolkit/components/normandy/test/NormandyTestUtils.sys.mjs @@ -0,0 +1,349 @@ +/* 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/. */ + +import { Preferences } from "resource://gre/modules/Preferences.sys.mjs"; +import { AddonStudies } from "resource://normandy/lib/AddonStudies.sys.mjs"; +import { NormandyUtils } from "resource://normandy/lib/NormandyUtils.sys.mjs"; +import { RecipeRunner } from "resource://normandy/lib/RecipeRunner.sys.mjs"; +import { sinon } from "resource://testing-common/Sinon.sys.mjs"; + +const FIXTURE_ADDON_ID = "normandydriver-a@example.com"; +const UUID_REGEX = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +// Factory IDs +let _addonStudyFactoryId = 0; +let _preferenceStudyFactoryId = 0; +let _preferenceRolloutFactoryId = 0; + +let testGlobals = {}; + +const preferenceBranches = { + user: Preferences, + default: new Preferences({ defaultBranch: true }), +}; + +export const NormandyTestUtils = { + init({ add_task, Assert } = {}) { + testGlobals.add_task = add_task; + testGlobals.Assert = Assert; + }, + + factories: { + addonStudyFactory(attrs = {}) { + for (const key of ["name", "description"]) { + if (attrs && attrs[key]) { + throw new Error( + `${key} is no longer a valid key for addon studies, please update to v2 study schema` + ); + } + } + + // Generate a slug from userFacingName + let recipeId = _addonStudyFactoryId++; + let { userFacingName = `Test study ${recipeId}`, slug } = attrs; + delete attrs.slug; + if (userFacingName && !slug) { + slug = userFacingName.replace(" ", "-").toLowerCase(); + } + + return Object.assign( + { + recipeId, + slug, + userFacingName: "Test study", + userFacingDescription: "test description", + branch: AddonStudies.NO_BRANCHES_MARKER, + active: true, + addonId: FIXTURE_ADDON_ID, + addonUrl: "http://test/addon.xpi", + addonVersion: "1.0.0", + studyStartDate: new Date(), + studyEndDate: null, + extensionApiId: 1, + extensionHash: + "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171", + extensionHashAlgorithm: "sha256", + enrollmentId: NormandyUtils.generateUuid(), + temporaryErrorDeadline: null, + }, + attrs + ); + }, + + branchedAddonStudyFactory(attrs = {}) { + return NormandyTestUtils.factories.addonStudyFactory( + Object.assign( + { + branch: "a", + }, + attrs + ) + ); + }, + + preferenceStudyFactory(attrs = {}) { + const defaultPref = { + "test.study": {}, + }; + const defaultPrefInfo = { + preferenceValue: false, + preferenceType: "boolean", + previousPreferenceValue: null, + preferenceBranchType: "default", + overridden: false, + }; + const preferences = {}; + for (const [prefName, prefInfo] of Object.entries( + attrs.preferences || defaultPref + )) { + preferences[prefName] = { ...defaultPrefInfo, ...prefInfo }; + } + + // Generate a slug from userFacingName + let { + userFacingName = `Test study ${_preferenceStudyFactoryId++}`, + slug, + } = attrs; + delete attrs.slug; + if (userFacingName && !slug) { + slug = userFacingName.replace(" ", "-").toLowerCase(); + } + + return Object.assign( + { + userFacingName, + userFacingDescription: `${userFacingName} description`, + slug, + branch: "control", + expired: false, + lastSeen: new Date().toJSON(), + experimentType: "exp", + enrollmentId: NormandyUtils.generateUuid(), + actionName: "PreferenceExperimentAction", + }, + attrs, + { + preferences, + } + ); + }, + + preferenceRolloutFactory(attrs = {}) { + const defaultPrefInfo = { + preferenceName: "test.rollout.{}", + value: true, + previousValue: false, + }; + const preferences = (attrs.preferences ?? [{}]).map((override, idx) => ({ + ...defaultPrefInfo, + preferenceName: defaultPrefInfo.preferenceName.replace( + "{}", + (idx + 1).toString() + ), + ...override, + })); + + return Object.assign( + { + slug: `test-rollout-${_preferenceRolloutFactoryId++}`, + state: "active", + enrollmentId: NormandyUtils.generateUuid(), + }, + attrs, + { + preferences, + } + ); + }, + }, + + /** + * Combine a list of functions right to left. The rightmost function is passed + * to the preceding function as the argument; the result of this is passed to + * the next function until all are exhausted. For example, this: + * + * decorate(func1, func2, func3); + * + * is equivalent to this: + * + * func1(func2(func3)); + */ + decorate(...args) { + const funcs = Array.from(args); + let decorated = funcs.pop(); + const origName = decorated.name; + funcs.reverse(); + for (const func of funcs) { + decorated = func(decorated); + } + Object.defineProperty(decorated, "name", { value: origName }); + return decorated; + }, + + /** + * Wrapper around add_task for declaring tests that use several with-style + * wrappers. The last argument should be your test function; all other arguments + * should be functions that accept a single test function argument. + * + * The arguments are combined using decorate and passed to add_task as a single + * test function. + * + * @param {[Function]} args + * @example + * decorate_task( + * withMockPreferences(), + * withMockNormandyApi(), + * async function myTest(mockPreferences, mockApi) { + * // Do a test + * } + * ); + */ + decorate_task(...args) { + return testGlobals.add_task(NormandyTestUtils.decorate(...args)); + }, + + isUuid(s) { + return UUID_REGEX.test(s); + }, + + withMockRecipeCollection(recipes = []) { + return function wrapper(testFunc) { + return async function inner(args) { + let recipeIds = new Set(); + for (const recipe of recipes) { + if (!recipe.id || recipeIds.has(recipe.id)) { + throw new Error( + "To use withMockRecipeCollection each recipe must have a unique ID" + ); + } + recipeIds.add(recipe.id); + } + + let db = await RecipeRunner._remoteSettingsClientForTesting.db; + await db.clear(); + const fakeSig = { signature: "abc" }; + + for (const recipe of recipes) { + await db.create({ + id: `recipe-${recipe.id}`, + recipe, + signature: fakeSig, + }); + } + + // last modified needs to be some positive integer + let lastModified = await db.getLastModified(); + await db.importChanges({}, lastModified + 1); + + const mockRecipeCollection = { + async addRecipes(newRecipes) { + for (const recipe of newRecipes) { + if (!recipe.id || recipeIds.has(recipe)) { + throw new Error( + "To use withMockRecipeCollection each recipe must have a unique ID" + ); + } + } + db = await RecipeRunner._remoteSettingsClientForTesting.db; + for (const recipe of newRecipes) { + recipeIds.add(recipe.id); + await db.create({ + id: `recipe-${recipe.id}`, + recipe, + signature: fakeSig, + }); + } + lastModified = (await db.getLastModified()) || 0; + await db.importChanges({}, lastModified + 1); + }, + }; + + try { + await testFunc({ ...args, mockRecipeCollection }); + } finally { + db = await RecipeRunner._remoteSettingsClientForTesting.db; + await db.clear(); + lastModified = await db.getLastModified(); + await db.importChanges({}, lastModified + 1); + } + }; + }; + }, + + MockPreferences: class { + constructor() { + this.oldValues = { user: {}, default: {} }; + } + + set(name, value, branch = "user") { + this.preserve(name, branch); + preferenceBranches[branch].set(name, value); + } + + preserve(name, branch) { + if (!(name in this.oldValues[branch])) { + this.oldValues[branch][name] = preferenceBranches[branch].get( + name, + undefined + ); + } + } + + cleanup() { + for (const [branchName, values] of Object.entries(this.oldValues)) { + const preferenceBranch = preferenceBranches[branchName]; + for (const [name, value] of Object.entries(values)) { + if (value !== undefined) { + preferenceBranch.set(name, value); + } else { + preferenceBranch.reset(name); + } + } + } + } + }, + + withMockPreferences() { + return function (testFunction) { + return async function inner(args) { + const mockPreferences = new NormandyTestUtils.MockPreferences(); + try { + await testFunction({ ...args, mockPreferences }); + } finally { + mockPreferences.cleanup(); + } + }; + }; + }, + + withStub(object, method, { returnValue, as = `${method}Stub` } = {}) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const stub = sinon.stub(object, method); + if (returnValue) { + stub.returns(returnValue); + } + try { + await testFunction({ ...args, [as]: stub }); + } finally { + stub.restore(); + } + }; + }; + }, + + withSpy(object, method, { as = `${method}Spy` } = {}) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const spy = sinon.spy(object, method); + try { + await testFunction({ ...args, [as]: spy }); + } finally { + spy.restore(); + } + }; + }; + }, +}; diff --git a/toolkit/components/normandy/test/browser/action_server.sjs b/toolkit/components/normandy/test/browser/action_server.sjs new file mode 100644 index 0000000000..5d6a3e6bb0 --- /dev/null +++ b/toolkit/components/normandy/test/browser/action_server.sjs @@ -0,0 +1,10 @@ +// Returns JS for an action, regardless of the URL. +function handleRequest(request, response) { + // Allow cross-origin, so you can XHR to it! + response.setHeader("Access-Control-Allow-Origin", "*", false); + // Avoid confusing cache behaviors + response.setHeader("Cache-Control", "no-cache", false); + + // Write response body + response.write('registerAsyncCallback("action", async () => {});'); +} diff --git a/toolkit/components/normandy/test/browser/addons/normandydriver-a-1.0/manifest.json b/toolkit/components/normandy/test/browser/addons/normandydriver-a-1.0/manifest.json new file mode 100644 index 0000000000..fca9426a3f --- /dev/null +++ b/toolkit/components/normandy/test/browser/addons/normandydriver-a-1.0/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "normandy_fixture_a", + "version": "1.0", + "description": "Dummy test fixture that's a webextension, branch A", + "browser_specific_settings": { + "gecko": { + "id": "normandydriver-a@example.com" + } + } +} diff --git a/toolkit/components/normandy/test/browser/addons/normandydriver-a-2.0/manifest.json b/toolkit/components/normandy/test/browser/addons/normandydriver-a-2.0/manifest.json new file mode 100644 index 0000000000..40f7351425 --- /dev/null +++ b/toolkit/components/normandy/test/browser/addons/normandydriver-a-2.0/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "normandy_fixture_a", + "version": "2.0", + "description": "Dummy test fixture that's a webextension, branch A", + "browser_specific_settings": { + "gecko": { + "id": "normandydriver-a@example.com" + } + } +} diff --git a/toolkit/components/normandy/test/browser/addons/normandydriver-b-1.0/manifest.json b/toolkit/components/normandy/test/browser/addons/normandydriver-b-1.0/manifest.json new file mode 100644 index 0000000000..044ae7ebc3 --- /dev/null +++ b/toolkit/components/normandy/test/browser/addons/normandydriver-b-1.0/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "normandy_fixture_b", + "version": "1.0", + "description": "Dummy test fixture that's a webextension, branch B", + "browser_specific_settings": { + "gecko": { + "id": "normandydriver-b@example.com" + } + } +} diff --git a/toolkit/components/normandy/test/browser/browser.ini b/toolkit/components/normandy/test/browser/browser.ini new file mode 100644 index 0000000000..9236d38c1a --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser.ini @@ -0,0 +1,49 @@ +[DEFAULT] +tags = addons +support-files = + action_server.sjs + addons/normandydriver-a-1.0.xpi + addons/normandydriver-b-1.0.xpi + addons/normandydriver-a-2.0.xpi +generated-files = + addons/normandydriver-a-1.0.xpi + addons/normandydriver-b-1.0.xpi + addons/normandydriver-a-2.0.xpi +head = head.js +[browser_ActionsManager.js] +[browser_AddonRollouts.js] +[browser_AddonStudies.js] +skip-if = (verify && (os == 'linux')) +[browser_BaseAction.js] +[browser_CleanupManager.js] +[browser_ClientEnvironment.js] +[browser_EventEmitter.js] +[browser_Heartbeat.js] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_LegacyHeartbeat.js] +[browser_LogManager.js] +[browser_Normandy.js] +[browser_NormandyAddonManager.js] +[browser_NormandyMigrations.js] +[browser_PreferenceExperiments.js] +[browser_PreferenceRollouts.js] +[browser_RecipeRunner.js] +tags = remote-settings +[browser_ShieldPreferences.js] +[browser_Storage.js] +[browser_Uptake.js] +[browser_about_preferences.js] +[browser_about_studies.js] +https_first_disabled = true +[browser_actions_AddonRollbackAction.js] +[browser_actions_AddonRolloutAction.js] +[browser_actions_BranchedAddonStudyAction.js] +[browser_actions_ConsoleLogAction.js] +[browser_actions_MessagingExperimentAction.js] +[browser_actions_PreferenceExperimentAction.js] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_actions_PreferenceRollbackAction.js] +[browser_actions_PreferenceRolloutAction.js] +[browser_actions_ShowHeartbeatAction.js] diff --git a/toolkit/components/normandy/test/browser/browser_ActionsManager.js b/toolkit/components/normandy/test/browser/browser_ActionsManager.js new file mode 100644 index 0000000000..8b5772fa26 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_ActionsManager.js @@ -0,0 +1,68 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { ActionsManager } = ChromeUtils.importESModule( + "resource://normandy/lib/ActionsManager.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); +const { ActionSchemas } = ChromeUtils.importESModule( + "resource://normandy/actions/schemas/index.sys.mjs" +); + +// Test life cycle methods for actions +decorate_task(async function (reportActionStub, Stub) { + let manager = new ActionsManager(); + const recipe = { id: 1, action: "test-local-action-used" }; + + let actionUsed = { + processRecipe: sinon.stub(), + finalize: sinon.stub(), + }; + let actionUnused = { + processRecipe: sinon.stub(), + finalize: sinon.stub(), + }; + manager.localActions = { + "test-local-action-used": actionUsed, + "test-local-action-unused": actionUnused, + }; + + await manager.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await manager.finalize(); + + Assert.deepEqual( + actionUsed.processRecipe.args, + [[recipe, BaseAction.suitability.FILTER_MATCH]], + "used action should be called with the recipe" + ); + ok( + actionUsed.finalize.calledOnce, + "finalize should be called on used action" + ); + Assert.deepEqual( + actionUnused.processRecipe.args, + [], + "unused action should not be called with the recipe" + ); + ok( + actionUnused.finalize.calledOnce, + "finalize should be called on the unused action" + ); +}); + +decorate_task(async function () { + for (const [name, Constructor] of Object.entries( + ActionsManager.actionConstructors + )) { + const action = new Constructor(); + Assert.deepEqual( + ActionSchemas[name], + action.schema, + "action name should map to a schema" + ); + } +}); diff --git a/toolkit/components/normandy/test/browser/browser_AddonRollouts.js b/toolkit/components/normandy/test/browser/browser_AddonRollouts.js new file mode 100644 index 0000000000..0826907b68 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_AddonRollouts.js @@ -0,0 +1,141 @@ +"use strict"; + +const { IndexedDB } = ChromeUtils.importESModule( + "resource://gre/modules/IndexedDB.sys.mjs" +); + +const { AddonRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonRollouts.sys.mjs" +); + +decorate_task(AddonRollouts.withTestMock(), async function testGetMissing() { + ok( + !(await AddonRollouts.get("does-not-exist")), + "get should return null when the requested rollout does not exist" + ); +}); + +decorate_task( + AddonRollouts.withTestMock(), + async function testAddUpdateAndGet() { + const rollout = { + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extension: {}, + }; + await AddonRollouts.add(rollout); + let storedRollout = await AddonRollouts.get(rollout.slug); + Assert.deepEqual( + rollout, + storedRollout, + "get should retrieve a rollout from storage." + ); + + rollout.state = AddonRollouts.STATE_ROLLED_BACK; + await AddonRollouts.update(rollout); + storedRollout = await AddonRollouts.get(rollout.slug); + Assert.deepEqual( + rollout, + storedRollout, + "get should retrieve a rollout from storage." + ); + } +); + +decorate_task( + AddonRollouts.withTestMock(), + async function testCantUpdateNonexistent() { + const rollout = { + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extensions: {}, + }; + await Assert.rejects( + AddonRollouts.update(rollout), + /doesn't already exist/, + "Update should fail if the rollout doesn't exist" + ); + ok( + !(await AddonRollouts.has("test-rollout")), + "rollout should not have been added" + ); + } +); + +decorate_task(AddonRollouts.withTestMock(), async function testGetAll() { + const rollout1 = { slug: "test-rollout-1", extension: {} }; + const rollout2 = { slug: "test-rollout-2", extension: {} }; + await AddonRollouts.add(rollout1); + await AddonRollouts.add(rollout2); + + const storedRollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + storedRollouts.sort((a, b) => a.id - b.id), + [rollout1, rollout2], + "getAll should return every stored rollout." + ); +}); + +decorate_task(AddonRollouts.withTestMock(), async function testGetAllActive() { + const rollout1 = { + slug: "test-rollout-1", + state: AddonRollouts.STATE_ACTIVE, + }; + const rollout3 = { + slug: "test-rollout-2", + state: AddonRollouts.STATE_ROLLED_BACK, + }; + await AddonRollouts.add(rollout1); + await AddonRollouts.add(rollout3); + + const activeRollouts = await AddonRollouts.getAllActive(); + Assert.deepEqual( + activeRollouts, + [rollout1], + "getAllActive should return only active rollouts" + ); +}); + +decorate_task(AddonRollouts.withTestMock(), async function testHas() { + const rollout = { slug: "test-rollout", extensions: {} }; + await AddonRollouts.add(rollout); + ok( + await AddonRollouts.has(rollout.slug), + "has should return true for an existing rollout" + ); + ok( + !(await AddonRollouts.has("does not exist")), + "has should return false for a missing rollout" + ); +}); + +// init should mark active rollouts in telemetry +decorate_task( + AddonRollouts.withTestMock(), + withStub(TelemetryEnvironment, "setExperimentActive"), + async function testInitTelemetry({ setExperimentActiveStub }) { + await AddonRollouts.add({ + slug: "test-rollout-active-1", + state: AddonRollouts.STATE_ACTIVE, + }); + await AddonRollouts.add({ + slug: "test-rollout-active-2", + state: AddonRollouts.STATE_ACTIVE, + }); + await AddonRollouts.add({ + slug: "test-rollout-rolled-back", + state: AddonRollouts.STATE_ROLLED_BACK, + }); + + await AddonRollouts.init(); + + Assert.deepEqual( + setExperimentActiveStub.args, + [ + ["test-rollout-active-1", "active", { type: "normandy-addonrollout" }], + ["test-rollout-active-2", "active", { type: "normandy-addonrollout" }], + ], + "init should set activate a telemetry experiment for active addons" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_AddonStudies.js b/toolkit/components/normandy/test/browser/browser_AddonStudies.js new file mode 100644 index 0000000000..44417fef89 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_AddonStudies.js @@ -0,0 +1,300 @@ +"use strict"; + +const { IndexedDB } = ChromeUtils.importESModule( + "resource://gre/modules/IndexedDB.sys.mjs" +); + +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); +const { addonStudyFactory, branchedAddonStudyFactory } = + NormandyTestUtils.factories; + +// Initialize test utils +AddonTestUtils.initMochitest(this); + +decorate_task(AddonStudies.withStudies(), async function testGetMissing() { + ok( + !(await AddonStudies.get("does-not-exist")), + "get returns null when the requested study does not exist" + ); +}); + +decorate_task( + AddonStudies.withStudies([addonStudyFactory({ slug: "test-study" })]), + async function testGet({ addonStudies: [study] }) { + const storedStudy = await AddonStudies.get(study.recipeId); + Assert.deepEqual(study, storedStudy, "get retrieved a study from storage."); + } +); + +decorate_task( + AddonStudies.withStudies([addonStudyFactory(), addonStudyFactory()]), + async function testGetAll({ addonStudies }) { + const storedStudies = await AddonStudies.getAll(); + Assert.deepEqual( + new Set(storedStudies), + new Set(addonStudies), + "getAll returns every stored study." + ); + } +); + +decorate_task( + AddonStudies.withStudies([addonStudyFactory({ slug: "test-study" })]), + async function testHas({ addonStudies: [study] }) { + let hasStudy = await AddonStudies.has(study.recipeId); + ok(hasStudy, "has returns true for a study that exists in storage."); + + hasStudy = await AddonStudies.has("does-not-exist"); + ok( + !hasStudy, + "has returns false for a study that doesn't exist in storage." + ); + } +); + +decorate_task( + AddonStudies.withStudies([ + addonStudyFactory({ slug: "test-study1" }), + addonStudyFactory({ slug: "test-study2" }), + ]), + async function testClear({ addonStudies: [study1, study2] }) { + const hasAll = + (await AddonStudies.has(study1.recipeId)) && + (await AddonStudies.has(study2.recipeId)); + ok(hasAll, "Before calling clear, both studies are in storage."); + + await AddonStudies.clear(); + const hasAny = + (await AddonStudies.has(study1.recipeId)) || + (await AddonStudies.has(study2.recipeId)); + ok(!hasAny, "After calling clear, all studies are removed from storage."); + } +); + +decorate_task( + AddonStudies.withStudies([addonStudyFactory({ slug: "foo" })]), + async function testUpdate({ addonStudies: [study] }) { + Assert.deepEqual(await AddonStudies.get(study.recipeId), study); + + const updatedStudy = { + ...study, + slug: "bar", + }; + await AddonStudies.update(updatedStudy); + + Assert.deepEqual(await AddonStudies.get(study.recipeId), updatedStudy); + } +); + +decorate_task( + AddonStudies.withStudies([ + addonStudyFactory({ + active: true, + addonId: "does.not.exist@example.com", + studyEndDate: null, + }), + addonStudyFactory({ active: true, addonId: "installed@example.com" }), + addonStudyFactory({ + active: false, + addonId: "already.gone@example.com", + studyEndDate: new Date(2012, 1), + }), + ]), + withSendEventSpy(), + withInstalledWebExtension( + { id: "installed@example.com" }, + { expectUninstall: true } + ), + async function testInit({ + addonStudies: [activeUninstalledStudy, activeInstalledStudy, inactiveStudy], + sendEventSpy, + installedWebExtension: { addonId }, + }) { + await AddonStudies.init(); + + const newActiveStudy = await AddonStudies.get( + activeUninstalledStudy.recipeId + ); + ok( + !newActiveStudy.active, + "init marks studies as inactive if their add-on is not installed." + ); + ok( + newActiveStudy.studyEndDate, + "init sets the study end date if a study's add-on is not installed." + ); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter(e => e[1] == "normandy"); + Assert.deepEqual( + events[0].slice(2), // strip timestamp and "normandy" + [ + "unenroll", + "addon_study", + activeUninstalledStudy.slug, + { + addonId: activeUninstalledStudy.addonId, + addonVersion: activeUninstalledStudy.addonVersion, + reason: "uninstalled-sideload", + branch: AddonStudies.NO_BRANCHES_MARKER, + enrollmentId: events[0][5].enrollmentId, + }, + ], + "AddonStudies.init() should send the correct telemetry event" + ); + ok( + NormandyTestUtils.isUuid(events[0][5].enrollmentId), + "enrollment ID should be a UUID" + ); + + const newInactiveStudy = await AddonStudies.get(inactiveStudy.recipeId); + is( + newInactiveStudy.studyEndDate.getFullYear(), + 2012, + "init does not modify inactive studies." + ); + + const newActiveInstalledStudy = await AddonStudies.get( + activeInstalledStudy.recipeId + ); + Assert.deepEqual( + activeInstalledStudy, + newActiveInstalledStudy, + "init does not modify studies whose add-on is still installed." + ); + + // Only activeUninstalledStudy should have generated any events + ok(sendEventSpy.calledOnce, "no extra events should be generated"); + + // Clean up + const addon = await AddonManager.getAddonByID(addonId); + await addon.uninstall(); + await TestUtils.topicObserved("shield-study-ended", (subject, message) => { + return message === `${activeInstalledStudy.recipeId}`; + }); + } +); + +// init should register telemetry experiments +decorate_task( + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + active: true, + addonId: "installed1@example.com", + }), + branchedAddonStudyFactory({ + active: true, + addonId: "installed2@example.com", + }), + ]), + withInstalledWebExtensionSafe({ id: "installed1@example.com" }), + withInstalledWebExtension({ id: "installed2@example.com" }), + withStub(TelemetryEnvironment, "setExperimentActive"), + async function testInit({ addonStudies, setExperimentActiveStub }) { + await AddonStudies.init(); + Assert.deepEqual( + setExperimentActiveStub.args, + [ + [ + addonStudies[0].slug, + addonStudies[0].branch, + { + type: "normandy-addonstudy", + enrollmentId: addonStudies[0].enrollmentId, + }, + ], + [ + addonStudies[1].slug, + addonStudies[1].branch, + { + type: "normandy-addonstudy", + enrollmentId: addonStudies[1].enrollmentId, + }, + ], + ], + "Add-on studies are registered in Telemetry by AddonStudies.init" + ); + } +); + +// Test that AddonStudies.init() ends studies that have been uninstalled +decorate_task( + AddonStudies.withStudies([ + addonStudyFactory({ + active: true, + addonId: "installed@example.com", + studyEndDate: null, + }), + ]), + withInstalledWebExtension( + { id: "installed@example.com" }, + { expectUninstall: true } + ), + async function testInit({ + addonStudies: [study], + installedWebExtension: { addonId }, + }) { + const addon = await AddonManager.getAddonByID(addonId); + await addon.uninstall(); + await TestUtils.topicObserved("shield-study-ended", (subject, message) => { + return message === `${study.recipeId}`; + }); + + const newStudy = await AddonStudies.get(study.recipeId); + ok( + !newStudy.active, + "Studies are marked as inactive when their add-on is uninstalled." + ); + ok( + newStudy.studyEndDate, + "The study end date is set when the add-on for the study is uninstalled." + ); + } +); + +decorate_task( + AddonStudies.withStudies([ + NormandyTestUtils.factories.addonStudyFactory({ active: true }), + NormandyTestUtils.factories.branchedAddonStudyFactory(), + ]), + async function testRemoveOldAddonStudies({ + addonStudies: [noBranchStudy, branchedStudy], + }) { + // pre check, both studies are active + const preActiveIds = (await AddonStudies.getAllActive()).map( + addon => addon.recipeId + ); + Assert.deepEqual( + preActiveIds, + [noBranchStudy.recipeId, branchedStudy.recipeId], + "Both studies should be active" + ); + + // run the migration + await AddonStudies.migrations.migration02RemoveOldAddonStudyAction(); + + // The unbrached study should end + const postActiveIds = (await AddonStudies.getAllActive()).map( + addon => addon.recipeId + ); + Assert.deepEqual( + postActiveIds, + [branchedStudy.recipeId], + "The unbranched study should end" + ); + + // But both studies should still be present + const postAllIds = (await AddonStudies.getAll()).map( + addon => addon.recipeId + ); + Assert.deepEqual( + postAllIds, + [noBranchStudy.recipeId, branchedStudy.recipeId], + "Both studies should still be present" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_BaseAction.js b/toolkit/components/normandy/test/browser/browser_BaseAction.js new file mode 100644 index 0000000000..0de9ce2405 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_BaseAction.js @@ -0,0 +1,349 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); + +class NoopAction extends BaseAction { + constructor() { + super(); + this._testPreExecutionFlag = false; + this._testRunFlag = false; + this._testFinalizeFlag = false; + } + + _preExecution() { + this._testPreExecutionFlag = true; + } + + _run(recipe) { + this._testRunFlag = true; + } + + _finalize() { + this._testFinalizeFlag = true; + } +} + +NoopAction._errorToThrow = new Error("test error"); + +class FailPreExecutionAction extends NoopAction { + _preExecution() { + throw NoopAction._errorToThrow; + } +} + +class FailRunAction extends NoopAction { + _run(recipe) { + throw NoopAction._errorToThrow; + } +} + +class FailFinalizeAction extends NoopAction { + _finalize() { + throw NoopAction._errorToThrow; + } +} + +// Test that constructor and override methods are run +decorate_task( + withStub(Uptake, "reportRecipe"), + withStub(Uptake, "reportAction"), + async () => { + let action = new NoopAction(); + is( + action._testPreExecutionFlag, + false, + "_preExecution should not have been called on a new action" + ); + is( + action._testRunFlag, + false, + "_run has should not have been called on a new action" + ); + is( + action._testFinalizeFlag, + false, + "_finalize should not be called on a new action" + ); + + const recipe = recipeFactory(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is( + action._testPreExecutionFlag, + true, + "_preExecution should be called when a recipe is executed" + ); + is( + action._testRunFlag, + true, + "_run should be called when a recipe is executed" + ); + is( + action._testFinalizeFlag, + false, + "_finalize should not have been called when a recipe is executed" + ); + + await action.finalize(); + is( + action._testFinalizeFlag, + true, + "_finalizeExecution should be called when finalize was called" + ); + + action = new NoopAction(); + await action.finalize(); + is( + action._testPreExecutionFlag, + true, + "_preExecution should be called when finalized even if no recipes" + ); + is( + action._testRunFlag, + false, + "_run should be called if no recipes were run" + ); + is( + action._testFinalizeFlag, + true, + "_finalize should be called when finalized" + ); + } +); + +// Test that per-recipe uptake telemetry is recorded +decorate_task( + withStub(Uptake, "reportRecipe"), + async function ({ reportRecipeStub }) { + const action = new NoopAction(); + const recipe = recipeFactory(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + Assert.deepEqual( + reportRecipeStub.args, + [[recipe, Uptake.RECIPE_SUCCESS]], + "per-recipe uptake telemetry should be reported" + ); + } +); + +// Finalize causes action telemetry to be recorded +decorate_task( + withStub(Uptake, "reportAction"), + async function ({ reportActionStub }) { + const action = new NoopAction(); + await action.finalize(); + ok( + action.state == NoopAction.STATE_FINALIZED, + "Action should be marked as finalized" + ); + Assert.deepEqual( + reportActionStub.args, + [[action.name, Uptake.ACTION_SUCCESS]], + "action uptake telemetry should be reported" + ); + } +); + +// Recipes can't be run after finalize is called +decorate_task( + withStub(Uptake, "reportRecipe"), + async function ({ reportRecipeStub }) { + const action = new NoopAction(); + const recipe1 = recipeFactory(); + const recipe2 = recipeFactory(); + + await action.processRecipe(recipe1, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + await Assert.rejects( + action.processRecipe(recipe2, BaseAction.suitability.FILTER_MATCH), + /^Error: Action has already been finalized$/, + "running recipes after finalization is an error" + ); + + Assert.deepEqual( + reportRecipeStub.args, + [[recipe1, Uptake.RECIPE_SUCCESS]], + "Only recipes executed prior to finalizer should report uptake telemetry" + ); + } +); + +// Test an action with a failing pre-execution step +decorate_task( + withStub(Uptake, "reportRecipe"), + withStub(Uptake, "reportAction"), + async function ({ reportRecipeStub, reportActionStub }) { + const recipe = recipeFactory(); + const action = new FailPreExecutionAction(); + is( + action.state, + FailPreExecutionAction.STATE_PREPARING, + "Pre-execution should not happen immediately" + ); + + // Should fail, putting the action into a "failed" state, but the entry + // point `processRecipe` should not itself throw an exception. + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is( + action.state, + FailPreExecutionAction.STATE_FAILED, + "Action fails if pre-execution fails" + ); + is( + action.lastError, + NoopAction._errorToThrow, + "The thrown error should be stored in lastError" + ); + + // Should not throw, even though the action is in a disabled state. + await action.finalize(); + is( + action.state, + FailPreExecutionAction.STATE_FINALIZED, + "Action should be finalized" + ); + is( + action.lastError, + NoopAction._errorToThrow, + "lastError should not have changed" + ); + + is(action._testRunFlag, false, "_run should not have been called"); + is( + action._testFinalizeFlag, + false, + "_finalize should not have been called" + ); + + Assert.deepEqual( + reportRecipeStub.args, + [[recipe, Uptake.RECIPE_ACTION_DISABLED]], + "Recipe should report recipe status as action disabled" + ); + + Assert.deepEqual( + reportActionStub.args, + [[action.name, Uptake.ACTION_PRE_EXECUTION_ERROR]], + "Action should report pre execution error" + ); + } +); + +// Test an action with a failing recipe step +decorate_task( + withStub(Uptake, "reportRecipe"), + withStub(Uptake, "reportAction"), + async function ({ reportRecipeStub, reportActionStub }) { + const recipe = recipeFactory(); + const action = new FailRunAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is( + action.state, + FailRunAction.STATE_READY, + "Action should not be marked as failed due to a recipe failure" + ); + await action.finalize(); + is( + action.state, + FailRunAction.STATE_FINALIZED, + "Action should be marked as finalized after finalize is called" + ); + + ok(action._testFinalizeFlag, "_finalize should have been called"); + + Assert.deepEqual( + reportRecipeStub.args, + [[recipe, Uptake.RECIPE_EXECUTION_ERROR]], + "Recipe should report recipe execution error" + ); + + Assert.deepEqual( + reportActionStub.args, + [[action.name, Uptake.ACTION_SUCCESS]], + "Action should report success" + ); + } +); + +// Test an action with a failing finalize step +decorate_task( + withStub(Uptake, "reportRecipe"), + withStub(Uptake, "reportAction"), + async function ({ reportRecipeStub, reportActionStub }) { + const recipe = recipeFactory(); + const action = new FailFinalizeAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual( + reportRecipeStub.args, + [[recipe, Uptake.RECIPE_SUCCESS]], + "Recipe should report success" + ); + + Assert.deepEqual( + reportActionStub.args, + [[action.name, Uptake.ACTION_POST_EXECUTION_ERROR]], + "Action should report post execution error" + ); + } +); + +// Disable disables an action +decorate_task( + withStub(Uptake, "reportRecipe"), + withStub(Uptake, "reportAction"), + async function ({ reportRecipeStub, reportActionStub }) { + const recipe = recipeFactory(); + const action = new NoopAction(); + + action.disable(); + is( + action.state, + NoopAction.STATE_DISABLED, + "Action should be marked as disabled" + ); + + // Should not throw, even though the action is disabled + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + + // Should not throw, even though the action is disabled + await action.finalize(); + + is(action._testRunFlag, false, "_run should not have been called"); + is( + action._testFinalizeFlag, + false, + "_finalize should not have been called" + ); + + Assert.deepEqual( + reportActionStub.args, + [[action.name, Uptake.ACTION_SUCCESS]], + "Action should not report pre execution error" + ); + + Assert.deepEqual( + reportRecipeStub.args, + [[recipe, Uptake.RECIPE_ACTION_DISABLED]], + "Recipe should report recipe status as action disabled" + ); + } +); + +// If the capabilities don't match, processRecipe shouldn't validate the arguments +decorate_task(async function () { + const recipe = recipeFactory(); + const action = new NoopAction(); + const verifySpy = sinon.spy(action, "validateArguments"); + await action.processRecipe( + recipe, + BaseAction.suitability.CAPABILITIES_MISMATCH + ); + ok(!verifySpy.called, "validateArguments should not be called"); +}); diff --git a/toolkit/components/normandy/test/browser/browser_CleanupManager.js b/toolkit/components/normandy/test/browser/browser_CleanupManager.js new file mode 100644 index 0000000000..f1b4930394 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_CleanupManager.js @@ -0,0 +1,26 @@ +"use strict"; + +const { CleanupManager } = ChromeUtils.importESModule( + "resource://normandy/lib/CleanupManager.sys.mjs" +); /* global CleanupManagerClass */ + +add_task(async function testCleanupManager() { + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const spy3 = sinon.spy(); + + const manager = new CleanupManager.constructor(); + manager.addCleanupHandler(spy1); + manager.addCleanupHandler(spy2); + manager.addCleanupHandler(spy3); + manager.removeCleanupHandler(spy2); // Test removal + + await manager.cleanup(); + ok(spy1.called, "cleanup called the spy1 handler"); + ok(!spy2.called, "cleanup did not call the spy2 handler"); + ok(spy3.called, "cleanup called the spy3 handler"); + + await manager.cleanup(); + ok(spy1.calledOnce, "cleanup only called the spy1 handler once"); + ok(spy3.calledOnce, "cleanup only called the spy3 handler once"); +}); diff --git a/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js b/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js new file mode 100644 index 0000000000..1b6d2c5ff9 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js @@ -0,0 +1,274 @@ +"use strict"; + +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); + +const { AddonRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonRollouts.sys.mjs" +); +const { ClientEnvironment } = ChromeUtils.importESModule( + "resource://normandy/lib/ClientEnvironment.sys.mjs" +); +const { PreferenceExperiments } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceExperiments.sys.mjs" +); +const { PreferenceRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceRollouts.sys.mjs" +); +const { RecipeRunner } = ChromeUtils.importESModule( + "resource://normandy/lib/RecipeRunner.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +add_task(async function testTelemetry() { + // setup + await SpecialPowers.pushPrefEnv({ + set: [["privacy.reduceTimerPrecision", true]], + }); + + await TelemetryController.submitExternalPing("testfoo", { foo: 1 }); + await TelemetryController.submitExternalPing("testbar", { bar: 2 }); + await TelemetryController.submitExternalPing("testfoo", { foo: 3 }); + + // Test it can access telemetry + const telemetry = await ClientEnvironment.telemetry; + is(typeof telemetry, "object", "Telemetry is accesible"); + + // Test it reads different types of telemetry + is( + telemetry.testfoo.payload.foo, + 3, + "telemetry filters pull the latest ping from a type" + ); + is( + telemetry.testbar.payload.bar, + 2, + "telemetry filters pull from submitted telemetry pings" + ); +}); + +add_task(async function testUserId() { + // Test that userId is available + ok(NormandyTestUtils.isUuid(ClientEnvironment.userId), "userId available"); + + // test that it pulls from the right preference + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.user_id", "fake id"]], + }); + is(ClientEnvironment.userId, "fake id", "userId is pulled from preferences"); +}); + +add_task(async function testDistribution() { + // distribution id defaults to "default" for most builds, and + // "mozilla-MSIX" for MSIX builds. + is( + ClientEnvironment.distribution, + AppConstants.platform === "win" && + Services.sysinfo.getProperty("hasWinPackageId") + ? "mozilla-MSIX" + : "default", + "distribution has a default value" + ); + + // distribution id is read from a preference + Services.prefs + .getDefaultBranch(null) + .setStringPref("distribution.id", "funnelcake"); + is( + ClientEnvironment.distribution, + "funnelcake", + "distribution is read from preferences" + ); + Services.prefs + .getDefaultBranch(null) + .setStringPref("distribution.id", "default"); +}); + +const mockClassify = { country: "FR", request_time: new Date(2017, 1, 1) }; +add_task( + ClientEnvironment.withMockClassify( + mockClassify, + async function testCountryRequestTime() { + // Test that country and request_time pull their data from the server. + is( + await ClientEnvironment.country, + mockClassify.country, + "country is read from the server API" + ); + is( + await ClientEnvironment.request_time, + mockClassify.request_time, + "request_time is read from the server API" + ); + } + ) +); + +add_task(async function testSync() { + is( + ClientEnvironment.syncMobileDevices, + 0, + "syncMobileDevices defaults to zero" + ); + is( + ClientEnvironment.syncDesktopDevices, + 0, + "syncDesktopDevices defaults to zero" + ); + is( + ClientEnvironment.syncTotalDevices, + 0, + "syncTotalDevices defaults to zero" + ); + await SpecialPowers.pushPrefEnv({ + set: [ + ["services.sync.clients.devices.mobile", 5], + ["services.sync.clients.devices.desktop", 4], + ], + }); + is( + ClientEnvironment.syncMobileDevices, + 5, + "syncMobileDevices is read when set" + ); + is( + ClientEnvironment.syncDesktopDevices, + 4, + "syncDesktopDevices is read when set" + ); + is( + ClientEnvironment.syncTotalDevices, + 9, + "syncTotalDevices is read when set" + ); +}); + +add_task(async function testDoNotTrack() { + // doNotTrack defaults to false + ok(!ClientEnvironment.doNotTrack, "doNotTrack has a default value"); + + // doNotTrack is read from a preference + await SpecialPowers.pushPrefEnv({ + set: [["privacy.donottrackheader.enabled", true]], + }); + ok(ClientEnvironment.doNotTrack, "doNotTrack is read from preferences"); +}); + +add_task(async function testExperiments() { + const active = { slug: "active", expired: false }; + const expired = { slug: "expired", expired: true }; + const getAll = sinon + .stub(PreferenceExperiments, "getAll") + .callsFake(async () => [active, expired]); + + const experiments = await ClientEnvironment.experiments; + Assert.deepEqual( + experiments.all, + ["active", "expired"], + "experiments.all returns all stored experiment names" + ); + Assert.deepEqual( + experiments.active, + ["active"], + "experiments.active returns all active experiment names" + ); + Assert.deepEqual( + experiments.expired, + ["expired"], + "experiments.expired returns all expired experiment names" + ); + + getAll.restore(); +}); + +add_task(async function isFirstRun() { + await SpecialPowers.pushPrefEnv({ set: [["app.normandy.first_run", true]] }); + ok(ClientEnvironment.isFirstRun, "isFirstRun is read from preferences"); +}); + +decorate_task( + PreferenceExperiments.withMockExperiments([ + NormandyTestUtils.factories.preferenceStudyFactory({ + branch: "a-test-branch", + }), + ]), + AddonStudies.withStudies([ + NormandyTestUtils.factories.branchedAddonStudyFactory({ + branch: "b-test-branch", + }), + ]), + async function testStudies({ + prefExperiments: [prefExperiment], + addonStudies: [addonStudy], + }) { + Assert.deepEqual( + await ClientEnvironment.studies, + { + pref: { + [prefExperiment.slug]: prefExperiment, + }, + addon: { + [addonStudy.slug]: addonStudy, + }, + }, + "addon and preference studies shold be accessible" + ); + is( + (await ClientEnvironment.studies).pref[prefExperiment.slug].branch, + "a-test-branch", + "A specific preference experiment field should be accessible in the context" + ); + is( + (await ClientEnvironment.studies).addon[addonStudy.slug].branch, + "b-test-branch", + "A specific addon study field should be accessible in the context" + ); + + ok(RecipeRunner.getCapabilities().has("jexl.context.normandy.studies")); + ok(RecipeRunner.getCapabilities().has("jexl.context.env.studies")); + } +); + +decorate_task(PreferenceRollouts.withTestMock(), async function testRollouts() { + const prefRollout = { + slug: "test-rollout", + preference: [], + enrollmentId: "test-enrollment-id-1", + }; + await PreferenceRollouts.add(prefRollout); + const addonRollout = { + slug: "test-rollout-1", + extension: {}, + enrollmentId: "test-enrollment-id-2", + }; + await AddonRollouts.add(addonRollout); + + Assert.deepEqual( + await ClientEnvironment.rollouts, + { + pref: { + [prefRollout.slug]: prefRollout, + }, + addon: { + [addonRollout.slug]: addonRollout, + }, + }, + "addon and preference rollouts should be accessible" + ); + is( + (await ClientEnvironment.rollouts).pref[prefRollout.slug].enrollmentId, + "test-enrollment-id-1", + "A specific preference rollout field should be accessible in the context" + ); + is( + (await ClientEnvironment.rollouts).addon[addonRollout.slug].enrollmentId, + "test-enrollment-id-2", + "A specific addon rollout field should be accessible in the context" + ); + + ok(RecipeRunner.getCapabilities().has("jexl.context.normandy.rollouts")); + ok(RecipeRunner.getCapabilities().has("jexl.context.env.rollouts")); +}); diff --git a/toolkit/components/normandy/test/browser/browser_EventEmitter.js b/toolkit/components/normandy/test/browser/browser_EventEmitter.js new file mode 100644 index 0000000000..a64c52896f --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_EventEmitter.js @@ -0,0 +1,110 @@ +"use strict"; + +const { EventEmitter } = ChromeUtils.importESModule( + "resource://normandy/lib/EventEmitter.sys.mjs" +); + +const evidence = { + a: 0, + b: 0, + c: 0, + log: "", +}; + +function listenerA(x) { + evidence.a += x; + evidence.log += "a"; +} + +function listenerB(x) { + evidence.b += x; + evidence.log += "b"; +} + +function listenerC(x) { + evidence.c += x; + evidence.log += "c"; +} + +decorate_task(async function () { + const eventEmitter = new EventEmitter(); + + // Fire an unrelated event, to make sure nothing goes wrong + eventEmitter.on("nothing"); + + // bind listeners + eventEmitter.on("event", listenerA); + eventEmitter.on("event", listenerB); + eventEmitter.once("event", listenerC); + + // one event for all listeners + eventEmitter.emit("event", 1); + // another event for a and b, since c should have turned off already + eventEmitter.emit("event", 10); + + // make sure events haven't actually fired yet, just queued + Assert.deepEqual( + evidence, + { + a: 0, + b: 0, + c: 0, + log: "", + }, + "events are fired async" + ); + + // Spin the event loop to run events, so we can safely "off" + await Promise.resolve(); + + // Check intermediate event results + Assert.deepEqual( + evidence, + { + a: 11, + b: 11, + c: 1, + log: "abcab", + }, + "intermediate events are fired" + ); + + // one more event for a + eventEmitter.off("event", listenerB); + eventEmitter.emit("event", 100); + + // And another unrelated event + eventEmitter.on("nothing"); + + // Spin the event loop to run events + await Promise.resolve(); + + Assert.deepEqual( + evidence, + { + a: 111, + b: 11, + c: 1, + log: "abcaba", // events are in order + }, + "events fired as expected" + ); + + // Test that mutating the data passed to the event doesn't actually + // mutate it for other events. + let handlerRunCount = 0; + const mutationHandler = data => { + handlerRunCount++; + data.count++; + is(data.count, 1, "Event data is not mutated between handlers."); + }; + eventEmitter.on("mutationTest", mutationHandler); + eventEmitter.on("mutationTest", mutationHandler); + + const data = { count: 0 }; + eventEmitter.emit("mutationTest", data); + await Promise.resolve(); + + is(handlerRunCount, 2, "Mutation handler was executed twice."); + is(data.count, 0, "Event data cannot be mutated by handlers."); +}); diff --git a/toolkit/components/normandy/test/browser/browser_Heartbeat.js b/toolkit/components/normandy/test/browser/browser_Heartbeat.js new file mode 100644 index 0000000000..0166c4d7b0 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_Heartbeat.js @@ -0,0 +1,262 @@ +"use strict"; + +const { Heartbeat } = ChromeUtils.importESModule( + "resource://normandy/lib/Heartbeat.sys.mjs" +); + +/** + * Assert an array is in non-descending order, and that every element is a number + */ +function assertOrdered(arr) { + for (let i = 0; i < arr.length; i++) { + Assert.equal(typeof arr[i], "number", `element ${i} is type "number"`); + } + for (let i = 0; i < arr.length - 1; i++) { + Assert.lessOrEqual( + arr[i], + arr[i + 1], + `element ${i} is less than or equal to element ${i + 1}` + ); + } +} + +/* Close every notification in a target window and notification box */ +function closeAllNotifications(targetWindow, notificationBox) { + if (notificationBox.allNotifications.length === 0) { + return Promise.resolve(); + } + + return new Promise(resolve => { + const notificationSet = new Set(notificationBox.allNotifications); + + const observer = new targetWindow.MutationObserver(mutations => { + for (const mutation of mutations) { + for (let i = 0; i < mutation.removedNodes.length; i++) { + const node = mutation.removedNodes.item(i); + if (notificationSet.has(node)) { + notificationSet.delete(node); + } + } + } + if (notificationSet.size === 0) { + Assert.equal( + notificationBox.allNotifications.length, + 0, + "No notifications left" + ); + observer.disconnect(); + resolve(); + } + }); + + observer.observe(notificationBox.stack, { childList: true }); + + for (const notification of notificationBox.allNotifications) { + notification.close(); + } + }); +} + +/* Check that the correct telemetry was sent */ +function assertTelemetrySent(hb, eventNames) { + return new Promise(resolve => { + hb.eventEmitter.once("TelemetrySent", payload => { + const events = [0]; + for (const name of eventNames) { + Assert.equal( + typeof payload[name], + "number", + `payload field ${name} is a number` + ); + events.push(payload[name]); + } + events.push(Date.now()); + + assertOrdered(events); + resolve(); + }); + }); +} + +function getStars(notice) { + return notice.buttonContainer.querySelectorAll(".star-x"); +} + +add_setup(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + // Open a new tab to keep the window open. + await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "https://example.com" + ); +}); + +// Several of the behaviors of heartbeat prompt are mutually exclusive, so checks are broken up +// into three batches. + +/* Batch #1 - General UI, Stars, and telemetry data */ +add_task(async function () { + const targetWindow = Services.wm.getMostRecentWindow("navigator:browser"); + const notificationBox = targetWindow.gNotificationBox; + + const preCount = notificationBox.allNotifications.length; + const hb = new Heartbeat(targetWindow, { + testing: true, + flowId: "test", + message: "test", + engagementButtonLabel: undefined, + learnMoreMessage: "Learn More", + learnMoreUrl: "https://example.org/learnmore", + }); + + // Check UI + const learnMoreEl = hb.notice.messageText.querySelector(".text-link"); + Assert.equal( + notificationBox.allNotifications.length, + preCount + 1, + "Correct number of notifications open" + ); + Assert.equal(getStars(hb.notice).length, 5, "Correct number of stars"); + Assert.equal( + hb.notice.buttonContainer.querySelectorAll(".notification-button").length, + 0, + "Engagement button not shown" + ); + Assert.equal( + learnMoreEl.href, + "https://example.org/learnmore", + "Learn more url correct" + ); + Assert.equal(learnMoreEl.value, "Learn More", "Learn more label correct"); + // There's a space included before the learn more link in proton. + Assert.equal( + hb.notice.messageText.textContent, + "test ", + "Message is correct" + ); + + // Check that when clicking the learn more link, a tab opens with the right URL + let loadedPromise; + const tabOpenPromise = new Promise(resolve => { + targetWindow.gBrowser.tabContainer.addEventListener( + "TabOpen", + event => { + let tab = event.target; + loadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + url => url && url !== "about:blank" + ); + resolve(tab); + }, + { once: true } + ); + }); + learnMoreEl.click(); + const tab = await tabOpenPromise; + const tabUrl = await loadedPromise; + + Assert.equal( + tabUrl, + "https://example.org/learnmore", + "Learn more link opened the right url" + ); + + const telemetrySentPromise = assertTelemetrySent(hb, [ + "offeredTS", + "learnMoreTS", + "closedTS", + ]); + // Close notification to trigger telemetry to be sent + await closeAllNotifications(targetWindow, notificationBox); + await telemetrySentPromise; + BrowserTestUtils.removeTab(tab); +}); + +// Batch #2 - Engagement buttons +add_task(async function () { + const targetWindow = Services.wm.getMostRecentWindow("navigator:browser"); + const notificationBox = targetWindow.gNotificationBox; + const hb = new Heartbeat(targetWindow, { + testing: true, + flowId: "test", + message: "test", + engagementButtonLabel: "Click me!", + postAnswerUrl: "https://example.org/postAnswer", + learnMoreMessage: "Learn More", + learnMoreUrl: "https://example.org/learnMore", + }); + const engagementButton = hb.notice.buttonContainer.querySelector( + ".notification-button" + ); + + Assert.equal(getStars(hb.notice).length, 0, "Stars not shown"); + Assert.ok(engagementButton, "Engagement button added"); + Assert.equal( + engagementButton.label, + "Click me!", + "Engagement button has correct label" + ); + + const engagementEl = hb.notice.buttonContainer.querySelector( + ".notification-button" + ); + let loadedPromise; + const tabOpenPromise = new Promise(resolve => { + targetWindow.gBrowser.tabContainer.addEventListener( + "TabOpen", + event => { + let tab = event.target; + loadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + url => url && url !== "about:blank" + ); + resolve(tab); + }, + { once: true } + ); + }); + engagementEl.click(); + const tab = await tabOpenPromise; + const tabUrl = await loadedPromise; + // the postAnswer url gets query parameters appended onto the end, so use Assert.startsWith instead of Assert.equal + Assert.ok( + tabUrl.startsWith("https://example.org/postAnswer"), + "Engagement button opened the right url" + ); + + const telemetrySentPromise = assertTelemetrySent(hb, [ + "offeredTS", + "engagedTS", + "closedTS", + ]); + // Close notification to trigger telemetry to be sent + await closeAllNotifications(targetWindow, notificationBox); + await telemetrySentPromise; + BrowserTestUtils.removeTab(tab); +}); + +// Batch 3 - Closing the window while heartbeat is open +add_task(async function () { + const targetWindow = await BrowserTestUtils.openNewBrowserWindow(); + + const hb = new Heartbeat(targetWindow, { + testing: true, + flowId: "test", + message: "test", + }); + + const telemetrySentPromise = assertTelemetrySent(hb, [ + "offeredTS", + "windowClosedTS", + ]); + // triggers sending ping to normandy + await BrowserTestUtils.closeWindow(targetWindow); + await telemetrySentPromise; +}); + +add_task(async function cleanup() { + const win = Services.wm.getMostRecentWindow("navigator:browser"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/toolkit/components/normandy/test/browser/browser_LegacyHeartbeat.js b/toolkit/components/normandy/test/browser/browser_LegacyHeartbeat.js new file mode 100644 index 0000000000..465e5c1040 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_LegacyHeartbeat.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { ClientEnvironment } = ChromeUtils.importESModule( + "resource://normandy/lib/ClientEnvironment.sys.mjs" +); +const { Heartbeat } = ChromeUtils.importESModule( + "resource://normandy/lib/Heartbeat.sys.mjs" +); +const { Normandy } = ChromeUtils.importESModule( + "resource://normandy/Normandy.sys.mjs" +); +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { RecipeRunner } = ChromeUtils.importESModule( + "resource://normandy/lib/RecipeRunner.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const SURVEY = { + surveyId: "a survey", + message: "test message", + engagementButtonLabel: "", + thanksMessage: "thanks!", + postAnswerUrl: "https://example.com", + learnMoreMessage: "Learn More", + learnMoreUrl: "https://example.com", + repeatOption: "once", +}; + +function assertSurvey(actual, expected) { + for (const key of Object.keys(actual)) { + if (["postAnswerUrl", "flowId"].includes(key)) { + continue; + } + + Assert.equal( + actual[key], + expected[key], + `Heartbeat should receive correct ${key} parameter` + ); + } + + Assert.ok(actual.postAnswerUrl.startsWith(expected.postAnswerUrl)); +} + +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testLegacyHeartbeatTrigger({ heartbeatClassStub }) { + const sandbox = sinon.createSandbox(); + + const cleanupEnrollment = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "legacyHeartbeat", + value: { + survey: SURVEY, + }, + }); + + const client = RemoteSettings("normandy-recipes-capabilities"); + sandbox.stub(client, "get").resolves([]); + + try { + await RecipeRunner.run(); + Assert.equal( + heartbeatClassStub.args.length, + 1, + "Heartbeat should be instantiated once" + ); + assertSurvey(heartbeatClassStub.args[0][1], SURVEY); + + await cleanupEnrollment(); + } finally { + sandbox.restore(); + } + } +); diff --git a/toolkit/components/normandy/test/browser/browser_LogManager.js b/toolkit/components/normandy/test/browser/browser_LogManager.js new file mode 100644 index 0000000000..6f41b46c63 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_LogManager.js @@ -0,0 +1,27 @@ +"use strict"; + +const { LogManager } = ChromeUtils.importESModule( + "resource://normandy/lib/LogManager.sys.mjs" +); + +add_task(async function () { + // Ensure that configuring the logger affects all generated loggers. + const firstLogger = LogManager.getLogger("first"); + LogManager.configure(5); + const secondLogger = LogManager.getLogger("second"); + is(firstLogger.level, 5, "First logger level inherited from root logger."); + is(secondLogger.level, 5, "Second logger level inherited from root logger."); + + // Ensure that our loggers have at least one appender. + LogManager.configure(Log.Level.Warn); + const logger = LogManager.getLogger("test"); + ok(!!logger.appenders.length, "Loggers have at least one appender."); + + // Ensure our loggers log to the console. + await new Promise(resolve => { + SimpleTest.waitForExplicitFinish(); + SimpleTest.monitorConsole(resolve, [{ message: /legend has it/ }]); + logger.warn("legend has it"); + SimpleTest.endMonitorConsole(); + }); +}); diff --git a/toolkit/components/normandy/test/browser/browser_Normandy.js b/toolkit/components/normandy/test/browser/browser_Normandy.js new file mode 100644 index 0000000000..1480bd13a4 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_Normandy.js @@ -0,0 +1,386 @@ +"use strict"; + +const { TelemetryUtils } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryUtils.sys.mjs" +); +const { Normandy } = ChromeUtils.importESModule( + "resource://normandy/Normandy.sys.mjs" +); +const { AddonRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonRollouts.sys.mjs" +); +const { PreferenceExperiments } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceExperiments.sys.mjs" +); +const { PreferenceRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceRollouts.sys.mjs" +); +const { RecipeRunner } = ChromeUtils.importESModule( + "resource://normandy/lib/RecipeRunner.sys.mjs" +); +const { + NormandyTestUtils: { factories }, +} = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +const experimentPref1 = "test.initExperimentPrefs1"; +const experimentPref2 = "test.initExperimentPrefs2"; +const experimentPref3 = "test.initExperimentPrefs3"; +const experimentPref4 = "test.initExperimentPrefs4"; + +function withStubInits() { + return function (testFunction) { + return decorate( + withStub(AddonRollouts, "init"), + withStub(AddonStudies, "init"), + withStub(PreferenceRollouts, "init"), + withStub(PreferenceExperiments, "init"), + withStub(RecipeRunner, "init"), + withStub(TelemetryEvents, "init"), + testFunction + ); + }; +} + +decorate_task( + withPrefEnv({ + set: [ + [`app.normandy.startupExperimentPrefs.${experimentPref1}`, true], + [`app.normandy.startupExperimentPrefs.${experimentPref2}`, 2], + [`app.normandy.startupExperimentPrefs.${experimentPref3}`, "string"], + ], + }), + async function testApplyStartupPrefs() { + const defaultBranch = Services.prefs.getDefaultBranch(""); + for (const pref of [experimentPref1, experimentPref2, experimentPref3]) { + is( + defaultBranch.getPrefType(pref), + defaultBranch.PREF_INVALID, + `Pref ${pref} don't exist before being initialized.` + ); + } + + let oldValues = Normandy.applyStartupPrefs( + "app.normandy.startupExperimentPrefs." + ); + + Assert.deepEqual( + oldValues, + { + [experimentPref1]: null, + [experimentPref2]: null, + [experimentPref3]: null, + }, + "the correct set of old values should be reported" + ); + + ok( + defaultBranch.getBoolPref(experimentPref1), + `Pref ${experimentPref1} has a default value after being initialized.` + ); + is( + defaultBranch.getIntPref(experimentPref2), + 2, + `Pref ${experimentPref2} has a default value after being initialized.` + ); + is( + defaultBranch.getCharPref(experimentPref3), + "string", + `Pref ${experimentPref3} has a default value after being initialized.` + ); + + for (const pref of [experimentPref1, experimentPref2, experimentPref3]) { + ok( + !defaultBranch.prefHasUserValue(pref), + `Pref ${pref} doesn't have a user value after being initialized.` + ); + Services.prefs.clearUserPref(pref); + defaultBranch.deleteBranch(pref); + } + } +); + +decorate_task( + withPrefEnv({ + set: [ + ["app.normandy.startupExperimentPrefs.test.existingPref", "experiment"], + ], + }), + async function testApplyStartupPrefsExisting() { + const defaultBranch = Services.prefs.getDefaultBranch(""); + defaultBranch.setCharPref("test.existingPref", "default"); + Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs."); + is( + defaultBranch.getCharPref("test.existingPref"), + "experiment", + "applyStartupPrefs overwrites the default values of existing preferences." + ); + } +); + +decorate_task( + withPrefEnv({ + set: [ + ["app.normandy.startupExperimentPrefs.test.mismatchPref", "experiment"], + ], + }), + async function testApplyStartupPrefsMismatch() { + const defaultBranch = Services.prefs.getDefaultBranch(""); + defaultBranch.setIntPref("test.mismatchPref", 2); + Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs."); + is( + defaultBranch.getPrefType("test.mismatchPref"), + Services.prefs.PREF_INT, + "applyStartupPrefs skips prefs that don't match the existing default value's type." + ); + } +); + +decorate_task( + withStub(Normandy, "finishInit"), + async function testStartupDelayed({ finishInitStub }) { + let originalDeferred = Normandy.uiAvailableNotificationObserved; + let mockUiAvailableDeferred = PromiseUtils.defer(); + Normandy.uiAvailableNotificationObserved = mockUiAvailableDeferred; + + let initPromise = Normandy.init(); + await null; + + ok( + !finishInitStub.called, + "When initialized, do not call finishInit immediately." + ); + + Normandy.observe(null, "sessionstore-windows-restored"); + await initPromise; + ok( + finishInitStub.called, + "Once the sessionstore-windows-restored event is observed, finishInit should be called." + ); + + Normandy.uiAvailableNotificationObserved = originalDeferred; + } +); + +// During startup, preferences that are changed for experiments should +// be record by calling PreferenceExperiments.recordOriginalValues. +decorate_task( + withStub(PreferenceExperiments, "recordOriginalValues", { + as: "experimentsRecordOriginalValuesStub", + }), + withStub(PreferenceRollouts, "recordOriginalValues", { + as: "rolloutsRecordOriginalValueStub", + }), + async function testApplyStartupPrefs({ + experimentsRecordOriginalValuesStub, + rolloutsRecordOriginalValueStub, + }) { + const defaultBranch = Services.prefs.getDefaultBranch(""); + + defaultBranch.setBoolPref(experimentPref1, false); + defaultBranch.setIntPref(experimentPref2, 1); + defaultBranch.setCharPref(experimentPref3, "original string"); + // experimentPref4 is left unset + + Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs."); + Normandy.studyPrefsChanged = { "test.study-pref": 1 }; + Normandy.rolloutPrefsChanged = { "test.rollout-pref": 1 }; + await Normandy.finishInit(); + + Assert.deepEqual( + experimentsRecordOriginalValuesStub.args, + [[{ "test.study-pref": 1 }]], + "finishInit should record original values of the study prefs" + ); + Assert.deepEqual( + rolloutsRecordOriginalValueStub.args, + [[{ "test.rollout-pref": 1 }]], + "finishInit should record original values of the study prefs" + ); + + // cleanup + defaultBranch.deleteBranch(experimentPref1); + defaultBranch.deleteBranch(experimentPref2); + defaultBranch.deleteBranch(experimentPref3); + } +); + +// Test that startup prefs are handled correctly when there is a value on the user branch but not the default branch. +decorate_task( + withPrefEnv({ + set: [ + ["app.normandy.startupExperimentPrefs.testing.does-not-exist", "foo"], + ["testing.does-not-exist", "foo"], + ], + }), + withStub(PreferenceExperiments, "recordOriginalValues"), + async function testApplyStartupPrefsNoDefaultValue() { + Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs"); + ok( + true, + "initExperimentPrefs should not throw for prefs that doesn't exist on the default branch" + ); + } +); + +decorate_task(withStubInits(), async function testStartup() { + const initObserved = TestUtils.topicObserved("shield-init-complete"); + await Normandy.finishInit(); + ok(AddonStudies.init.called, "startup calls AddonStudies.init"); + ok( + PreferenceExperiments.init.called, + "startup calls PreferenceExperiments.init" + ); + ok(RecipeRunner.init.called, "startup calls RecipeRunner.init"); + await initObserved; +}); + +decorate_task(withStubInits(), async function testStartupPrefInitFail() { + PreferenceExperiments.init.rejects(); + + await Normandy.finishInit(); + ok(AddonStudies.init.called, "startup calls AddonStudies.init"); + ok(AddonRollouts.init.called, "startup calls AddonRollouts.init"); + ok( + PreferenceExperiments.init.called, + "startup calls PreferenceExperiments.init" + ); + ok(RecipeRunner.init.called, "startup calls RecipeRunner.init"); + ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init"); + ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init"); +}); + +decorate_task( + withStubInits(), + async function testStartupAddonStudiesInitFail() { + AddonStudies.init.rejects(); + + await Normandy.finishInit(); + ok(AddonStudies.init.called, "startup calls AddonStudies.init"); + ok(AddonRollouts.init.called, "startup calls AddonRollouts.init"); + ok( + PreferenceExperiments.init.called, + "startup calls PreferenceExperiments.init" + ); + ok(RecipeRunner.init.called, "startup calls RecipeRunner.init"); + ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init"); + ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init"); + } +); + +decorate_task( + withStubInits(), + async function testStartupTelemetryEventsInitFail() { + TelemetryEvents.init.throws(); + + await Normandy.finishInit(); + ok(AddonStudies.init.called, "startup calls AddonStudies.init"); + ok(AddonRollouts.init.called, "startup calls AddonRollouts.init"); + ok( + PreferenceExperiments.init.called, + "startup calls PreferenceExperiments.init" + ); + ok(RecipeRunner.init.called, "startup calls RecipeRunner.init"); + ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init"); + ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init"); + } +); + +decorate_task( + withStubInits(), + async function testStartupPreferenceRolloutsInitFail() { + PreferenceRollouts.init.throws(); + + await Normandy.finishInit(); + ok(AddonStudies.init.called, "startup calls AddonStudies.init"); + ok(AddonRollouts.init.called, "startup calls AddonRollouts.init"); + ok( + PreferenceExperiments.init.called, + "startup calls PreferenceExperiments.init" + ); + ok(RecipeRunner.init.called, "startup calls RecipeRunner.init"); + ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init"); + ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init"); + } +); + +// Test that disabling telemetry removes all stored enrollment IDs +decorate_task( + PreferenceExperiments.withMockExperiments([ + factories.preferenceStudyFactory({ + enrollmentId: "test-enrollment-id", + }), + ]), + AddonStudies.withStudies([ + factories.addonStudyFactory({ slug: "test-study" }), + ]), + PreferenceRollouts.withTestMock(), + AddonRollouts.withTestMock(), + async function disablingTelemetryClearsEnrollmentIds({ + prefExperiments: [prefExperiment], + addonStudies: [addonStudy], + }) { + const prefRollout = { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [], + enrollmentId: "test-enrollment-id", + }; + await PreferenceRollouts.add(prefRollout); + const addonRollout = { + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extension: {}, + enrollmentId: "test-enrollment-id", + }; + await AddonRollouts.add(addonRollout); + + // pre-check + ok( + (await PreferenceExperiments.get(prefExperiment.slug)).enrollmentId, + "pref experiment should have an enrollment id" + ); + ok( + (await AddonStudies.get(addonStudy.recipeId)).enrollmentId, + "addon study should have an enrollment id" + ); + ok( + (await PreferenceRollouts.get(prefRollout.slug)).enrollmentId, + "pref rollout should have an enrollment id" + ); + ok( + (await AddonRollouts.get(addonRollout.slug)).enrollmentId, + "addon rollout should have an enrollment id" + ); + + // trigger telemetry being disabled + await Normandy.observe( + null, + TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC, + null + ); + + // no enrollment IDs anymore + is( + (await PreferenceExperiments.get(prefExperiment.slug)).enrollmentId, + TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + "pref experiment should not have an enrollment id" + ); + is( + (await AddonStudies.get(addonStudy.recipeId)).enrollmentId, + TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + "addon study should not have an enrollment id" + ); + is( + (await PreferenceRollouts.get(prefRollout.slug)).enrollmentId, + TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + "pref rollout should not have an enrollment id" + ); + is( + (await AddonRollouts.get(addonRollout.slug)).enrollmentId, + TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + "addon rollout should not have an enrollment id" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_NormandyAddonManager.js b/toolkit/components/normandy/test/browser/browser_NormandyAddonManager.js new file mode 100644 index 0000000000..fe62f557e2 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_NormandyAddonManager.js @@ -0,0 +1,189 @@ +"use strict"; + +const { NormandyAddonManager } = ChromeUtils.importESModule( + "resource://normandy/lib/NormandyAddonManager.sys.mjs" +); + +decorate_task(ensureAddonCleanup(), async function download_and_install() { + const applyDeferred = PromiseUtils.defer(); + + const [addonId, addonVersion] = await NormandyAddonManager.downloadAndInstall( + { + extensionDetails: { + extension_id: FIXTURE_ADDON_ID, + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + hash_algorithm: "sha256", + version: "1.0", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + }, + applyNormandyChanges: () => { + applyDeferred.resolve(); + }, + createError: () => {}, + reportError: () => {}, + undoNormandyChanges: () => {}, + } + ); + + // Ensure applyNormandyChanges was called + await applyDeferred; + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, addonId, "add-on is installed"); + is(addon.version, addonVersion, "add-on version is correct"); + + // Cleanup + await addon.uninstall(); +}); + +decorate_task(ensureAddonCleanup(), async function id_mismatch() { + const applyDeferred = PromiseUtils.defer(); + const undoDeferred = PromiseUtils.defer(); + + let error; + + try { + await NormandyAddonManager.downloadAndInstall({ + extensionDetails: { + extension_id: FIXTURE_ADDON_ID, + hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash, + hash_algorithm: "sha256", + version: "1.0", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].url, + }, + applyNormandyChanges: () => { + applyDeferred.resolve(); + }, + createError: (reason, extra) => { + return [reason, extra]; + }, + reportError: err => { + return err; + }, + undoNormandyChanges: () => { + undoDeferred.resolve(); + }, + }); + } catch ([reason, extra]) { + error = true; + is(reason, "metadata-mismatch", "the expected reason is provided"); + Assert.deepEqual( + extra, + undefined, + "the expected extra details are provided" + ); + } + + ok(error, "an error occured"); + + // Ensure applyNormandyChanges was called + await applyDeferred; + + // Ensure undoNormandyChanges was called + await undoDeferred; + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(!addon, "add-on is not installed"); +}); + +decorate_task(ensureAddonCleanup(), async function version_mismatch() { + const applyDeferred = PromiseUtils.defer(); + const undoDeferred = PromiseUtils.defer(); + + let error; + + try { + await NormandyAddonManager.downloadAndInstall({ + extensionDetails: { + extension_id: FIXTURE_ADDON_ID, + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + hash_algorithm: "sha256", + version: "2.0", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + }, + applyNormandyChanges: () => { + applyDeferred.resolve(); + }, + createError: (reason, extra) => { + return [reason, extra]; + }, + reportError: err => { + return err; + }, + undoNormandyChanges: () => { + undoDeferred.resolve(); + }, + }); + } catch ([reason, extra]) { + error = true; + is(reason, "metadata-mismatch", "the expected reason is provided"); + Assert.deepEqual( + extra, + undefined, + "the expected extra details are provided" + ); + } + + ok(error, "should throw an error"); + + // Ensure applyNormandyChanges was called + await applyDeferred; + + // Ensure undoNormandyChanges was called + await undoDeferred; + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(!addon, "add-on is not installed"); +}); + +decorate_task(ensureAddonCleanup(), async function download_failure() { + const applyDeferred = PromiseUtils.defer(); + const undoDeferred = PromiseUtils.defer(); + + let error; + + try { + await NormandyAddonManager.downloadAndInstall({ + extensionDetails: { + extension_id: FIXTURE_ADDON_ID, + hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash, + hash_algorithm: "sha256", + version: "1.0", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + }, + applyNormandyChanges: () => { + applyDeferred.resolve(); + }, + createError: (reason, extra) => { + return [reason, extra]; + }, + reportError: err => { + return err; + }, + undoNormandyChanges: () => { + undoDeferred.resolve(); + }, + }); + } catch ([reason, extra]) { + error = true; + is(reason, "download-failure", "the expected reason is provided"); + Assert.deepEqual( + extra, + { + detail: "ERROR_INCORRECT_HASH", + }, + "the expected extra details are provided" + ); + } + + ok(error, "an error occured"); + + // Ensure applyNormandyChanges was called + await applyDeferred; + + // Ensure undoNormandyChanges was called + await undoDeferred; + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(!addon, "add-on is not installed"); +}); diff --git a/toolkit/components/normandy/test/browser/browser_NormandyMigrations.js b/toolkit/components/normandy/test/browser/browser_NormandyMigrations.js new file mode 100644 index 0000000000..9e60219c8b --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_NormandyMigrations.js @@ -0,0 +1,106 @@ +const { NormandyMigrations } = ChromeUtils.importESModule( + "resource://normandy/NormandyMigrations.sys.mjs" +); + +decorate_task( + withMockPreferences(), + async function testApplyMigrations({ mockPreferences }) { + const migrationsAppliedPref = "app.normandy.migrationsApplied"; + mockPreferences.set(migrationsAppliedPref, 0); + + await NormandyMigrations.applyAll(); + + is( + Services.prefs.getIntPref(migrationsAppliedPref), + NormandyMigrations.migrations.length, + "All migrations should have been applied" + ); + } +); + +decorate_task( + withMockPreferences(), + async function testPrefMigration({ mockPreferences }) { + const legacyPref = "extensions.shield-recipe-client.test"; + const migratedPref = "app.normandy.test"; + mockPreferences.set(legacyPref, 1); + + ok( + Services.prefs.prefHasUserValue(legacyPref), + "Legacy pref should have a user value before running migration" + ); + ok( + !Services.prefs.prefHasUserValue(migratedPref), + "Migrated pref should not have a user value before running migration" + ); + + await NormandyMigrations.applyOne(0); + + ok( + !Services.prefs.prefHasUserValue(legacyPref), + "Legacy pref should not have a user value after running migration" + ); + ok( + Services.prefs.prefHasUserValue(migratedPref), + "Migrated pref should have a user value after running migration" + ); + is( + Services.prefs.getIntPref(migratedPref), + 1, + "Value should have been migrated" + ); + + Services.prefs.clearUserPref(migratedPref); + } +); + +decorate_task( + withMockPreferences(), + async function testMigration0({ mockPreferences }) { + const studiesEnabledPref = "app.shield.optoutstudies.enabled"; + const healthReportUploadEnabledPref = + "datareporting.healthreport.uploadEnabled"; + + // Both enabled + mockPreferences.set(studiesEnabledPref, true); + mockPreferences.set(healthReportUploadEnabledPref, true); + await NormandyMigrations.applyOne(1); + ok( + Services.prefs.getBoolPref(studiesEnabledPref), + "Studies should be enabled." + ); + + mockPreferences.cleanup(); + + // Telemetry disabled, studies enabled + mockPreferences.set(studiesEnabledPref, true); + mockPreferences.set(healthReportUploadEnabledPref, false); + await NormandyMigrations.applyOne(1); + ok( + !Services.prefs.getBoolPref(studiesEnabledPref), + "Studies should be disabled." + ); + + mockPreferences.cleanup(); + + // Telemetry enabled, studies disabled + mockPreferences.set(studiesEnabledPref, false); + mockPreferences.set(healthReportUploadEnabledPref, true); + await NormandyMigrations.applyOne(1); + ok( + !Services.prefs.getBoolPref(studiesEnabledPref), + "Studies should be disabled." + ); + + mockPreferences.cleanup(); + + // Both disabled + mockPreferences.set(studiesEnabledPref, false); + mockPreferences.set(healthReportUploadEnabledPref, false); + await NormandyMigrations.applyOne(1); + ok( + !Services.prefs.getBoolPref(studiesEnabledPref), + "Studies should be disabled." + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js b/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js new file mode 100644 index 0000000000..80c3cd79f2 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js @@ -0,0 +1,2205 @@ +"use strict"; + +const { PreferenceExperiments } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceExperiments.sys.mjs" +); +const { CleanupManager } = ChromeUtils.importESModule( + "resource://normandy/lib/CleanupManager.sys.mjs" +); +const { NormandyUtils } = ChromeUtils.importESModule( + "resource://normandy/lib/NormandyUtils.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +// Save ourselves some typing +const { withMockExperiments } = PreferenceExperiments; +const DefaultPreferences = new Preferences({ defaultBranch: true }); +const startupPrefs = "app.normandy.startupExperimentPrefs"; +const { preferenceStudyFactory } = NormandyTestUtils.factories; + +const NOW = new Date(); + +const mockV1Data = { + hypothetical_experiment: { + name: "hypothetical_experiment", + branch: "hypo_1", + expired: false, + lastSeen: NOW.toJSON(), + preferenceName: "some.pref", + preferenceValue: 2, + preferenceType: "integer", + previousPreferenceValue: 1, + preferenceBranchType: "user", + experimentType: "exp", + }, + another_experiment: { + name: "another_experiment", + branch: "another_4", + expired: true, + lastSeen: NOW.toJSON(), + preferenceName: "another.pref", + preferenceValue: true, + preferenceType: "boolean", + previousPreferenceValue: false, + preferenceBranchType: "default", + experimentType: "exp", + }, +}; + +const mockV2Data = { + experiments: { + hypothetical_experiment: { + name: "hypothetical_experiment", + branch: "hypo_1", + expired: false, + lastSeen: NOW.toJSON(), + preferenceName: "some.pref", + preferenceValue: 2, + preferenceType: "integer", + previousPreferenceValue: 1, + preferenceBranchType: "user", + experimentType: "exp", + }, + another_experiment: { + name: "another_experiment", + branch: "another_4", + expired: true, + lastSeen: NOW.toJSON(), + preferenceName: "another.pref", + preferenceValue: true, + preferenceType: "boolean", + previousPreferenceValue: false, + preferenceBranchType: "default", + experimentType: "exp", + }, + }, +}; + +const mockV3Data = { + experiments: { + hypothetical_experiment: { + name: "hypothetical_experiment", + branch: "hypo_1", + expired: false, + lastSeen: NOW.toJSON(), + preferences: { + "some.pref": { + preferenceValue: 2, + preferenceType: "integer", + previousPreferenceValue: 1, + preferenceBranchType: "user", + }, + }, + experimentType: "exp", + }, + another_experiment: { + name: "another_experiment", + branch: "another_4", + expired: true, + lastSeen: NOW.toJSON(), + preferences: { + "another.pref": { + preferenceValue: true, + preferenceType: "boolean", + previousPreferenceValue: false, + preferenceBranchType: "default", + }, + }, + experimentType: "exp", + }, + }, +}; + +const mockV4Data = { + experiments: { + hypothetical_experiment: { + name: "hypothetical_experiment", + branch: "hypo_1", + actionName: "SinglePreferenceExperimentAction", + expired: false, + lastSeen: NOW.toJSON(), + preferences: { + "some.pref": { + preferenceValue: 2, + preferenceType: "integer", + previousPreferenceValue: 1, + preferenceBranchType: "user", + }, + }, + experimentType: "exp", + }, + another_experiment: { + name: "another_experiment", + branch: "another_4", + actionName: "SinglePreferenceExperimentAction", + expired: true, + lastSeen: NOW.toJSON(), + preferences: { + "another.pref": { + preferenceValue: true, + preferenceType: "boolean", + previousPreferenceValue: false, + preferenceBranchType: "default", + }, + }, + experimentType: "exp", + }, + }, +}; + +const mockV5Data = { + experiments: { + hypothetical_experiment: { + slug: "hypothetical_experiment", + branch: "hypo_1", + actionName: "SinglePreferenceExperimentAction", + expired: false, + lastSeen: NOW.toJSON(), + preferences: { + "some.pref": { + preferenceValue: 2, + preferenceType: "integer", + previousPreferenceValue: 1, + preferenceBranchType: "user", + }, + }, + experimentType: "exp", + }, + another_experiment: { + slug: "another_experiment", + branch: "another_4", + actionName: "SinglePreferenceExperimentAction", + expired: true, + lastSeen: NOW.toJSON(), + preferences: { + "another.pref": { + preferenceValue: true, + preferenceType: "boolean", + previousPreferenceValue: false, + preferenceBranchType: "default", + }, + }, + experimentType: "exp", + }, + }, +}; + +const migrationsInfo = [ + { + migration: PreferenceExperiments.migrations.migration01MoveExperiments, + dataBefore: mockV1Data, + dataAfter: mockV2Data, + }, + { + migration: PreferenceExperiments.migrations.migration02MultiPreference, + dataBefore: mockV2Data, + dataAfter: mockV3Data, + }, + { + migration: PreferenceExperiments.migrations.migration03AddActionName, + dataBefore: mockV3Data, + dataAfter: mockV4Data, + }, + { + migration: PreferenceExperiments.migrations.migration04RenameNameToSlug, + dataBefore: mockV4Data, + dataAfter: mockV5Data, + }, + // Migration 5 is not a simple data migration. This style of tests does not apply to it. +]; + +/** + * Make a mock `JsonFile` object with a no-op `saveSoon` method and a deep copy + * of the data passed. + * @param {Object} data the data in the store + */ +function makeMockJsonFile(data = {}) { + return { + // Deep clone the data in case migrations mutate it. + data: JSON.parse(JSON.stringify(data)), + saveSoon: () => {}, + }; +} + +/** Test that each migration results in the expected data */ +add_task(async function test_migrations() { + for (const { migration, dataAfter, dataBefore } of migrationsInfo) { + let mockJsonFile = makeMockJsonFile(dataBefore); + await migration(mockJsonFile); + Assert.deepEqual( + mockJsonFile.data, + dataAfter, + `Migration ${migration.name} should result in the expected data` + ); + } +}); + +add_task(async function migrations_are_idempotent() { + for (const { migration, dataBefore } of migrationsInfo) { + const mockJsonFileOnce = makeMockJsonFile(dataBefore); + const mockJsonFileTwice = makeMockJsonFile(dataBefore); + await migration(mockJsonFileOnce); + await migration(mockJsonFileTwice); + await migration(mockJsonFileTwice); + Assert.deepEqual( + mockJsonFileOnce.data, + mockJsonFileTwice.data, + "migrating data twice should be idempotent for " + migration.name + ); + } +}); + +add_task(async function migration03KeepsActionName() { + let mockData = JSON.parse(JSON.stringify(mockV3Data)); + mockData.experiments.another_experiment.actionName = "SomeOldAction"; + const mockJsonFile = makeMockJsonFile(mockData); + // Output should be the same as mockV4Data, but preserving the action. + const migratedData = JSON.parse(JSON.stringify(mockV4Data)); + migratedData.experiments.another_experiment.actionName = "SomeOldAction"; + + await PreferenceExperiments.migrations.migration03AddActionName(mockJsonFile); + Assert.deepEqual(mockJsonFile.data, migratedData); +}); + +// Test that migration 5 works as expected +decorate_task( + withMockExperiments([ + NormandyTestUtils.factories.preferenceStudyFactory({ + actionName: "PreferenceExperimentAction", + expired: false, + }), + NormandyTestUtils.factories.preferenceStudyFactory({ + actionName: "SinglePreferenceExperimentAction", + expired: false, + }), + ]), + async function migration05Works({ prefExperiments: [expKeep, expExpire] }) { + // pre check + const activeSlugsBefore = (await PreferenceExperiments.getAllActive()).map( + e => e.slug + ); + Assert.deepEqual( + activeSlugsBefore, + [expKeep.slug, expExpire.slug], + "Both experiments should be present and active before the migration" + ); + + // run the migration + await PreferenceExperiments.migrations.migration05RemoveOldAction(); + + // verify behavior + const activeSlugsAfter = (await PreferenceExperiments.getAllActive()).map( + e => e.slug + ); + Assert.deepEqual( + activeSlugsAfter, + [expKeep.slug], + "The single pref experiment should be ended by the migration" + ); + const allSlugsAfter = (await PreferenceExperiments.getAll()).map( + e => e.slug + ); + Assert.deepEqual( + allSlugsAfter, + [expKeep.slug, expExpire.slug], + "Both experiments should still exist after the migration" + ); + } +); + +// clearAllExperimentStorage +decorate_task( + withMockExperiments([preferenceStudyFactory({ slug: "test" })]), + async function ({ prefExperiments }) { + ok(await PreferenceExperiments.has("test"), "Mock experiment is detected."); + await PreferenceExperiments.clearAllExperimentStorage(); + ok( + !(await PreferenceExperiments.has("test")), + "clearAllExperimentStorage removed all stored experiments" + ); + } +); + +// start should throw if an experiment with the given name already exists +decorate_task( + withMockExperiments([preferenceStudyFactory({ slug: "test" })]), + withSendEventSpy(), + async function ({ sendEventSpy }) { + await Assert.rejects( + PreferenceExperiments.start({ + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.preference": { + preferenceValue: "value", + preferenceType: "string", + preferenceBranchType: "default", + }, + }, + }), + /test.*already exists/, + "start threw an error due to a conflicting experiment name" + ); + + sendEventSpy.assertEvents([ + ["enrollFailed", "preference_study", "test", { reason: "name-conflict" }], + ]); + } +); + +// start should throw if an experiment for any of the given +// preferences are active +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + preferences: { "fake.preferenceinteger": {} }, + }), + ]), + withSendEventSpy(), + async function ({ sendEventSpy }) { + await Assert.rejects( + PreferenceExperiments.start({ + slug: "different", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.preference": { + preferenceValue: "value", + preferenceType: "string", + preferenceBranchType: "default", + }, + "fake.preferenceinteger": { + preferenceValue: 2, + preferenceType: "integer", + preferenceBranchType: "default", + }, + }, + }), + /another.*is currently active/i, + "start threw an error due to an active experiment for the given preference" + ); + + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "preference_study", + "different", + { reason: "pref-conflict" }, + ], + ]); + } +); + +// start should throw if an invalid preferenceBranchType is given +decorate_task( + withMockExperiments(), + withSendEventSpy(), + async function ({ sendEventSpy }) { + await Assert.rejects( + PreferenceExperiments.start({ + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.preference": { + preferenceValue: "value", + preferenceType: "string", + preferenceBranchType: "invalid", + }, + }, + }), + /invalid value for preferenceBranchType: invalid/i, + "start threw an error due to an invalid preference branch type" + ); + + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "preference_study", + "test", + { reason: "invalid-branch" }, + ], + ]); + } +); + +// start should save experiment data, modify preferences, and register a +// watcher. +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "startObserver"), + withSendEventSpy(), + async function testStart({ + prefExperiments, + mockPreferences, + startObserverStub, + sendEventSpy, + }) { + mockPreferences.set("fake.preference", "oldvalue", "default"); + mockPreferences.set("fake.preference", "uservalue", "user"); + mockPreferences.set("fake.preferenceinteger", 1, "default"); + mockPreferences.set("fake.preferenceinteger", 101, "user"); + + const experiment = { + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.preference": { + preferenceValue: "newvalue", + preferenceBranchType: "default", + preferenceType: "string", + }, + "fake.preferenceinteger": { + preferenceValue: 2, + preferenceBranchType: "default", + preferenceType: "integer", + }, + }, + }; + await PreferenceExperiments.start(experiment); + ok(await PreferenceExperiments.get("test"), "start saved the experiment"); + ok( + startObserverStub.calledWith("test", experiment.preferences), + "start registered an observer" + ); + + const expectedExperiment = { + slug: "test", + branch: "branch", + expired: false, + preferences: { + "fake.preference": { + preferenceValue: "newvalue", + preferenceType: "string", + previousPreferenceValue: "oldvalue", + preferenceBranchType: "default", + overridden: true, + }, + "fake.preferenceinteger": { + preferenceValue: 2, + preferenceType: "integer", + previousPreferenceValue: 1, + preferenceBranchType: "default", + overridden: true, + }, + }, + }; + const experimentSubset = {}; + const actualExperiment = await PreferenceExperiments.get("test"); + Object.keys(expectedExperiment).forEach( + key => (experimentSubset[key] = actualExperiment[key]) + ); + Assert.deepEqual( + experimentSubset, + expectedExperiment, + "start saved the experiment" + ); + + is( + DefaultPreferences.get("fake.preference"), + "newvalue", + "start modified the default preference" + ); + is( + Preferences.get("fake.preference"), + "uservalue", + "start did not modify the user preference" + ); + is( + Preferences.get(`${startupPrefs}.fake.preference`), + "newvalue", + "start saved the experiment value to the startup prefs tree" + ); + is( + DefaultPreferences.get("fake.preferenceinteger"), + 2, + "start modified the default preference" + ); + is( + Preferences.get("fake.preferenceinteger"), + 101, + "start did not modify the user preference" + ); + is( + Preferences.get(`${startupPrefs}.fake.preferenceinteger`), + 2, + "start saved the experiment value to the startup prefs tree" + ); + } +); + +// start should modify the user preference for the user branch type +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "startObserver"), + async function ({ mockPreferences, startObserverStub }) { + mockPreferences.set("fake.preference", "olddefaultvalue", "default"); + mockPreferences.set("fake.preference", "oldvalue", "user"); + + const experiment = { + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.preference": { + preferenceValue: "newvalue", + preferenceType: "string", + preferenceBranchType: "user", + }, + }, + }; + await PreferenceExperiments.start(experiment); + ok( + startObserverStub.calledWith("test", experiment.preferences), + "start registered an observer" + ); + + const expectedExperiment = { + slug: "test", + branch: "branch", + expired: false, + preferences: { + "fake.preference": { + preferenceValue: "newvalue", + preferenceType: "string", + previousPreferenceValue: "oldvalue", + preferenceBranchType: "user", + }, + }, + }; + + const experimentSubset = {}; + const actualExperiment = await PreferenceExperiments.get("test"); + Object.keys(expectedExperiment).forEach( + key => (experimentSubset[key] = actualExperiment[key]) + ); + Assert.deepEqual( + experimentSubset, + expectedExperiment, + "start saved the experiment" + ); + + Assert.notEqual( + DefaultPreferences.get("fake.preference"), + "newvalue", + "start did not modify the default preference" + ); + is( + Preferences.get("fake.preference"), + "newvalue", + "start modified the user preference" + ); + } +); + +// start should detect if a new preference value type matches the previous value type +decorate_task( + withMockPreferences(), + withSendEventSpy(), + async function ({ mockPreferences, sendEventSpy }) { + mockPreferences.set("fake.type_preference", "oldvalue"); + + await Assert.rejects( + PreferenceExperiments.start({ + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.type_preference": { + preferenceBranchType: "user", + preferenceValue: 12345, + preferenceType: "integer", + }, + }, + }), + /previous preference value is of type/i, + "start threw error for incompatible preference type" + ); + + sendEventSpy.assertEvents([ + ["enrollFailed", "preference_study", "test", { reason: "invalid-type" }], + ]); + } +); + +// startObserver should throw if an observer for the experiment is already +// active. +decorate_task(withMockExperiments(), async function () { + PreferenceExperiments.startObserver("test", { + "fake.preference": { + preferenceType: "string", + preferenceValue: "newvalue", + }, + }); + Assert.throws( + () => + PreferenceExperiments.startObserver("test", { + "another.fake": { + preferenceType: "string", + preferenceValue: "othervalue", + }, + }), + /observer.*is already active/i, + "startObservers threw due to a conflicting active observer" + ); + PreferenceExperiments.stopAllObservers(); +}); + +// startObserver should register an observer that sends an event when preference +// changes from its experimental value. +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "recordPrefChange"), + async function testObserversCanObserveChanges({ + mockPreferences, + recordPrefChangeStub, + }) { + const preferences = { + "fake.preferencestring": { + preferenceType: "string", + previousPreferenceValue: "startvalue", + preferenceValue: "experimentvalue", + }, + // "newvalue", + "fake.preferenceboolean": { + preferenceType: "boolean", + previousPreferenceValue: false, + preferenceValue: true, + }, // false + "fake.preferenceinteger": { + preferenceType: "integer", + previousPreferenceValue: 1, + preferenceValue: 2, + }, // 42 + }; + const newValues = { + "fake.preferencestring": "newvalue", + "fake.preferenceboolean": false, + "fake.preferenceinteger": 42, + }; + + for (const [testPref, newValue] of Object.entries(newValues)) { + const experimentSlug = "test-" + testPref; + for (const [prefName, prefInfo] of Object.entries(preferences)) { + mockPreferences.set(prefName, prefInfo.previousPreferenceValue); + } + + // NOTE: startObserver does not modify the pref + PreferenceExperiments.startObserver(experimentSlug, preferences); + + // Setting it to the experimental value should not trigger the call. + for (const [prefName, prefInfo] of Object.entries(preferences)) { + mockPreferences.set(prefName, prefInfo.preferenceValue); + ok( + !recordPrefChangeStub.called, + "Changing to the experimental pref value did not trigger the observer" + ); + } + + // Setting it to something different should trigger the call. + mockPreferences.set(testPref, newValue); + Assert.deepEqual( + recordPrefChangeStub.args, + [[{ experimentSlug, preferenceName: testPref, reason: "observer" }]], + "Changing to a different value triggered the observer" + ); + + PreferenceExperiments.stopAllObservers(); + recordPrefChangeStub.resetHistory(); + } + } +); + +// Changes to prefs that have an experimental pref as a prefix should not trigger the observer. +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "recordPrefChange"), + async function testObserversCanObserveChanges({ + mockPreferences, + recordPrefChangeStub, + }) { + const preferences = { + "fake.preference": { + preferenceType: "string", + previousPreferenceValue: "startvalue", + preferenceValue: "experimentvalue", + }, + }; + + const experimentSlug = "test-prefix"; + for (const [prefName, prefInfo] of Object.entries(preferences)) { + mockPreferences.set(prefName, prefInfo.preferenceValue); + } + PreferenceExperiments.startObserver(experimentSlug, preferences); + + // Changing a preference that has the experimental pref as a prefix should + // not trigger the observer. + mockPreferences.set("fake.preference.extra", "value"); + // Setting it to the experimental value should not trigger the call. + ok( + !recordPrefChangeStub.called, + "Changing to the experimental pref value did not trigger the observer" + ); + + PreferenceExperiments.stopAllObservers(); + } +); + +decorate_task(withMockExperiments(), async function testHasObserver() { + PreferenceExperiments.startObserver("test", { + "fake.preference": { + preferenceType: "string", + preferenceValue: "experimentValue", + }, + }); + + ok( + await PreferenceExperiments.hasObserver("test"), + "hasObserver should detect active observers" + ); + ok( + !(await PreferenceExperiments.hasObserver("missing")), + "hasObserver shouldn't detect inactive observers" + ); + + PreferenceExperiments.stopAllObservers(); +}); + +// stopObserver should throw if there is no observer active for it to stop. +decorate_task(withMockExperiments(), async function () { + Assert.throws( + () => PreferenceExperiments.stopObserver("neveractive"), + /no observer.*found/i, + "stopObserver threw because there was not matching active observer" + ); +}); + +// stopObserver should cancel an active observers. +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "stop", { returnValue: Promise.resolve() }), + async function ({ mockPreferences, stopStub }) { + const preferenceInfo = { + "fake.preferencestring": { + preferenceType: "string", + preferenceValue: "experimentvalue", + }, + "fake.preferenceinteger": { + preferenceType: "integer", + preferenceValue: 2, + }, + }; + mockPreferences.set("fake.preference", "startvalue"); + + PreferenceExperiments.startObserver("test", preferenceInfo); + PreferenceExperiments.stopObserver("test"); + + // Setting the preference now that the observer is stopped should not call + // stop. + mockPreferences.set("fake.preferencestring", "newvalue"); + ok( + !stopStub.called, + "stopObserver successfully removed the observer for string" + ); + + mockPreferences.set("fake.preferenceinteger", 42); + ok( + !stopStub.called, + "stopObserver successfully removed the observer for integer" + ); + + // Now that the observer is stopped, start should be able to start a new one + // without throwing. + try { + PreferenceExperiments.startObserver("test", preferenceInfo); + } catch (err) { + ok( + false, + "startObserver did not throw an error for an observer that was already stopped" + ); + } + + PreferenceExperiments.stopAllObservers(); + } +); + +// stopAllObservers +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "stop", { returnValue: Promise.resolve() }), + async function ({ mockPreferences, stopStub }) { + mockPreferences.set("fake.preference", "startvalue"); + mockPreferences.set("other.fake.preference", "startvalue"); + + PreferenceExperiments.startObserver("test", { + "fake.preference": { + preferenceType: "string", + preferenceValue: "experimentvalue", + }, + }); + PreferenceExperiments.startObserver("test2", { + "other.fake.preference": { + preferenceType: "string", + preferenceValue: "experimentvalue", + }, + }); + PreferenceExperiments.stopAllObservers(); + + // Setting the preference now that the observers are stopped should not call + // stop. + mockPreferences.set("fake.preference", "newvalue"); + mockPreferences.set("other.fake.preference", "newvalue"); + ok(!stopStub.called, "stopAllObservers successfully removed all observers"); + + // Now that the observers are stopped, start should be able to start new + // observers without throwing. + try { + PreferenceExperiments.startObserver("test", { + "fake.preference": { + preferenceType: "string", + preferenceValue: "experimentvalue", + }, + }); + PreferenceExperiments.startObserver("test2", { + "other.fake.preference": { + preferenceType: "string", + preferenceValue: "experimentvalue", + }, + }); + } catch (err) { + ok( + false, + "startObserver did not throw an error for an observer that was already stopped" + ); + } + + PreferenceExperiments.stopAllObservers(); + } +); + +// markLastSeen should throw if it can't find a matching experiment +decorate_task(withMockExperiments(), async function () { + await Assert.rejects( + PreferenceExperiments.markLastSeen("neveractive"), + /could not find/i, + "markLastSeen threw because there was not a matching experiment" + ); +}); + +// markLastSeen should update the lastSeen date +const oldDate = new Date(1988, 10, 1).toJSON(); +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ slug: "test", lastSeen: oldDate }), + ]), + async function ({ prefExperiments: [experiment] }) { + await PreferenceExperiments.markLastSeen("test"); + Assert.notEqual( + experiment.lastSeen, + oldDate, + "markLastSeen updated the experiment lastSeen date" + ); + } +); + +// stop should throw if an experiment with the given name doesn't exist +decorate_task( + withMockExperiments(), + withSendEventSpy(), + async function ({ sendEventSpy }) { + await Assert.rejects( + PreferenceExperiments.stop("test"), + /could not find/i, + "stop threw an error because there are no experiments with the given name" + ); + + sendEventSpy.assertEvents([ + [ + "unenrollFailed", + "preference_study", + "test", + { reason: "does-not-exist" }, + ], + ]); + } +); + +// stop should throw if the experiment is already expired +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ slug: "test", expired: true }), + ]), + withSendEventSpy(), + async function ({ sendEventSpy }) { + await Assert.rejects( + PreferenceExperiments.stop("test"), + /already expired/, + "stop threw an error because the experiment was already expired" + ); + + sendEventSpy.assertEvents([ + [ + "unenrollFailed", + "preference_study", + "test", + { reason: "already-unenrolled" }, + ], + ]); + } +); + +// stop should mark the experiment as expired, stop its observer, and revert the +// preference value. +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + expired: false, + branch: "fakebranch", + preferences: { + "fake.preference": { + preferenceValue: "experimentvalue", + preferenceType: "string", + previousPreferenceValue: "oldvalue", + preferenceBranchType: "default", + }, + }, + }), + ]), + withMockPreferences(), + withSpy(PreferenceExperiments, "stopObserver"), + withSendEventSpy(), + async function testStop({ mockPreferences, stopObserverSpy, sendEventSpy }) { + // this assertion is mostly useful for --verify test runs, to make + // sure that tests clean up correctly. + ok(!Preferences.get("fake.preference"), "preference should start unset"); + + mockPreferences.set( + `${startupPrefs}.fake.preference`, + "experimentvalue", + "user" + ); + mockPreferences.set("fake.preference", "experimentvalue", "default"); + PreferenceExperiments.startObserver("test", { + "fake.preference": { + preferenceType: "string", + preferenceValue: "experimentvalue", + }, + }); + + await PreferenceExperiments.stop("test", { reason: "test-reason" }); + ok(stopObserverSpy.calledWith("test"), "stop removed an observer"); + const experiment = await PreferenceExperiments.get("test"); + is(experiment.expired, true, "stop marked the experiment as expired"); + is( + DefaultPreferences.get("fake.preference"), + "oldvalue", + "stop reverted the preference to its previous value" + ); + ok( + !Services.prefs.prefHasUserValue(`${startupPrefs}.fake.preference`), + "stop cleared the startup preference for fake.preference." + ); + + sendEventSpy.assertEvents([ + [ + "unenroll", + "preference_study", + "test", + { + didResetValue: "true", + reason: "test-reason", + branch: "fakebranch", + }, + ], + ]); + + PreferenceExperiments.stopAllObservers(); + } +); + +// stop should also support user pref experiments +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + expired: false, + preferences: { + "fake.preference": { + preferenceValue: "experimentvalue", + preferenceType: "string", + previousPreferenceValue: "oldvalue", + preferenceBranchType: "user", + }, + }, + }), + ]), + withMockPreferences(), + withStub(PreferenceExperiments, "stopObserver"), + withStub(PreferenceExperiments, "hasObserver"), + async function testStopUserPrefs({ + mockPreferences, + stopObserverStub, + hasObserverStub, + }) { + hasObserverStub.returns(true); + + mockPreferences.set("fake.preference", "experimentvalue", "user"); + PreferenceExperiments.startObserver("test", { + "fake.preference": { + preferenceType: "string", + preferenceValue: "experimentvalue", + }, + }); + + await PreferenceExperiments.stop("test"); + ok(stopObserverStub.calledWith("test"), "stop removed an observer"); + const experiment = await PreferenceExperiments.get("test"); + is(experiment.expired, true, "stop marked the experiment as expired"); + is( + Preferences.get("fake.preference"), + "oldvalue", + "stop reverted the preference to its previous value" + ); + stopObserverStub.restore(); + PreferenceExperiments.stopAllObservers(); + } +); + +// stop should remove a preference that had no value prior to an experiment for user prefs +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + expired: false, + preferences: { + "fake.preference": { + preferenceValue: "experimentvalue", + preferenceType: "string", + previousPreferenceValue: null, + preferenceBranchType: "user", + }, + }, + }), + ]), + withMockPreferences(), + withStub(PreferenceExperiments, "stopObserver"), + async function ({ mockPreferences }) { + mockPreferences.set("fake.preference", "experimentvalue", "user"); + + await PreferenceExperiments.stop("test"); + ok( + !Preferences.isSet("fake.preference"), + "stop removed the preference that had no value prior to the experiment" + ); + } +); + +// stop should not modify a preference if resetValue is false +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + expired: false, + branch: "fakebranch", + preferences: { + "fake.preference": { + preferenceValue: "experimentvalue", + preferenceType: "string", + previousPreferenceValue: "oldvalue", + preferenceBranchType: "default", + }, + }, + }), + ]), + withMockPreferences(), + withStub(PreferenceExperiments, "stopObserver"), + withSendEventSpy(), + async function testStopReset({ mockPreferences, sendEventSpy }) { + mockPreferences.set("fake.preference", "customvalue", "default"); + + await PreferenceExperiments.stop("test", { + reason: "test-reason", + resetValue: false, + }); + is( + DefaultPreferences.get("fake.preference"), + "customvalue", + "stop did not modify the preference" + ); + sendEventSpy.assertEvents([ + [ + "unenroll", + "preference_study", + "test", + { + didResetValue: "false", + reason: "test-reason", + branch: "fakebranch", + }, + ], + ]); + } +); + +// stop should include the system that stopped it +decorate_task( + withMockExperiments([preferenceStudyFactory({ expired: true })]), + withSendEventSpy, + async function testStopUserPrefs([experiment], sendEventSpy) { + await Assert.rejects( + PreferenceExperiments.stop(experiment.slug, { + caller: "testCaller", + reason: "original-reason", + }), + /.*already expired.*/, + "Stopped an expired experiment should throw an exception" + ); + + const expectedExtra = { + reason: "already-unenrolled", + enrollmentId: experiment.enrollmentId, + originalReason: "original-reason", + }; + if (AppConstants.NIGHTLY_BUILD) { + expectedExtra.caller = "testCaller"; + } + + sendEventSpy.assertEvents([ + ["unenrollFailed", "preference_study", experiment.slug, expectedExtra], + ]); + } +); + +// get should throw if no experiment exists with the given name +decorate_task(withMockExperiments(), async function () { + await Assert.rejects( + PreferenceExperiments.get("neverexisted"), + /could not find/i, + "get rejects if no experiment with the given name is found" + ); +}); + +// get +decorate_task( + withMockExperiments([preferenceStudyFactory({ slug: "test" })]), + async function ({ prefExperiments }) { + const experiment = await PreferenceExperiments.get("test"); + is(experiment.slug, "test", "get fetches the correct experiment"); + + // Modifying the fetched experiment must not edit the data source. + experiment.slug = "othername"; + const refetched = await PreferenceExperiments.get("test"); + is(refetched.slug, "test", "get returns a copy of the experiment"); + } +); + +// get all +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ slug: "experiment1", disabled: false }), + preferenceStudyFactory({ slug: "experiment2", disabled: true }), + ]), + async function testGetAll({ prefExperiments: [experiment1, experiment2] }) { + const fetchedExperiments = await PreferenceExperiments.getAll(); + is( + fetchedExperiments.length, + 2, + "getAll returns a list of all stored experiments" + ); + Assert.deepEqual( + fetchedExperiments.find(e => e.slug === "experiment1"), + experiment1, + "getAll returns a list with the correct experiments" + ); + const fetchedExperiment2 = fetchedExperiments.find( + e => e.slug === "experiment2" + ); + Assert.deepEqual( + fetchedExperiment2, + experiment2, + "getAll returns a list with the correct experiments, including disabled ones" + ); + + fetchedExperiment2.slug = "otherslug"; + is( + experiment2.slug, + "experiment2", + "getAll returns copies of the experiments" + ); + } +); + +// get all active +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "active", + expired: false, + }), + preferenceStudyFactory({ + slug: "inactive", + expired: true, + }), + ]), + withMockPreferences(), + async function testGetAllActive({ + prefExperiments: [activeExperiment, inactiveExperiment], + }) { + let allActiveExperiments = await PreferenceExperiments.getAllActive(); + Assert.deepEqual( + allActiveExperiments, + [activeExperiment], + "getAllActive only returns active experiments" + ); + + allActiveExperiments[0].slug = "newfakename"; + allActiveExperiments = await PreferenceExperiments.getAllActive(); + Assert.notEqual( + allActiveExperiments, + "newfakename", + "getAllActive returns copies of stored experiments" + ); + } +); + +// has +decorate_task( + withMockExperiments([preferenceStudyFactory({ slug: "test" })]), + async function () { + ok( + await PreferenceExperiments.has("test"), + "has returned true for a stored experiment" + ); + ok( + !(await PreferenceExperiments.has("missing")), + "has returned false for a missing experiment" + ); + } +); + +// init should register telemetry experiments +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + branch: "branch", + preferences: { + "fake.pref": { + preferenceValue: "experiment value", + preferenceBranchType: "default", + preferenceType: "string", + }, + }, + }), + ]), + withMockPreferences(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withStub(PreferenceExperiments, "startObserver"), + async function testInit({ + prefExperiments, + mockPreferences, + setExperimentActiveStub, + }) { + mockPreferences.set("fake.pref", "experiment value"); + await PreferenceExperiments.init(); + ok( + setExperimentActiveStub.calledWith("test", "branch", { + type: "normandy-exp", + enrollmentId: prefExperiments[0].enrollmentId, + }), + "Experiment is registered by init" + ); + } +); + +// init should use the provided experiment type +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + branch: "branch", + preferences: { + "fake.pref": { + preferenceValue: "experiment value", + preferenceType: "string", + }, + }, + experimentType: "pref-test", + }), + ]), + withMockPreferences(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withStub(PreferenceExperiments, "startObserver"), + async function testInit({ mockPreferences, setExperimentActiveStub }) { + mockPreferences.set("fake.pref", "experiment value"); + await PreferenceExperiments.init(); + ok( + setExperimentActiveStub.calledWith("test", "branch", { + type: "normandy-pref-test", + enrollmentId: sinon.match(NormandyTestUtils.isUuid), + }), + "init should use the provided experiment type" + ); + } +); + +// starting and stopping experiments should register in telemetry +decorate_task( + withMockExperiments(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + async function testStartAndStopTelemetry({ + setExperimentActiveStub, + setExperimentInactiveStub, + sendEventSpy, + }) { + let { enrollmentId } = await PreferenceExperiments.start({ + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.preference": { + preferenceValue: "value", + preferenceType: "string", + preferenceBranchType: "default", + }, + }, + }); + + ok( + NormandyTestUtils.isUuid(enrollmentId), + "Experiment should have a UUID enrollmentId" + ); + + Assert.deepEqual( + setExperimentActiveStub.getCall(0).args, + ["test", "branch", { type: "normandy-exp", enrollmentId }], + "Experiment is registered by start()" + ); + await PreferenceExperiments.stop("test", { reason: "test-reason" }); + Assert.deepEqual( + setExperimentInactiveStub.args, + [["test"]], + "Experiment is unregistered by stop()" + ); + + sendEventSpy.assertEvents([ + [ + "enroll", + "preference_study", + "test", + { + experimentType: "exp", + branch: "branch", + enrollmentId, + }, + ], + [ + "unenroll", + "preference_study", + "test", + { + reason: "test-reason", + didResetValue: "true", + branch: "branch", + enrollmentId, + }, + ], + ]); + } +); + +// starting experiments should use the provided experiment type +decorate_task( + withMockExperiments(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + async function testInitTelemetryExperimentType({ + setExperimentActiveStub, + sendEventSpy, + }) { + const { enrollmentId } = await PreferenceExperiments.start({ + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + "fake.preference": { + preferenceValue: "value", + preferenceType: "string", + preferenceBranchType: "default", + }, + }, + experimentType: "pref-test", + }); + + Assert.deepEqual( + setExperimentActiveStub.getCall(0).args, + ["test", "branch", { type: "normandy-pref-test", enrollmentId }], + "start() should register the experiment with the provided type" + ); + + sendEventSpy.assertEvents([ + [ + "enroll", + "preference_study", + "test", + { + experimentType: "pref-test", + branch: "branch", + enrollmentId, + }, + ], + ]); + + // start sets the passed preference in a way that is hard to mock. + // Reset the preference so it doesn't interfere with other tests. + Services.prefs.getDefaultBranch("fake.preference").deleteBranch(""); + } +); + +// When a default-branch experiment starts, and some preferences already have +// user set values, they should immediately send telemetry events. +decorate_task( + withMockExperiments(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + withMockPreferences(), + async function testOverriddenAtEnroll({ sendEventSpy, mockPreferences }) { + // consts for preference names to avoid typos + const prefNames = { + defaultNoOverride: "fake.preference.default-no-override", + defaultWithOverride: "fake.preference.default-with-override", + userNoOverride: "fake.preference.user-no-override", + userWithOverride: "fake.preference.user-with-override", + }; + + // Set up preferences for the test. Two preferences with only default + // values, and two preferences with both default and user values. + mockPreferences.set( + prefNames.defaultNoOverride, + "default value", + "default" + ); + mockPreferences.set( + prefNames.defaultWithOverride, + "default value", + "default" + ); + mockPreferences.set(prefNames.defaultWithOverride, "user value", "user"); + mockPreferences.set(prefNames.userNoOverride, "default value", "default"); + mockPreferences.set(prefNames.userWithOverride, "default value", "default"); + mockPreferences.set(prefNames.userWithOverride, "user value", "user"); + + // Start the experiment with two each of default-branch and user-branch + // methods, one each of which will already be overridden. + const { enrollmentId, slug } = await PreferenceExperiments.start({ + slug: "test-experiment", + actionName: "someAction", + branch: "experimental-branch", + preferences: { + [prefNames.defaultNoOverride]: { + preferenceValue: "experimental value", + preferenceType: "string", + preferenceBranchType: "default", + }, + [prefNames.defaultWithOverride]: { + preferenceValue: "experimental value", + preferenceType: "string", + preferenceBranchType: "default", + }, + [prefNames.userNoOverride]: { + preferenceValue: "experimental value", + preferenceType: "string", + preferenceBranchType: "user", + }, + [prefNames.userWithOverride]: { + preferenceValue: "experimental value", + preferenceType: "string", + preferenceBranchType: "user", + }, + }, + experimentType: "pref-test", + }); + + sendEventSpy.assertEvents([ + [ + "enroll", + "preference_study", + slug, + { + experimentType: "pref-test", + branch: "experimental-branch", + enrollmentId, + }, + ], + [ + "expPrefChanged", + "preference_study", + slug, + { + preferenceName: prefNames.defaultWithOverride, + reason: "onEnroll", + enrollmentId, + }, + ], + ]); + } +); + +// Experiments shouldn't be recorded by init() in telemetry if they are expired +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "expired", + branch: "branch", + expired: true, + }), + ]), + withStub(TelemetryEnvironment, "setExperimentActive"), + async function testInitTelemetryExpired({ setExperimentActiveStub }) { + await PreferenceExperiments.init(); + ok( + !setExperimentActiveStub.called, + "Expired experiment is not registered by init" + ); + } +); + +// Experiments should record if the preference has been changed when init() is +// called and no previous override had been observed. +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + preferences: { + "fake.preference.1": { + preferenceValue: "experiment value 1", + preferenceType: "string", + overridden: false, + }, + "fake.preference.2": { + preferenceValue: "experiment value 2", + preferenceType: "string", + overridden: true, + }, + }, + }), + ]), + withMockPreferences(), + withStub(PreferenceExperiments, "recordPrefChange"), + async function testInitChanges({ + mockPreferences, + recordPrefChangeStub, + prefExperiments: [experiment], + }) { + mockPreferences.set("fake.preference.1", "experiment value 1", "default"); + mockPreferences.set("fake.preference.1", "changed value 1", "user"); + mockPreferences.set("fake.preference.2", "experiment value 2", "default"); + mockPreferences.set("fake.preference.2", "changed value 2", "user"); + await PreferenceExperiments.init(); + + is( + Preferences.get("fake.preference.1"), + "changed value 1", + "Preference value was not changed" + ); + is( + Preferences.get("fake.preference.2"), + "changed value 2", + "Preference value was not changed" + ); + + Assert.deepEqual( + recordPrefChangeStub.args, + [ + [ + { + experiment, + preferenceName: "fake.preference.1", + reason: "sideload", + }, + ], + ], + "Only one experiment preference change should be recorded" + ); + } +); + +// init should register an observer for experiments +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + preferences: { + "fake.preference": { + preferenceValue: "experiment value", + preferenceType: "string", + previousPreferenceValue: "oldfakevalue", + }, + }, + }), + ]), + withMockPreferences(), + withStub(PreferenceExperiments, "startObserver"), + withStub(PreferenceExperiments, "stop"), + withStub(CleanupManager, "addCleanupHandler"), + async function testInitRegistersObserver({ + mockPreferences, + startObserverStub, + stopStub, + }) { + stopStub.throws("Stop should not be called"); + mockPreferences.set("fake.preference", "experiment value", "default"); + is( + Preferences.get("fake.preference"), + "experiment value", + "pref shouldn't have a user value" + ); + await PreferenceExperiments.init(); + + ok(startObserverStub.calledOnce, "init should register an observer"); + Assert.deepEqual( + startObserverStub.getCall(0).args, + [ + "test", + { + "fake.preference": { + preferenceType: "string", + preferenceValue: "experiment value", + previousPreferenceValue: "oldfakevalue", + preferenceBranchType: "default", + overridden: false, + }, + }, + ], + "init should register an observer with the right args" + ); + } +); + +// saveStartupPrefs +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "char", + preferences: { + "fake.char": { + preferenceValue: "string", + preferenceType: "string", + }, + }, + }), + preferenceStudyFactory({ + slug: "int", + preferences: { + "fake.int": { + preferenceValue: 2, + preferenceType: "int", + }, + }, + }), + preferenceStudyFactory({ + slug: "bool", + preferences: { + "fake.bool": { + preferenceValue: true, + preferenceType: "boolean", + }, + }, + }), + ]), + async function testSaveStartupPrefs() { + Services.prefs.deleteBranch(startupPrefs); + Services.prefs.setBoolPref(`${startupPrefs}.fake.old`, true); + await PreferenceExperiments.saveStartupPrefs(); + + ok( + Services.prefs.getBoolPref(`${startupPrefs}.fake.bool`), + "The startup value for fake.bool was saved." + ); + is( + Services.prefs.getCharPref(`${startupPrefs}.fake.char`), + "string", + "The startup value for fake.char was saved." + ); + is( + Services.prefs.getIntPref(`${startupPrefs}.fake.int`), + 2, + "The startup value for fake.int was saved." + ); + ok( + !Services.prefs.prefHasUserValue(`${startupPrefs}.fake.old`), + "saveStartupPrefs deleted old startup pref values." + ); + } +); + +// saveStartupPrefs errors for invalid pref type +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + preferences: { + "fake.invalidValue": { + preferenceValue: new Date(), + }, + }, + }), + ]), + async function testSaveStartupPrefsError() { + await Assert.rejects( + PreferenceExperiments.saveStartupPrefs(), + /invalid preference type/i, + "saveStartupPrefs throws if an experiment has an invalid preference value type" + ); + } +); + +// saveStartupPrefs should not store values for user-branch recipes +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "defaultBranchRecipe", + preferences: { + "fake.default": { + preferenceValue: "experiment value", + preferenceType: "string", + preferenceBranchType: "default", + }, + }, + }), + preferenceStudyFactory({ + slug: "userBranchRecipe", + preferences: { + "fake.user": { + preferenceValue: "experiment value", + preferenceType: "string", + preferenceBranchType: "user", + }, + }, + }), + ]), + async function testSaveStartupPrefsUserBranch() { + Assert.deepEqual( + Services.prefs.getChildList(startupPrefs), + [], + "As a prerequisite no startup prefs are set" + ); + + await PreferenceExperiments.saveStartupPrefs(); + + Assert.deepEqual( + Services.prefs.getChildList(startupPrefs), + [`${startupPrefs}.fake.default`], + "only the expected prefs are set" + ); + is( + Services.prefs.getCharPref( + `${startupPrefs}.fake.default`, + "fallback value" + ), + "experiment value", + "The startup value for fake.default was set" + ); + is( + Services.prefs.getPrefType(`${startupPrefs}.fake.user`), + Services.prefs.PREF_INVALID, + "The startup value for fake.user was not set" + ); + + Services.prefs.deleteBranch(startupPrefs); + } +); + +// test that default branch prefs restore to the right value if the default pref changes +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "startObserver"), + withStub(PreferenceExperiments, "stopObserver"), + async function testDefaultBranchStop({ mockPreferences }) { + const prefName = "fake.preference"; + mockPreferences.set(prefName, "old version's value", "default"); + + // start an experiment + await PreferenceExperiments.start({ + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + [prefName]: { + preferenceValue: "experiment value", + preferenceBranchType: "default", + preferenceType: "string", + }, + }, + }); + + is( + Services.prefs.getCharPref(prefName), + "experiment value", + "Starting an experiment should change the pref" + ); + + // Now pretend that firefox has updated and restarted to a version + // where the built-default value of fake.preference is something + // else. Bootstrap has run and changed the pref to the + // experimental value, and produced the call to + // recordOriginalValues below. + PreferenceExperiments.recordOriginalValues({ + [prefName]: "new version's value", + }); + is( + Services.prefs.getCharPref(prefName), + "experiment value", + "Recording original values shouldn't affect the preference." + ); + + // Now stop the experiment. It should revert to the new version's default, not the old. + await PreferenceExperiments.stop("test"); + is( + Services.prefs.getCharPref(prefName), + "new version's value", + "Preference should revert to new default" + ); + } +); + +// test that default branch prefs restore to the right value if the preference is removed +decorate_task( + withMockExperiments(), + withMockPreferences(), + withStub(PreferenceExperiments, "startObserver"), + withStub(PreferenceExperiments, "stopObserver"), + async function testDefaultBranchStop({ mockPreferences }) { + const prefName = "fake.preference"; + mockPreferences.set(prefName, "old version's value", "default"); + + // start an experiment + await PreferenceExperiments.start({ + slug: "test", + actionName: "SomeAction", + branch: "branch", + preferences: { + [prefName]: { + preferenceValue: "experiment value", + preferenceBranchType: "default", + preferenceType: "string", + }, + }, + }); + + is( + Services.prefs.getCharPref(prefName), + "experiment value", + "Starting an experiment should change the pref" + ); + + // Now pretend that firefox has updated and restarted to a version + // where fake.preference has been removed in the default pref set. + // Bootstrap has run and changed the pref to the experimental + // value, and produced the call to recordOriginalValues below. + PreferenceExperiments.recordOriginalValues({ [prefName]: null }); + is( + Services.prefs.getCharPref(prefName), + "experiment value", + "Recording original values shouldn't affect the preference." + ); + + // Now stop the experiment. It should remove the preference + await PreferenceExperiments.stop("test"); + is( + Services.prefs.getCharPref(prefName, "DEFAULT"), + "DEFAULT", + "Preference should be absent" + ); + } +).skip(/* bug 1502410 and bug 1505941 */); + +// stop should pass "unknown" to telemetry event for `reason` if none is specified +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test", + preferences: { + "fake.preference": { + preferenceValue: "experiment value", + preferenceType: "string", + }, + }, + }), + ]), + withMockPreferences(), + withStub(PreferenceExperiments, "stopObserver"), + withSendEventSpy(), + async function testStopUnknownReason({ mockPreferences, sendEventSpy }) { + mockPreferences.set("fake.preference", "default value", "default"); + await PreferenceExperiments.stop("test"); + is( + sendEventSpy.getCall(0).args[3].reason, + "unknown", + "PreferenceExperiments.stop() should use unknown as the default reason" + ); + } +); + +// stop should pass along the value for resetValue to Telemetry Events as didResetValue +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + slug: "test1", + preferences: { + "fake.preference1": { + preferenceValue: "experiment value", + preferenceType: "string", + previousValue: "previous", + }, + }, + }), + preferenceStudyFactory({ + slug: "test2", + preferences: { + "fake.preference2": { + preferenceValue: "experiment value", + preferenceType: "string", + previousValue: "previous", + }, + }, + }), + ]), + withMockPreferences(), + withStub(PreferenceExperiments, "stopObserver"), + withSendEventSpy(), + async function testStopResetValue({ mockPreferences, sendEventSpy }) { + mockPreferences.set("fake.preference1", "default value", "default"); + await PreferenceExperiments.stop("test1", { resetValue: true }); + is(sendEventSpy.callCount, 1); + is( + sendEventSpy.getCall(0).args[3].didResetValue, + "true", + "PreferenceExperiments.stop() should pass true values of resetValue as didResetValue" + ); + + mockPreferences.set("fake.preference2", "default value", "default"); + await PreferenceExperiments.stop("test2", { resetValue: false }); + is(sendEventSpy.callCount, 2); + is( + sendEventSpy.getCall(1).args[3].didResetValue, + "false", + "PreferenceExperiments.stop() should pass false values of resetValue as didResetValue" + ); + } +); + +// `recordPrefChange` should send the right telemetry and mark the pref as +// overridden when passed an experiment +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + preferences: { + "test.pref": {}, + }, + }), + ]), + withSendEventSpy(), + async function testRecordPrefChangeWorks({ + sendEventSpy, + prefExperiments: [experiment], + }) { + is( + experiment.preferences["test.pref"].overridden, + false, + "Precondition: the pref should not be overridden yet" + ); + + await PreferenceExperiments.recordPrefChange({ + experiment, + preferenceName: "test.pref", + reason: "test-run", + }); + + experiment = await PreferenceExperiments.get(experiment.slug); + is( + experiment.preferences["test.pref"].overridden, + true, + "The pref should be marked as overridden" + ); + sendEventSpy.assertEvents([ + [ + "expPrefChanged", + "preference_study", + experiment.slug, + { + preferenceName: "test.pref", + reason: "test-run", + enrollmentId: experiment.enrollmentId, + }, + ], + ]); + } +); + +// `recordPrefChange` should send the right telemetry and mark the pref as +// overridden when passed a slug +decorate_task( + withMockExperiments([ + preferenceStudyFactory({ + preferences: { + "test.pref": {}, + }, + }), + ]), + withSendEventSpy(), + async function testRecordPrefChangeWorks({ + sendEventSpy, + prefExperiments: [experiment], + }) { + is( + experiment.preferences["test.pref"].overridden, + false, + "Precondition: the pref should not be overridden yet" + ); + + await PreferenceExperiments.recordPrefChange({ + experimentSlug: experiment.slug, + preferenceName: "test.pref", + reason: "test-run", + }); + + experiment = await PreferenceExperiments.get(experiment.slug); + is( + experiment.preferences["test.pref"].overridden, + true, + "The pref should be marked as overridden" + ); + sendEventSpy.assertEvents([ + [ + "expPrefChanged", + "preference_study", + experiment.slug, + { + preferenceName: "test.pref", + reason: "test-run", + enrollmentId: experiment.enrollmentId, + }, + ], + ]); + } +); + +// When a default-branch experiment starts, prefs that already have user values +// should not be changed. +decorate_task( + withMockExperiments(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + withMockPreferences(), + async function testOverriddenAtEnrollNoChange({ mockPreferences }) { + // Set up a situation where the user has changed the value of the pref away + // from the default. Then run a default experiment that changes the + // preference to the same value. + mockPreferences.set("test.pref", "old value", "default"); + mockPreferences.set("test.pref", "new value", "user"); + + await PreferenceExperiments.start({ + slug: "test-experiment", + actionName: "someAction", + branch: "experimental-branch", + preferences: { + "test.pref": { + preferenceValue: "new value", + preferenceType: "string", + preferenceBranchType: "default", + }, + }, + experimentType: "pref-test", + }); + + is( + Services.prefs.getCharPref("test.pref"), + "new value", + "User value should be preserved" + ); + is( + Services.prefs.getDefaultBranch("").getCharPref("test.pref"), + "old value", + "Default value should not have changed" + ); + + const experiment = await PreferenceExperiments.get("test-experiment"); + ok( + experiment.preferences["test.pref"].overridden, + "Pref should be marked as overridden" + ); + } +); + +// When a default-branch experiment starts, prefs that already exist and that +// have user values should not be changed. +// Bug 1735344: +// eslint-disable-next-line mozilla/reject-addtask-only +decorate_task( + withMockExperiments(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + withMockPreferences(), + async function testOverriddenAtEnrollNoChange({ mockPreferences }) { + // Set up a situation where the user has changed the value of the pref away + // from the default. Then run a default experiment that changes the + // preference to the same value. + + // An arbitrary string preference that won't interact with Normandy. + let pref = "extensions.recommendations.privacyPolicyUrl"; + let defaultValue = Services.prefs.getCharPref(pref); + + mockPreferences.set(pref, "user-set-value", "user"); + + await PreferenceExperiments.start({ + slug: "test-experiment", + actionName: "someAction", + branch: "experimental-branch", + preferences: { + [pref]: { + preferenceValue: "experiment-value", + preferenceType: "string", + preferenceBranchType: "default", + }, + }, + experimentType: "pref-test", + }); + + is( + Services.prefs.getCharPref(pref), + "user-set-value", + "User value should be preserved" + ); + is( + Services.prefs.getDefaultBranch("").getCharPref(pref), + defaultValue, + "Default value should not have changed" + ); + + const experiment = await PreferenceExperiments.get("test-experiment"); + ok( + experiment.preferences[pref].overridden, + "Pref should be marked as overridden" + ); + } +).only(); diff --git a/toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js b/toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js new file mode 100644 index 0000000000..43536418ab --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js @@ -0,0 +1,316 @@ +"use strict"; + +const { IndexedDB } = ChromeUtils.importESModule( + "resource://gre/modules/IndexedDB.sys.mjs" +); + +const { PreferenceRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceRollouts.sys.mjs" +); +const { + NormandyTestUtils: { + factories: { preferenceRolloutFactory }, + }, +} = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +decorate_task( + PreferenceRollouts.withTestMock(), + async function testGetMissing() { + ok( + !(await PreferenceRollouts.get("does-not-exist")), + "get should return null when the requested rollout does not exist" + ); + } +); + +decorate_task( + PreferenceRollouts.withTestMock(), + async function testAddUpdateAndGet() { + const rollout = { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [], + enrollmentId: "test-enrollment-id", + }; + await PreferenceRollouts.add(rollout); + let storedRollout = await PreferenceRollouts.get(rollout.slug); + Assert.deepEqual( + rollout, + storedRollout, + "get should retrieve a rollout from storage." + ); + + rollout.state = PreferenceRollouts.STATE_GRADUATED; + await PreferenceRollouts.update(rollout); + storedRollout = await PreferenceRollouts.get(rollout.slug); + Assert.deepEqual( + rollout, + storedRollout, + "get should retrieve a rollout from storage." + ); + } +); + +decorate_task( + PreferenceRollouts.withTestMock(), + async function testCantUpdateNonexistent() { + const rollout = { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [], + }; + await Assert.rejects( + PreferenceRollouts.update(rollout), + /doesn't already exist/, + "Update should fail if the rollout doesn't exist" + ); + ok( + !(await PreferenceRollouts.has("test-rollout")), + "rollout should not have been added" + ); + } +); + +decorate_task(PreferenceRollouts.withTestMock(), async function testGetAll() { + const rollout1 = { + slug: "test-rollout-1", + preference: [], + enrollmentId: "test-enrollment-id-1", + }; + const rollout2 = { + slug: "test-rollout-2", + preference: [], + enrollmentId: "test-enrollment-id-2", + }; + await PreferenceRollouts.add(rollout1); + await PreferenceRollouts.add(rollout2); + + const storedRollouts = await PreferenceRollouts.getAll(); + Assert.deepEqual( + storedRollouts.sort((a, b) => a.id - b.id), + [rollout1, rollout2], + "getAll should return every stored rollout." + ); +}); + +decorate_task( + PreferenceRollouts.withTestMock(), + async function testGetAllActive() { + const rollout1 = { + slug: "test-rollout-1", + state: PreferenceRollouts.STATE_ACTIVE, + enrollmentId: "test-enrollment-1", + }; + const rollout2 = { + slug: "test-rollout-2", + state: PreferenceRollouts.STATE_GRADUATED, + enrollmentId: "test-enrollment-2", + }; + const rollout3 = { + slug: "test-rollout-3", + state: PreferenceRollouts.STATE_ROLLED_BACK, + enrollmentId: "test-enrollment-3", + }; + await PreferenceRollouts.add(rollout1); + await PreferenceRollouts.add(rollout2); + await PreferenceRollouts.add(rollout3); + + const activeRollouts = await PreferenceRollouts.getAllActive(); + Assert.deepEqual( + activeRollouts, + [rollout1], + "getAllActive should return only active rollouts" + ); + } +); + +decorate_task(PreferenceRollouts.withTestMock(), async function testHas() { + const rollout = { + slug: "test-rollout", + preferences: [], + enrollmentId: "test-enrollment", + }; + await PreferenceRollouts.add(rollout); + ok( + await PreferenceRollouts.has(rollout.slug), + "has should return true for an existing rollout" + ); + ok( + !(await PreferenceRollouts.has("does not exist")), + "has should return false for a missing rollout" + ); +}); + +// recordOriginalValue should update storage to note the original values +decorate_task( + PreferenceRollouts.withTestMock(), + async function testRecordOriginalValuesUpdatesPreviousValues() { + await PreferenceRollouts.add({ + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref", value: 2, previousValue: null }, + ], + enrollmentId: "test-enrollment", + }); + + await PreferenceRollouts.recordOriginalValues({ "test.pref": 1 }); + + Assert.deepEqual( + await PreferenceRollouts.getAll(), + [ + { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref", value: 2, previousValue: 1 }, + ], + enrollmentId: "test-enrollment", + }, + ], + "rollout in database should be updated" + ); + } +); + +// recordOriginalValue should graduate a study when all of its preferences are built-in +decorate_task( + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function testRecordOriginalValuesGraduates({ sendEventSpy }) { + await PreferenceRollouts.add({ + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref1", value: 2, previousValue: null }, + { preferenceName: "test.pref2", value: 2, previousValue: null }, + ], + enrollmentId: "test-enrollment-id", + }); + + // one pref being the same isn't enough to graduate + await PreferenceRollouts.recordOriginalValues({ + "test.pref1": 1, + "test.pref2": 2, + }); + let rollout = await PreferenceRollouts.get("test-rollout"); + is( + rollout.state, + PreferenceRollouts.STATE_ACTIVE, + "rollouts should remain active when only one pref matches the built-in default" + ); + + sendEventSpy.assertEvents([]); + + // both prefs is enough + await PreferenceRollouts.recordOriginalValues({ + "test.pref1": 2, + "test.pref2": 2, + }); + rollout = await PreferenceRollouts.get("test-rollout"); + is( + rollout.state, + PreferenceRollouts.STATE_GRADUATED, + "rollouts should graduate when all prefs matches the built-in defaults" + ); + + sendEventSpy.assertEvents([ + [ + "graduate", + "preference_rollout", + "test-rollout", + { enrollmentId: "test-enrollment-id" }, + ], + ]); + } +); + +// init should mark active rollouts in telemetry +decorate_task( + withStub(TelemetryEnvironment, "setExperimentActive"), + PreferenceRollouts.withTestMock(), + async function testInitTelemetry({ setExperimentActiveStub }) { + await PreferenceRollouts.add({ + slug: "test-rollout-active-1", + state: PreferenceRollouts.STATE_ACTIVE, + enrollmentId: "test-enrollment-1", + }); + await PreferenceRollouts.add({ + slug: "test-rollout-active-2", + state: PreferenceRollouts.STATE_ACTIVE, + enrollmentId: "test-enrollment-2", + }); + await PreferenceRollouts.add({ + slug: "test-rollout-rolled-back", + state: PreferenceRollouts.STATE_ROLLED_BACK, + enrollmentId: "test-enrollment-3", + }); + await PreferenceRollouts.add({ + slug: "test-rollout-graduated", + state: PreferenceRollouts.STATE_GRADUATED, + enrollmentId: "test-enrollment-4", + }); + + await PreferenceRollouts.init(); + + Assert.deepEqual( + setExperimentActiveStub.args, + [ + [ + "test-rollout-active-1", + "active", + { type: "normandy-prefrollout", enrollmentId: "test-enrollment-1" }, + ], + [ + "test-rollout-active-2", + "active", + { type: "normandy-prefrollout", enrollmentId: "test-enrollment-2" }, + ], + ], + "init should set activate a telemetry experiment for active preferences" + ); + } +); + +// init should graduate rollouts in the graduation set +decorate_task( + withStub(TelemetryEnvironment, "setExperimentActive"), + withSendEventSpy(), + PreferenceRollouts.withTestMock({ + graduationSet: new Set(["test-rollout"]), + rollouts: [ + preferenceRolloutFactory({ + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + enrollmentId: "test-enrollment-id", + }), + ], + }), + async function testInitGraduationSet({ + setExperimentActiveStub, + sendEventSpy, + }) { + await PreferenceRollouts.init(); + const newRollout = await PreferenceRollouts.get("test-rollout"); + Assert.equal( + newRollout.state, + PreferenceRollouts.STATE_GRADUATED, + "the rollout should be graduated" + ); + Assert.deepEqual( + setExperimentActiveStub.args, + [], + "setExperimentActive should not be called" + ); + sendEventSpy.assertEvents([ + [ + "graduate", + "preference_rollout", + "test-rollout", + { enrollmentId: "test-enrollment-id", reason: "in-graduation-set" }, + ], + ]); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_RecipeRunner.js b/toolkit/components/normandy/test/browser/browser_RecipeRunner.js new file mode 100644 index 0000000000..d5b37b5c67 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_RecipeRunner.js @@ -0,0 +1,874 @@ +"use strict"; + +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); +const { FilterExpressions } = ChromeUtils.importESModule( + "resource://gre/modules/components-utils/FilterExpressions.sys.mjs" +); + +const { Normandy } = ChromeUtils.importESModule( + "resource://normandy/Normandy.sys.mjs" +); +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { RecipeRunner } = ChromeUtils.importESModule( + "resource://normandy/lib/RecipeRunner.sys.mjs" +); +const { ClientEnvironment } = ChromeUtils.importESModule( + "resource://normandy/lib/ClientEnvironment.sys.mjs" +); +const { CleanupManager } = ChromeUtils.importESModule( + "resource://normandy/lib/CleanupManager.sys.mjs" +); +const { ActionsManager } = ChromeUtils.importESModule( + "resource://normandy/lib/ActionsManager.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +add_task(async function getFilterContext() { + const recipe = { id: 17, arguments: { foo: "bar" }, unrelated: false }; + const context = RecipeRunner.getFilterContext(recipe); + + // Test for expected properties in the filter expression context. + const expectedNormandyKeys = [ + "channel", + "country", + "distribution", + "doNotTrack", + "isDefaultBrowser", + "locale", + "plugins", + "recipe", + "request_time", + "searchEngine", + "syncDesktopDevices", + "syncMobileDevices", + "syncSetup", + "syncTotalDevices", + "telemetry", + "userId", + "version", + ]; + for (const key of expectedNormandyKeys) { + ok(key in context.env, `env.${key} is available`); + ok(key in context.normandy, `normandy.${key} is available`); + } + Assert.deepEqual( + context.normandy, + context.env, + "context offers normandy as backwards-compatible alias for context.environment" + ); + + is( + context.env.recipe.id, + recipe.id, + "environment.recipe is the recipe passed to getFilterContext" + ); + is( + ClientEnvironment.recipe, + undefined, + "ClientEnvironment has not been mutated" + ); + delete recipe.unrelated; + Assert.deepEqual( + context.env.recipe, + recipe, + "environment.recipe drops unrecognized attributes from the recipe" + ); + + // Filter context attributes are cached. + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.user_id", "some id"]], + }); + is(context.env.userId, "some id", "User id is read from prefs when accessed"); + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.user_id", "real id"]], + }); + is(context.env.userId, "some id", "userId was cached"); +}); + +add_task( + withStub(NormandyApi, "verifyObjectSignature"), + async function test_getRecipeSuitability_filterExpressions() { + const check = filter => + RecipeRunner.getRecipeSuitability({ filter_expression: filter }); + + // Errors must result in a false return value. + is( + await check("invalid ( + 5yntax"), + BaseAction.suitability.FILTER_ERROR, + "Invalid filter expressions return false" + ); + + // Non-boolean filter results result in a true return value. + is( + await check("[1, 2, 3]"), + BaseAction.suitability.FILTER_MATCH, + "Non-boolean filter expressions return true" + ); + + // The given recipe must be available to the filter context. + const recipe = { filter_expression: "normandy.recipe.id == 7", id: 7 }; + is( + await RecipeRunner.getRecipeSuitability(recipe), + BaseAction.suitability.FILTER_MATCH, + "The recipe is available in the filter context" + ); + recipe.id = 4; + is( + await RecipeRunner.getRecipeSuitability(recipe), + BaseAction.suitability.FILTER_MISMATCH, + "The recipe is available in the filter context" + ); + } +); + +decorate_task( + withStub(FilterExpressions, "eval"), + withStub(Uptake, "reportRecipe"), + withStub(NormandyApi, "verifyObjectSignature"), + async function test_getRecipeSuitability_canHandleExceptions({ + evalStub, + reportRecipeStub, + }) { + evalStub.throws("this filter was broken somehow"); + const someRecipe = { + id: "1", + action: "action", + filter_expression: "broken", + }; + const result = await RecipeRunner.getRecipeSuitability(someRecipe); + + is( + result, + BaseAction.suitability.FILTER_ERROR, + "broken filters are reported" + ); + Assert.deepEqual(reportRecipeStub.args, [ + [someRecipe, Uptake.RECIPE_FILTER_BROKEN], + ]); + } +); + +decorate_task( + withSpy(FilterExpressions, "eval"), + withStub(RecipeRunner, "getCapabilities"), + withStub(NormandyApi, "verifyObjectSignature"), + async function test_getRecipeSuitability_checksCapabilities({ + evalSpy, + getCapabilitiesStub, + }) { + getCapabilitiesStub.returns(new Set(["test-capability"])); + + is( + await RecipeRunner.getRecipeSuitability({ + filter_expression: "true", + }), + BaseAction.suitability.FILTER_MATCH, + "Recipes with no capabilities should pass" + ); + ok(evalSpy.called, "Filter should be evaluated"); + + evalSpy.resetHistory(); + is( + await RecipeRunner.getRecipeSuitability({ + capabilities: [], + filter_expression: "true", + }), + BaseAction.suitability.FILTER_MATCH, + "Recipes with empty capabilities should pass" + ); + ok(evalSpy.called, "Filter should be evaluated"); + + evalSpy.resetHistory(); + is( + await RecipeRunner.getRecipeSuitability({ + capabilities: ["test-capability"], + filter_expression: "true", + }), + BaseAction.suitability.FILTER_MATCH, + "Recipes with a matching capability should pass" + ); + ok(evalSpy.called, "Filter should be evaluated"); + + evalSpy.resetHistory(); + is( + await RecipeRunner.getRecipeSuitability({ + capabilities: ["impossible-capability"], + filter_expression: "true", + }), + BaseAction.suitability.CAPABILITIES_MISMATCH, + "Recipes with non-matching capabilities should not pass" + ); + ok(!evalSpy.called, "Filter should not be evaluated"); + } +); + +decorate_task( + withMockNormandyApi(), + withStub(ClientEnvironment, "getClientClassification"), + async function testClientClassificationCache({ + mockNormandyApi, + getClientClassificationStub, + }) { + getClientClassificationStub.returns(Promise.resolve(false)); + + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.api_url", "https://example.com/selfsupport-dummy"]], + }); + + // When the experiment pref is false, eagerly call getClientClassification. + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.experiments.lazy_classify", false]], + }); + ok( + !getClientClassificationStub.called, + "getClientClassification hasn't been called" + ); + await RecipeRunner.run(); + ok( + getClientClassificationStub.called, + "getClientClassification was called eagerly" + ); + + // When the experiment pref is true, do not eagerly call getClientClassification. + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.experiments.lazy_classify", true]], + }); + getClientClassificationStub.reset(); + ok( + !getClientClassificationStub.called, + + "getClientClassification hasn't been called" + ); + await RecipeRunner.run(); + ok( + !getClientClassificationStub.called, + + "getClientClassification was not called eagerly" + ); + } +); + +decorate_task( + withStub(Uptake, "reportRunner"), + withStub(ActionsManager.prototype, "finalize"), + NormandyTestUtils.withMockRecipeCollection([]), + async function testRunEvents() { + const startPromise = TestUtils.topicObserved("recipe-runner:start"); + const endPromise = TestUtils.topicObserved("recipe-runner:end"); + + await RecipeRunner.run(); + + // Will timeout if notifications were not received. + await startPromise; + await endPromise; + ok(true, "The test should pass without timing out"); + } +); + +decorate_task( + withStub(RecipeRunner, "getCapabilities"), + withStub(NormandyApi, "verifyObjectSignature"), + NormandyTestUtils.withMockRecipeCollection([{ id: 1 }]), + async function test_run_includesCapabilities({ getCapabilitiesStub }) { + getCapabilitiesStub.returns(new Set(["test-capability"])); + await RecipeRunner.run(); + ok(getCapabilitiesStub.called, "getCapabilities should be called"); + } +); + +decorate_task( + withStub(NormandyApi, "verifyObjectSignature"), + withStub(ActionsManager.prototype, "processRecipe"), + withStub(ActionsManager.prototype, "finalize"), + withStub(Uptake, "reportRecipe"), + async function testReadFromRemoteSettings({ + verifyObjectSignatureStub, + processRecipeStub, + finalizeStub, + reportRecipeStub, + }) { + const matchRecipe = { + id: 1, + name: "match", + action: "matchAction", + filter_expression: "true", + }; + const noMatchRecipe = { + id: 2, + name: "noMatch", + action: "noMatchAction", + filter_expression: "false", + }; + const missingRecipe = { + id: 3, + name: "missing", + action: "missingAction", + filter_expression: "true", + }; + + const db = await RecipeRunner._remoteSettingsClientForTesting.db; + const fakeSig = { signature: "abc" }; + await db.importChanges({}, Date.now(), [ + { id: "match", recipe: matchRecipe, signature: fakeSig }, + { + id: "noMatch", + recipe: noMatchRecipe, + signature: fakeSig, + }, + { + id: "missing", + recipe: missingRecipe, + signature: fakeSig, + }, + ]); + + let recipesFromRS = ( + await RecipeRunner._remoteSettingsClientForTesting.get() + ).map(({ recipe, signature }) => recipe); + // Sort the records by id so that they match the order in the assertion + recipesFromRS.sort((a, b) => a.id - b.id); + Assert.deepEqual( + recipesFromRS, + [matchRecipe, noMatchRecipe, missingRecipe], + "The recipes should be accesible from Remote Settings" + ); + + await RecipeRunner.run(); + + Assert.deepEqual( + verifyObjectSignatureStub.args, + [ + [matchRecipe, fakeSig, "recipe"], + [missingRecipe, fakeSig, "recipe"], + [noMatchRecipe, fakeSig, "recipe"], + ], + "all recipes should have their signature verified" + ); + Assert.deepEqual( + processRecipeStub.args, + [ + [matchRecipe, BaseAction.suitability.FILTER_MATCH], + [missingRecipe, BaseAction.suitability.FILTER_MATCH], + [noMatchRecipe, BaseAction.suitability.FILTER_MISMATCH], + ], + "Recipes should be reported with the correct suitabilities" + ); + Assert.deepEqual( + reportRecipeStub.args, + [[noMatchRecipe, Uptake.RECIPE_DIDNT_MATCH_FILTER]], + "Filtered-out recipes should be reported" + ); + } +); + +decorate_task( + withStub(NormandyApi, "verifyObjectSignature"), + withStub(ActionsManager.prototype, "processRecipe"), + withStub(RecipeRunner, "getCapabilities"), + async function testReadFromRemoteSettings({ + processRecipeStub, + getCapabilitiesStub, + }) { + getCapabilitiesStub.returns(new Set(["compatible"])); + const compatibleRecipe = { + name: "match", + filter_expression: "true", + capabilities: ["compatible"], + }; + const incompatibleRecipe = { + name: "noMatch", + filter_expression: "true", + capabilities: ["incompatible"], + }; + + const db = await RecipeRunner._remoteSettingsClientForTesting.db; + const fakeSig = { signature: "abc" }; + await db.importChanges( + {}, + Date.now(), + [ + { + id: "match", + recipe: compatibleRecipe, + signature: fakeSig, + }, + { + id: "noMatch", + recipe: incompatibleRecipe, + signature: fakeSig, + }, + ], + { + clear: true, + } + ); + + await RecipeRunner.run(); + + Assert.deepEqual( + processRecipeStub.args, + [ + [compatibleRecipe, BaseAction.suitability.FILTER_MATCH], + [incompatibleRecipe, BaseAction.suitability.CAPABILITIES_MISMATCH], + ], + "recipes should be marked if their capabilities aren't compatible" + ); + } +); + +decorate_task( + withStub(ActionsManager.prototype, "processRecipe"), + withStub(NormandyApi, "verifyObjectSignature"), + withStub(Uptake, "reportRecipe"), + NormandyTestUtils.withMockRecipeCollection(), + async function testBadSignatureFromRemoteSettings({ + processRecipeStub, + verifyObjectSignatureStub, + reportRecipeStub, + mockRecipeCollection, + }) { + verifyObjectSignatureStub.throws(new Error("fake signature error")); + const badSigRecipe = { + id: 1, + name: "badSig", + action: "matchAction", + filter_expression: "true", + }; + await mockRecipeCollection.addRecipes([badSigRecipe]); + + await RecipeRunner.run(); + + Assert.deepEqual(processRecipeStub.args, [ + [badSigRecipe, BaseAction.suitability.SIGNATURE_ERROR], + ]); + Assert.deepEqual( + reportRecipeStub.args, + [[badSigRecipe, Uptake.RECIPE_INVALID_SIGNATURE]], + "The recipe should have its uptake status recorded" + ); + } +); + +// Test init() during normal operation +decorate_task( + withPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], // telemetry enabled + ["app.normandy.dev_mode", false], + ["app.normandy.first_run", false], + ], + }), + withStub(RecipeRunner, "run"), + withStub(RecipeRunner, "registerTimer"), + async function testInit({ runStub, registerTimerStub }) { + await RecipeRunner.init(); + ok( + !runStub.called, + "RecipeRunner.run should not be called immediately when not in dev mode or first run" + ); + ok(registerTimerStub.called, "RecipeRunner.init registers a timer"); + } +); + +// test init() in dev mode +decorate_task( + withPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], // telemetry enabled + ["app.normandy.dev_mode", true], + ], + }), + withStub(RecipeRunner, "run"), + withStub(RecipeRunner, "registerTimer"), + withStub(RecipeRunner._remoteSettingsClientForTesting, "sync"), + async function testInitDevMode({ runStub, registerTimerStub, syncStub }) { + await RecipeRunner.init(); + Assert.deepEqual( + runStub.args, + [[{ trigger: "devMode" }]], + "RecipeRunner.run should be called immediately when in dev mode" + ); + ok(registerTimerStub.called, "RecipeRunner.init should register a timer"); + ok( + syncStub.called, + "RecipeRunner.init should sync remote settings in dev mode" + ); + } +); + +// Test init() first run +decorate_task( + withPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], // telemetry enabled + ["app.normandy.dev_mode", false], + ["app.normandy.first_run", true], + ], + }), + withStub(RecipeRunner, "run"), + withStub(RecipeRunner, "registerTimer"), + withStub(RecipeRunner, "watchPrefs"), + async function testInitFirstRun({ + runStub, + registerTimerStub, + watchPrefsStub, + }) { + await RecipeRunner.init(); + Assert.deepEqual( + runStub.args, + [[{ trigger: "firstRun" }]], + "RecipeRunner.run is called immediately on first run" + ); + ok( + !Services.prefs.getBoolPref("app.normandy.first_run"), + "On first run, the first run pref is set to false" + ); + ok( + registerTimerStub.called, + "RecipeRunner.registerTimer registers a timer" + ); + + // RecipeRunner.init() sets this pref to false, but SpecialPowers + // relies on the preferences it manages to actually change when it + // tries to change them. Settings this back to true here allows + // that to happen. Not doing this causes popPrefEnv to hang forever. + Services.prefs.setBoolPref("app.normandy.first_run", true); + } +); + +// Test that prefs are watched correctly +decorate_task( + withPrefEnv({ + set: [ + ["app.normandy.dev_mode", false], + ["app.normandy.first_run", false], + ["app.normandy.enabled", true], + ], + }), + withStub(RecipeRunner, "run"), + withStub(RecipeRunner, "enable"), + withStub(RecipeRunner, "disable"), + withStub(CleanupManager, "addCleanupHandler"), + + async function testPrefWatching({ runStub, enableStub, disableStub }) { + await RecipeRunner.init(); + is(enableStub.callCount, 1, "Enable should be called initially"); + is(disableStub.callCount, 0, "Disable should not be called initially"); + + await SpecialPowers.pushPrefEnv({ set: [["app.normandy.enabled", false]] }); + is(enableStub.callCount, 1, "Enable should not be called again"); + is( + disableStub.callCount, + 1, + "RecipeRunner should disable when Shield is disabled" + ); + + await SpecialPowers.pushPrefEnv({ set: [["app.normandy.enabled", true]] }); + is( + enableStub.callCount, + 2, + "RecipeRunner should re-enable when Shield is enabled" + ); + is(disableStub.callCount, 1, "Disable should not be called again"); + + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.api_url", "http://example.com"]], + }); // does not start with https:// + is(enableStub.callCount, 2, "Enable should not be called again"); + is( + disableStub.callCount, + 2, + "RecipeRunner should disable when an invalid api url is given" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.api_url", "https://example.com"]], + }); // ends with https:// + is( + enableStub.callCount, + 3, + "RecipeRunner should re-enable when a valid api url is given" + ); + is(disableStub.callCount, 2, "Disable should not be called again"); + + is( + runStub.callCount, + 0, + "RecipeRunner.run should not be called during this test" + ); + } +); + +// Test that enable and disable are idempotent +decorate_task( + withStub(RecipeRunner, "registerTimer"), + withStub(RecipeRunner, "unregisterTimer"), + async function testPrefWatching({ registerTimerStub }) { + const originalEnabled = RecipeRunner.enabled; + + try { + RecipeRunner.enabled = false; + RecipeRunner.enable(); + RecipeRunner.enable(); + RecipeRunner.enable(); + is(registerTimerStub.callCount, 1, "Enable should be idempotent"); + + RecipeRunner.enabled = true; + RecipeRunner.disable(); + RecipeRunner.disable(); + RecipeRunner.disable(); + is(registerTimerStub.callCount, 1, "Disable should be idempotent"); + } finally { + RecipeRunner.enabled = originalEnabled; + } + } +); + +decorate_task( + withPrefEnv({ + set: [["app.normandy.onsync_skew_sec", 0]], + }), + withStub(RecipeRunner, "run"), + async function testRunOnSyncRemoteSettings({ runStub }) { + const rsClient = RecipeRunner._remoteSettingsClientForTesting; + await RecipeRunner.init(); + ok( + RecipeRunner._alreadySetUpRemoteSettings, + "remote settings should be set up in the runner" + ); + + // Runner disabled + RecipeRunner.disable(); + await rsClient.emit("sync", {}); + ok(!runStub.called, "run() should not be called if disabled"); + runStub.reset(); + + // Runner enabled + RecipeRunner.enable(); + await rsClient.emit("sync", {}); + ok(runStub.called, "run() should be called if enabled"); + runStub.reset(); + + // Runner disabled + RecipeRunner.disable(); + await rsClient.emit("sync", {}); + ok(!runStub.called, "run() should not be called if disabled"); + runStub.reset(); + + // Runner re-enabled + RecipeRunner.enable(); + await rsClient.emit("sync", {}); + ok(runStub.called, "run() should be called if runner is re-enabled"); + } +); + +decorate_task( + withPrefEnv({ + set: [ + ["app.normandy.onsync_skew_sec", 600], // 10 minutes, much longer than the test will take to run + ], + }), + withStub(RecipeRunner, "run"), + async function testOnSyncRunDelayed({ runStub }) { + ok( + !RecipeRunner._syncSkewTimeout, + "precondition: No timer should be active" + ); + const rsClient = RecipeRunner._remoteSettingsClientForTesting; + await rsClient.emit("sync", {}); + ok(runStub.notCalled, "run() should be not called yet"); + ok(RecipeRunner._syncSkewTimeout, "A timer should be set"); + clearInterval(RecipeRunner._syncSkewTimeout); // cleanup + } +); + +decorate_task( + withStub(RecipeRunner._remoteSettingsClientForTesting, "get"), + async function testRunCanRunOnlyOnce({ getStub }) { + getStub.returns( + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + new Promise(resolve => setTimeout(() => resolve([]), 10)) + ); + + // Run 2 in parallel. + await Promise.all([RecipeRunner.run(), RecipeRunner.run()]); + + is(getStub.callCount, 1, "run() is no-op if already running"); + } +); + +decorate_task( + withPrefEnv({ + set: [ + // Enable update timer logs. + ["app.update.log", true], + ["app.normandy.api_url", "https://example.com"], + ["app.normandy.first_run", false], + ["app.normandy.onsync_skew_sec", 0], + ], + }), + withSpy(RecipeRunner, "run"), + withStub(ActionsManager.prototype, "finalize"), + withStub(Uptake, "reportRunner"), + async function testSyncDelaysTimer({ runSpy }) { + // Mark any existing timer as having run just now. + for (const { value } of Services.catMan.enumerateCategory("update-timer")) { + const timerID = value.split(",")[2]; + console.log(`Mark timer ${timerID} as ran recently`); + // See https://searchfox.org/mozilla-central/rev/11cfa0462/toolkit/components/timermanager/UpdateTimerManager.jsm#8 + const timerLastUpdatePref = `app.update.lastUpdateTime.${timerID}`; + const lastUpdateTime = Math.round(Date.now() / 1000); + Services.prefs.setIntPref(timerLastUpdatePref, lastUpdateTime); + } + + // Give our timer a short duration so that it executes quickly. + // This needs to be more than 1 second as we will call UpdateTimerManager's + // notify method twice in a row and verify that our timer is only called + // once, but because the timestamps are rounded to seconds, just a few + // additional ms could result in a higher value that would cause the timer + // to be called again almost immediately if our timer duration was only 1s. + const kTimerDuration = 2; + Services.prefs.setIntPref( + "app.normandy.run_interval_seconds", + kTimerDuration + ); + // This will refresh the timer interval. + RecipeRunner.unregisterTimer(); + // Ensure our timer is ready to run now. + Services.prefs.setIntPref( + "app.update.lastUpdateTime.recipe-client-addon-run", + Math.round(Date.now() / 1000) - kTimerDuration + ); + RecipeRunner.registerTimer(); + + is(runSpy.callCount, 0, "run() shouldn't have run yet"); + + // Simulate timer notification. + runSpy.resetHistory(); + const service = Cc["@mozilla.org/updates/timer-manager;1"].getService( + Ci.nsITimerCallback + ); + const newTimer = () => { + const t = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + t.initWithCallback(() => {}, 10, Ci.nsITimer.TYPE_ONE_SHOT); + return t; + }; + + // Run timer once, to make sure this test works as expected. + const startTime = Date.now(); + const endPromise = TestUtils.topicObserved("recipe-runner:end"); + service.notify(newTimer()); + await endPromise; // will timeout if run() not called. + const timerLatency = Math.max(Date.now() - startTime, 1); + is(runSpy.callCount, 1, "run() should be called from timer"); + + // Run once from sync event. + runSpy.resetHistory(); + const rsClient = RecipeRunner._remoteSettingsClientForTesting; + await rsClient.emit("sync", {}); // waits for listeners to run. + is(runSpy.callCount, 1, "run() should be called from sync"); + + // Trigger timer again. This should not run recipes again, since a sync just happened + runSpy.resetHistory(); + is(runSpy.callCount, 0, "run() does not run again from timer"); + service.notify(newTimer()); + // Wait at least as long as the latency we had above. Ten times as a margin. + is(runSpy.callCount, 0, "run() does not run again from timer"); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, timerLatency * 10)); + is(runSpy.callCount, 0, "run() does not run again from timer"); + RecipeRunner.disable(); + } +); + +// Test that the capabilities for context variables are generated correctly. +decorate_task(async function testAutomaticCapabilities() { + const capabilities = await RecipeRunner.getCapabilities(); + + ok( + capabilities.has("jexl.context.env.country"), + "context variables from Normandy's client context should be included" + ); + ok( + capabilities.has("jexl.context.env.version"), + "context variables from the superclass context should be included" + ); + ok( + !capabilities.has("jexl.context.env.getClientClassification"), + "non-getter functions should not be included" + ); + ok( + !capabilities.has("jexl.context.env.prototype"), + "built-in, non-enumerable properties should not be included" + ); +}); + +// Test that recipe runner won't run if Normandy hasn't been initialized. +decorate_task( + withStub(Uptake, "reportRunner"), + withStub(ActionsManager.prototype, "finalize"), + NormandyTestUtils.withMockRecipeCollection([]), + async function testRunEvents({ reportRunnerStub, finalizeStub }) { + const observer = sinon.spy(); + Services.obs.addObserver(observer, "recipe-runner:start"); + + const originalPrefsApplied = Normandy.defaultPrefsHaveBeenApplied; + Normandy.defaultPrefsHaveBeenApplied = PromiseUtils.defer(); + + const recipeRunnerPromise = RecipeRunner.run(); + await Promise.resolve(); + ok( + !observer.called, + "RecipeRunner.run shouldn't run if Normandy isn't initialized" + ); + + Normandy.defaultPrefsHaveBeenApplied.resolve(); + await recipeRunnerPromise; + ok( + observer.called, + "RecipeRunner.run should run after Normandy has initialized" + ); + + // cleanup + Services.obs.removeObserver(observer, "recipe-runner:start"); + Normandy.defaultPrefsHaveBeenApplied = originalPrefsApplied; + } +); + +// If no recipes are found on the server, the action manager should be informed of that +decorate_task( + withSpy(ActionsManager.prototype, "finalize"), + NormandyTestUtils.withMockRecipeCollection([]), + async function testNoRecipes({ finalizeSpy }) { + await RecipeRunner.run(); + Assert.deepEqual( + finalizeSpy.args, + [[{ noRecipes: true }]], + "Action manager should know there were no recipes received" + ); + } +); + +// If some recipes are found on the server, the action manager should be informed of that +decorate_task( + withSpy(ActionsManager.prototype, "finalize"), + NormandyTestUtils.withMockRecipeCollection([{ id: 1 }]), + async function testSomeRecipes({ finalizeSpy }) { + await RecipeRunner.run(); + Assert.deepEqual( + finalizeSpy.args, + [[{ noRecipes: false }]], + "Action manager should know there were recipes received" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_ShieldPreferences.js b/toolkit/components/normandy/test/browser/browser_ShieldPreferences.js new file mode 100644 index 0000000000..e455a5f25b --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_ShieldPreferences.js @@ -0,0 +1,91 @@ +"use strict"; + +const { PreferenceExperiments } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceExperiments.sys.mjs" +); +const { ShieldPreferences } = ChromeUtils.importESModule( + "resource://normandy/lib/ShieldPreferences.sys.mjs" +); + +const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled"; + +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); +const { addonStudyFactory, preferenceStudyFactory } = + NormandyTestUtils.factories; + +ShieldPreferences.init(); + +decorate_task( + withMockPreferences(), + AddonStudies.withStudies([ + addonStudyFactory({ active: true }), + addonStudyFactory({ active: true }), + ]), + async function testDisableStudiesWhenOptOutDisabled({ + mockPreferences, + addonStudies: [study1, study2], + }) { + mockPreferences.set(OPT_OUT_STUDIES_ENABLED_PREF, true); + const observers = [ + studyEndObserved(study1.recipeId), + studyEndObserved(study2.recipeId), + ]; + Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, false); + await Promise.all(observers); + + const newStudy1 = await AddonStudies.get(study1.recipeId); + const newStudy2 = await AddonStudies.get(study2.recipeId); + ok( + !newStudy1.active && !newStudy2.active, + "Setting the opt-out pref to false stops all active opt-out studies." + ); + } +); + +decorate_task( + withMockPreferences(), + PreferenceExperiments.withMockExperiments([ + preferenceStudyFactory({ active: true }), + preferenceStudyFactory({ active: true }), + ]), + withStub(PreferenceExperiments, "stop"), + async function testDisableExperimentsWhenOptOutDisabled({ + mockPreferences, + prefExperiments: [study1, study2], + stopStub, + }) { + mockPreferences.set(OPT_OUT_STUDIES_ENABLED_PREF, true); + let stopArgs = []; + let stoppedBoth = new Promise(resolve => { + let calls = 0; + stopStub.callsFake(function () { + stopArgs.push(Array.from(arguments)); + calls++; + if (calls == 2) { + resolve(); + } + }); + }); + Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, false); + await stoppedBoth; + + Assert.deepEqual(stopArgs, [ + [ + study1.slug, + { + reason: "general-opt-out", + caller: "observePrefChange::general-opt-out", + }, + ], + [ + study2.slug, + { + reason: "general-opt-out", + caller: "observePrefChange::general-opt-out", + }, + ], + ]); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_Storage.js b/toolkit/components/normandy/test/browser/browser_Storage.js new file mode 100644 index 0000000000..74272c52d9 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_Storage.js @@ -0,0 +1,43 @@ +"use strict"; + +add_task(async function () { + const store1 = new Storage("prefix1"); + const store2 = new Storage("prefix2"); + + // Make sure values return null before being set + Assert.equal(await store1.getItem("key"), null); + Assert.equal(await store2.getItem("key"), null); + + // Set values to check + await store1.setItem("key", "value1"); + await store2.setItem("key", "value2"); + + // Check that they are available + Assert.equal(await store1.getItem("key"), "value1"); + Assert.equal(await store2.getItem("key"), "value2"); + + // Remove them, and check they are gone + await store1.removeItem("key"); + await store2.removeItem("key"); + Assert.equal(await store1.getItem("key"), null); + Assert.equal(await store2.getItem("key"), null); + + // Check that numbers are stored as numbers (not strings) + await store1.setItem("number", 42); + Assert.equal(await store1.getItem("number"), 42); + + // Check complex types work + const complex = { a: 1, b: [2, 3], c: { d: 4 } }; + await store1.setItem("complex", complex); + Assert.deepEqual(await store1.getItem("complex"), complex); + + // Check that clearing the storage removes data from multiple + // prefixes. + await store1.setItem("removeTest", 1); + await store2.setItem("removeTest", 2); + Assert.equal(await store1.getItem("removeTest"), 1); + Assert.equal(await store2.getItem("removeTest"), 2); + await Storage.clearAllStorage(); + Assert.equal(await store1.getItem("removeTest"), null); + Assert.equal(await store2.getItem("removeTest"), null); +}); diff --git a/toolkit/components/normandy/test/browser/browser_Uptake.js b/toolkit/components/normandy/test/browser/browser_Uptake.js new file mode 100644 index 0000000000..1fa3db3da1 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_Uptake.js @@ -0,0 +1,15 @@ +"use strict"; + +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); + +const Telemetry = Services.telemetry; + +add_task(async function reportRecipeSubmitsFreshness() { + Telemetry.clearScalars(); + const recipe = { id: 17, revision_id: "12" }; + await Uptake.reportRecipe(recipe, Uptake.RECIPE_SUCCESS); + const scalars = Telemetry.getSnapshotForKeyedScalars("main", true); + Assert.deepEqual(scalars.parent["normandy.recipe_freshness"], { 17: 12 }); +}); diff --git a/toolkit/components/normandy/test/browser/browser_about_preferences.js b/toolkit/components/normandy/test/browser/browser_about_preferences.js new file mode 100644 index 0000000000..7b0c706d13 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_about_preferences.js @@ -0,0 +1,106 @@ +"use strict"; + +const OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; + +function withPrivacyPrefs() { + return function (testFunc) { + return async args => + BrowserTestUtils.withNewTab("about:preferences#privacy", async browser => + testFunc({ ...args, browser }) + ); + }; +} + +decorate_task( + withPrefEnv({ + set: [[OPT_OUT_PREF, true]], + }), + withPrivacyPrefs(), + async function testCheckedOnLoad({ browser }) { + const checkbox = browser.contentDocument.getElementById( + "optOutStudiesEnabled" + ); + ok( + checkbox.checked, + "Opt-out checkbox is checked on load when the pref is true" + ); + } +); + +decorate_task( + withPrefEnv({ + set: [[OPT_OUT_PREF, false]], + }), + withPrivacyPrefs(), + async function testUncheckedOnLoad({ browser }) { + const checkbox = browser.contentDocument.getElementById( + "optOutStudiesEnabled" + ); + ok( + !checkbox.checked, + "Opt-out checkbox is unchecked on load when the pref is false" + ); + } +); + +decorate_task( + withPrefEnv({ + set: [[OPT_OUT_PREF, true]], + }), + withPrivacyPrefs(), + async function testCheckboxes({ browser }) { + const optOutCheckbox = browser.contentDocument.getElementById( + "optOutStudiesEnabled" + ); + + optOutCheckbox.click(); + ok( + !Services.prefs.getBoolPref(OPT_OUT_PREF), + "Unchecking the opt-out checkbox sets the pref to false." + ); + optOutCheckbox.click(); + ok( + Services.prefs.getBoolPref(OPT_OUT_PREF), + "Checking the opt-out checkbox sets the pref to true." + ); + } +); + +decorate_task( + withPrefEnv({ + set: [[OPT_OUT_PREF, true]], + }), + withPrivacyPrefs(), + async function testPrefWatchers({ browser }) { + const optOutCheckbox = browser.contentDocument.getElementById( + "optOutStudiesEnabled" + ); + + Services.prefs.setBoolPref(OPT_OUT_PREF, false); + ok( + !optOutCheckbox.checked, + "Disabling the opt-out pref unchecks the opt-out checkbox." + ); + Services.prefs.setBoolPref(OPT_OUT_PREF, true); + ok( + optOutCheckbox.checked, + "Enabling the opt-out pref checks the opt-out checkbox." + ); + } +); + +decorate_task( + withPrivacyPrefs(), + async function testViewStudiesLink({ browser }) { + browser.contentDocument.getElementById("viewShieldStudies").click(); + await BrowserTestUtils.waitForLocationChange(gBrowser); + + is( + gBrowser.currentURI.spec, + "about:studies", + "Clicking the view studies link opens about:studies in a new tab." + ); + + gBrowser.removeCurrentTab(); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_about_studies.js b/toolkit/components/normandy/test/browser/browser_about_studies.js new file mode 100644 index 0000000000..745e961b9a --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_about_studies.js @@ -0,0 +1,825 @@ +"use strict"; + +const { PreferenceExperiments } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceExperiments.sys.mjs" +); +const { RecipeRunner } = ChromeUtils.importESModule( + "resource://normandy/lib/RecipeRunner.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { ExperimentManager } = ChromeUtils.importESModule( + "resource://nimbus/lib/ExperimentManager.sys.mjs" +); +const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule( + "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs" +); + +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); +const { addonStudyFactory, preferenceStudyFactory } = + NormandyTestUtils.factories; + +function withAboutStudies() { + return function (testFunc) { + return async args => + BrowserTestUtils.withNewTab("about:studies", async browser => + testFunc({ ...args, browser }) + ); + }; +} + +// Test that the code renders at all +decorate_task( + withAboutStudies(), + async function testAboutStudiesWorks({ browser }) { + const appFound = await SpecialPowers.spawn( + browser, + [], + () => !!content.document.getElementById("app") + ); + ok(appFound, "App element was found"); + } +); + +// Test that the learn more element is displayed correctly +decorate_task( + withPrefEnv({ + set: [["app.normandy.shieldLearnMoreUrl", "http://test/%OS%/"]], + }), + withAboutStudies(), + async function testLearnMore({ browser }) { + SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + await ContentTaskUtils.waitForCondition(() => + doc.getElementById("shield-studies-learn-more") + ); + doc.getElementById("shield-studies-learn-more").click(); + }); + await BrowserTestUtils.waitForLocationChange(gBrowser); + + const location = browser.currentURI.spec; + is( + location, + AboutPages.aboutStudies.getShieldLearnMoreHref(), + "Clicking Learn More opens the correct page on SUMO." + ); + ok(!location.includes("%OS%"), "The Learn More URL is formatted."); + } +); + +// Test that jumping to preferences worked as expected +decorate_task( + withAboutStudies(), + async function testUpdatePreferences({ browser }) { + let loadPromise = BrowserTestUtils.firstBrowserLoaded(window); + + // We have to use gBrowser instead of browser in most spots since we're + // dealing with a new tab outside of the about:studies tab. + const tab = await BrowserTestUtils.switchTab(gBrowser, () => { + SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + await ContentTaskUtils.waitForCondition(() => + doc.getElementById("shield-studies-update-preferences") + ); + content.document + .getElementById("shield-studies-update-preferences") + .click(); + }); + }); + + await loadPromise; + + const location = gBrowser.currentURI.spec; + is( + location, + "about:preferences#privacy", + "Clicking Update Preferences opens the privacy section of the new about:preferences." + ); + + BrowserTestUtils.removeTab(tab); + } +); + +// Test that the study listing shows studies in the proper order and grouping +decorate_task( + AddonStudies.withStudies([ + addonStudyFactory({ + slug: "fake-study-a", + userFacingName: "A Fake Add-on Study", + active: true, + userFacingDescription: "A fake description", + studyStartDate: new Date(2018, 0, 4), + }), + addonStudyFactory({ + slug: "fake-study-b", + userFacingName: "B Fake Add-on Study", + active: false, + userFacingDescription: "B fake description", + studyStartDate: new Date(2018, 0, 2), + }), + addonStudyFactory({ + slug: "fake-study-c", + userFacingName: "C Fake Add-on Study", + active: true, + userFacingDescription: "C fake description", + studyStartDate: new Date(2018, 0, 1), + }), + ]), + PreferenceExperiments.withMockExperiments([ + preferenceStudyFactory({ + slug: "fake-study-d", + userFacingName: null, + userFacingDescription: null, + lastSeen: new Date(2018, 0, 3), + expired: false, + }), + preferenceStudyFactory({ + slug: "fake-study-e", + userFacingName: "E Fake Preference Study", + lastSeen: new Date(2018, 0, 5), + expired: true, + }), + preferenceStudyFactory({ + slug: "fake-study-f", + userFacingName: "F Fake Preference Study", + lastSeen: new Date(2018, 0, 6), + expired: false, + }), + ]), + withAboutStudies(), + async function testStudyListing({ addonStudies, prefExperiments, browser }) { + await SpecialPowers.spawn( + browser, + [{ addonStudies, prefExperiments }], + async ({ addonStudies, prefExperiments }) => { + const doc = content.document; + + function getStudyRow(docElem, slug) { + return docElem.querySelector(`.study[data-study-slug="${slug}"]`); + } + + await ContentTaskUtils.waitForCondition( + () => doc.querySelectorAll(".active-study-list .study").length + ); + const activeNames = Array.from( + doc.querySelectorAll(".active-study-list .study") + ).map(row => row.dataset.studySlug); + const inactiveNames = Array.from( + doc.querySelectorAll(".inactive-study-list .study") + ).map(row => row.dataset.studySlug); + + Assert.deepEqual( + activeNames, + [ + prefExperiments[2].slug, + addonStudies[0].slug, + prefExperiments[0].slug, + addonStudies[2].slug, + ], + "Active studies are grouped by enabled status, and sorted by date" + ); + Assert.deepEqual( + inactiveNames, + [prefExperiments[1].slug, addonStudies[1].slug], + "Inactive studies are grouped by enabled status, and sorted by date" + ); + + const activeAddonStudy = getStudyRow(doc, addonStudies[0].slug); + ok( + activeAddonStudy + .querySelector(".study-description") + .textContent.includes(addonStudies[0].userFacingDescription), + "Study descriptions are shown in about:studies." + ); + is( + activeAddonStudy.querySelector(".study-status").textContent, + "Active", + "Active studies show an 'Active' indicator." + ); + ok( + activeAddonStudy.querySelector(".remove-button"), + "Active studies show a remove button" + ); + is( + activeAddonStudy + .querySelector(".study-icon") + .textContent.toLowerCase(), + "a", + "Study icons use the first letter of the study name." + ); + + const inactiveAddonStudy = getStudyRow(doc, addonStudies[1].slug); + is( + inactiveAddonStudy.querySelector(".study-status").textContent, + "Complete", + "Inactive studies are marked as complete." + ); + ok( + !inactiveAddonStudy.querySelector(".remove-button"), + "Inactive studies do not show a remove button" + ); + + const activePrefStudy = getStudyRow(doc, prefExperiments[0].slug); + const preferenceName = Object.keys(prefExperiments[0].preferences)[0]; + ok( + activePrefStudy + .querySelector(".study-description") + .textContent.includes(preferenceName), + "Preference studies show the preference they are changing" + ); + is( + activePrefStudy.querySelector(".study-status").textContent, + "Active", + "Active studies show an 'Active' indicator." + ); + ok( + activePrefStudy.querySelector(".remove-button"), + "Active studies show a remove button" + ); + + const inactivePrefStudy = getStudyRow(doc, prefExperiments[1].slug); + is( + inactivePrefStudy.querySelector(".study-status").textContent, + "Complete", + "Inactive studies are marked as complete." + ); + ok( + !inactivePrefStudy.querySelector(".remove-button"), + "Inactive studies do not show a remove button" + ); + + activeAddonStudy.querySelector(".remove-button").click(); + await ContentTaskUtils.waitForCondition(() => + getStudyRow(doc, addonStudies[0].slug).matches(".study.disabled") + ); + ok( + getStudyRow(doc, addonStudies[0].slug).matches(".study.disabled"), + "Clicking the remove button updates the UI to show that the study has been disabled." + ); + + activePrefStudy.querySelector(".remove-button").click(); + await ContentTaskUtils.waitForCondition(() => + getStudyRow(doc, prefExperiments[0].slug).matches(".study.disabled") + ); + ok( + getStudyRow(doc, prefExperiments[0].slug).matches(".study.disabled"), + "Clicking the remove button updates the UI to show that the study has been disabled." + ); + } + ); + + const updatedAddonStudy = await AddonStudies.get(addonStudies[0].recipeId); + ok( + !updatedAddonStudy.active, + "Clicking the remove button marks addon studies as inactive in storage." + ); + + const updatedPrefStudy = await PreferenceExperiments.get( + prefExperiments[0].slug + ); + ok( + updatedPrefStudy.expired, + "Clicking the remove button marks preference studies as expired in storage." + ); + } +); + +// Test that a message is shown when no studies have been run +decorate_task( + AddonStudies.withStudies([]), + withAboutStudies(), + async function testStudyListingNoStudies({ browser }) { + await SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + await ContentTaskUtils.waitForCondition( + () => doc.querySelectorAll(".study-list-info").length + ); + const studyRows = doc.querySelectorAll(".study-list .study"); + is(studyRows.length, 0, "There should be no studies"); + is( + doc.querySelector(".study-list-info").textContent, + "You have not participated in any studies.", + "A message is shown when no studies exist" + ); + }); + } +); + +// Test that the message shown when studies are disabled and studies exist +decorate_task( + withAboutStudies(), + AddonStudies.withStudies([ + addonStudyFactory({ + userFacingName: "A Fake Add-on Study", + slug: "fake-addon-study", + active: false, + userFacingDescription: "A fake description", + studyStartDate: new Date(2018, 0, 4), + }), + ]), + PreferenceExperiments.withMockExperiments([ + preferenceStudyFactory({ + slug: "fake-pref-study", + userFacingName: "B Fake Preference Study", + lastSeen: new Date(2018, 0, 5), + expired: true, + }), + ]), + async function testStudyListingDisabled({ browser }) { + try { + RecipeRunner.disable(); + + await SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + await ContentTaskUtils.waitForCondition(() => + doc.querySelector(".info-box-content > span") + ); + + is( + doc.querySelector(".info-box-content > span").textContent, + "This is a list of studies that you have participated in. No new studies will run.", + "A message is shown when studies are disabled" + ); + }); + } finally { + // reset RecipeRunner.enabled + RecipeRunner.checkPrefs(); + } + } +); + +// Test for bug 1498940 - detects studies disabled when only study opt-out is set +decorate_task( + withPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], + ["app.normandy.api_url", "https://example.com"], + ["app.shield.optoutstudies.enabled", false], + ], + }), + withAboutStudies(), + AddonStudies.withStudies([]), + PreferenceExperiments.withMockExperiments([]), + async function testStudyListingStudiesOptOut({ browser }) { + RecipeRunner.checkPrefs(); + ok( + RecipeRunner.enabled, + "RecipeRunner should be enabled as a Precondition" + ); + + await SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + await ContentTaskUtils.waitForCondition(() => { + const span = doc.querySelector(".info-box-content > span"); + return span && span.textContent; + }); + + is( + doc.querySelector(".info-box-content > span").textContent, + "This is a list of studies that you have participated in. No new studies will run.", + "A message is shown when studies are disabled" + ); + }); + } +); + +// Test that clicking remove on a study that was disabled by an outside source +// since the page loaded correctly updates. +decorate_task( + AddonStudies.withStudies([ + addonStudyFactory({ + slug: "fake-addon-study", + userFacingName: "Fake Add-on Study", + active: true, + userFacingDescription: "A fake description", + studyStartDate: new Date(2018, 0, 4), + }), + ]), + PreferenceExperiments.withMockExperiments([ + preferenceStudyFactory({ + slug: "fake-pref-study", + userFacingName: "Fake Preference Study", + lastSeen: new Date(2018, 0, 3), + expired: false, + }), + ]), + withAboutStudies(), + async function testStudyListing({ + addonStudies: [addonStudy], + prefExperiments: [prefStudy], + browser, + }) { + // The content page has already loaded. Disabling the studies here shouldn't + // affect it, since it doesn't live-update. + await AddonStudies.markAsEnded(addonStudy, "disabled-automatically-test"); + await PreferenceExperiments.stop(prefStudy.slug, { + resetValue: false, + reason: "disabled-automatically-test", + }); + + await SpecialPowers.spawn( + browser, + [{ addonStudy, prefStudy }], + async ({ addonStudy, prefStudy }) => { + const doc = content.document; + + function getStudyRow(docElem, slug) { + return docElem.querySelector(`.study[data-study-slug="${slug}"]`); + } + + await ContentTaskUtils.waitForCondition( + () => doc.querySelectorAll(".remove-button").length == 2 + ); + let activeNames = Array.from( + doc.querySelectorAll(".active-study-list .study") + ).map(row => row.dataset.studySlug); + let inactiveNames = Array.from( + doc.querySelectorAll(".inactive-study-list .study") + ).map(row => row.dataset.studySlug); + + Assert.deepEqual( + activeNames, + [addonStudy.slug, prefStudy.slug], + "Both studies should be listed as active, even though they have been disabled outside of the page" + ); + Assert.deepEqual( + inactiveNames, + [], + "No studies should be listed as inactive" + ); + + const activeAddonStudy = getStudyRow(doc, addonStudy.slug); + const activePrefStudy = getStudyRow(doc, prefStudy.slug); + + activeAddonStudy.querySelector(".remove-button").click(); + await ContentTaskUtils.waitForCondition(() => + getStudyRow(doc, addonStudy.slug).matches(".study.disabled") + ); + ok( + getStudyRow(doc, addonStudy.slug).matches(".study.disabled"), + "Clicking the remove button updates the UI to show that the study has been disabled." + ); + + activePrefStudy.querySelector(".remove-button").click(); + await ContentTaskUtils.waitForCondition(() => + getStudyRow(doc, prefStudy.slug).matches(".study.disabled") + ); + ok( + getStudyRow(doc, prefStudy.slug).matches(".study.disabled"), + "Clicking the remove button updates the UI to show that the study has been disabled." + ); + + activeNames = Array.from( + doc.querySelectorAll(".active-study-list .study") + ).map(row => row.dataset.studySlug); + + Assert.deepEqual( + activeNames, + [], + "No studies should be listed as active" + ); + } + ); + } +); + +// Test that clicking remove on a study updates even about:studies pages +// that are not currently in focus. +decorate_task( + AddonStudies.withStudies([ + addonStudyFactory({ + slug: "fake-addon-study", + userFacingName: "Fake Add-on Study", + active: true, + userFacingDescription: "A fake description", + studyStartDate: new Date(2018, 0, 4), + }), + ]), + PreferenceExperiments.withMockExperiments([ + preferenceStudyFactory({ + slug: "fake-pref-study", + userFacingName: "Fake Preference Study", + lastSeen: new Date(2018, 0, 3), + expired: false, + }), + ]), + withAboutStudies(), + async function testOtherTabsUpdated({ + addonStudies: [addonStudy], + prefExperiments: [prefStudy], + browser, + }) { + // Ensure that both our studies are active in the current tab. + await SpecialPowers.spawn( + browser, + [{ addonStudy, prefStudy }], + async ({ addonStudy, prefStudy }) => { + const doc = content.document; + await ContentTaskUtils.waitForCondition( + () => doc.querySelectorAll(".remove-button").length == 2, + "waiting for page to load" + ); + let activeNames = Array.from( + doc.querySelectorAll(".active-study-list .study") + ).map(row => row.dataset.studySlug); + let inactiveNames = Array.from( + doc.querySelectorAll(".inactive-study-list .study") + ).map(row => row.dataset.studySlug); + + Assert.deepEqual( + activeNames, + [addonStudy.slug, prefStudy.slug], + "Both studies should be listed as active" + ); + Assert.deepEqual( + inactiveNames, + [], + "No studies should be listed as inactive" + ); + } + ); + + // Open a new about:studies tab. + await BrowserTestUtils.withNewTab("about:studies", async browser => { + // Delete both studies in this tab; this should pass if previous tests have passed. + await SpecialPowers.spawn( + browser, + [{ addonStudy, prefStudy }], + async ({ addonStudy, prefStudy }) => { + const doc = content.document; + + function getStudyRow(docElem, slug) { + return docElem.querySelector(`.study[data-study-slug="${slug}"]`); + } + + await ContentTaskUtils.waitForCondition( + () => doc.querySelectorAll(".remove-button").length == 2, + "waiting for page to load" + ); + let activeNames = Array.from( + doc.querySelectorAll(".active-study-list .study") + ).map(row => row.dataset.studySlug); + let inactiveNames = Array.from( + doc.querySelectorAll(".inactive-study-list .study") + ).map(row => row.dataset.studySlug); + + Assert.deepEqual( + activeNames, + [addonStudy.slug, prefStudy.slug], + "Both studies should be listed as active in the new tab" + ); + Assert.deepEqual( + inactiveNames, + [], + "No studies should be listed as inactive in the new tab" + ); + + const activeAddonStudy = getStudyRow(doc, addonStudy.slug); + const activePrefStudy = getStudyRow(doc, prefStudy.slug); + + activeAddonStudy.querySelector(".remove-button").click(); + await ContentTaskUtils.waitForCondition(() => + getStudyRow(doc, addonStudy.slug).matches(".study.disabled") + ); + ok( + getStudyRow(doc, addonStudy.slug).matches(".study.disabled"), + "Clicking the remove button updates the UI in the new tab" + ); + + activePrefStudy.querySelector(".remove-button").click(); + await ContentTaskUtils.waitForCondition(() => + getStudyRow(doc, prefStudy.slug).matches(".study.disabled") + ); + ok( + getStudyRow(doc, prefStudy.slug).matches(".study.disabled"), + "Clicking the remove button updates the UI in the new tab" + ); + + activeNames = Array.from( + doc.querySelectorAll(".active-study-list .study") + ).map(row => row.dataset.studySlug); + + Assert.deepEqual( + activeNames, + [], + "No studies should be listed as active" + ); + } + ); + }); + + // Ensure that the original tab has updated correctly. + await SpecialPowers.spawn( + browser, + [{ addonStudy, prefStudy }], + async ({ addonStudy, prefStudy }) => { + const doc = content.document; + await ContentTaskUtils.waitForCondition( + () => doc.querySelectorAll(".inactive-study-list .study").length == 2, + "Two studies should load into the inactive list, since they were disabled in a different tab" + ); + let activeNames = Array.from( + doc.querySelectorAll(".active-study-list .study") + ).map(row => row.dataset.studySlug); + let inactiveNames = Array.from( + doc.querySelectorAll(".inactive-study-list .study") + ).map(row => row.dataset.studySlug); + Assert.deepEqual( + activeNames, + [], + "No studies should be listed as active, since they were disabled in a different tab" + ); + Assert.deepEqual( + inactiveNames, + [addonStudy.slug, prefStudy.slug], + "Both studies should be listed as inactive, since they were disabled in a different tab" + ); + } + ); + } +); + +add_task(async function test_nimbus_about_studies_experiment() { + const recipe = ExperimentFakes.recipe("about-studies-foo"); + await ExperimentManager.enroll(recipe); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:studies" }, + async browser => { + const name = await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".nimbus .remove-button"), + "waiting for page/experiment to load" + ); + return content.document.querySelector(".study-name").innerText; + }); + // Make sure strings are properly shown + Assert.equal( + name, + recipe.userFacingName, + "Correct active experiment name" + ); + } + ); + ExperimentManager.unenroll(recipe.slug); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:studies" }, + async browser => { + const name = await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".nimbus.disabled"), + "waiting for experiment to become disabled" + ); + return content.document.querySelector(".study-name").innerText; + }); + // Make sure strings are properly shown + Assert.equal( + name, + recipe.userFacingName, + "Correct disabled experiment name" + ); + } + ); + // Cleanup for multiple test runs + ExperimentManager.store._deleteForTests(recipe.slug); + Assert.equal(ExperimentManager.store.getAll().length, 0, "Cleanup done"); +}); + +add_task(async function test_nimbus_about_studies_rollout() { + let recipe = ExperimentFakes.recipe("test_nimbus_about_studies_rollout"); + let rollout = { + ...recipe, + branches: [recipe.branches[0]], + isRollout: true, + }; + await ExperimentManager.enroll(rollout); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:studies" }, + async browser => { + const studyCount = await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("#shield-studies-learn-more"), + "waiting for page/experiment to load" + ); + return content.document.querySelectorAll(".study-name").length; + }); + // Make sure strings are properly shown + Assert.equal(studyCount, 0, "Rollout not loaded in non-debug mode"); + } + ); + Services.prefs.setBoolPref("nimbus.debug", true); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:studies" }, + async browser => { + const studyName = await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".nimbus .remove-button"), + "waiting for page/experiment to load" + ); + return content.document.querySelector(".study-header").innerText; + }); + // Make sure strings are properly shown + Assert.ok(studyName.includes("Active"), "Rollout loaded in debug mode"); + } + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:studies" }, + async browser => { + const name = await SpecialPowers.spawn(browser, [], async () => { + content.document.querySelector(".remove-button").click(); + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".nimbus.disabled"), + "waiting for experiment to become disabled" + ); + return content.document.querySelector(".study-header").innerText; + }); + // Make sure strings are properly shown + Assert.ok(name.includes("Complete"), "Rollout was removed"); + } + ); + // Cleanup for multiple test runs + ExperimentManager.store._deleteForTests(rollout.slug); + Services.prefs.clearUserPref("nimbus.debug"); +}); + +add_task(async function test_getStudiesEnabled() { + RecipeRunner.initializedPromise = PromiseUtils.defer(); + let promise = AboutPages.aboutStudies.getStudiesEnabled(); + + RecipeRunner.initializedPromise.resolve(); + let result = await promise; + + Assert.equal( + result, + Services.prefs.getBoolPref("app.shield.optoutstudies.enabled"), + "about:studies is enabled if the pref is enabled" + ); +}); + +add_task(async function test_forceEnroll() { + let sandbox = sinon.createSandbox(); + + // This simulates a succesful enrollment + let stub = sandbox.stub(RemoteSettingsExperimentLoader, "optInToExperiment"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:studies?optin_collection=collection123&optin_branch=branch123&optin_slug=slug123", + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".opt-in-box"), + "Should show the opt in message" + ); + + Assert.equal( + content.document + .querySelector(".opt-in-box") + .classList.contains("opt-in-error"), + false, + "should not have an error class since the enrollment was successful" + ); + + return true; + }); + } + ); + + // Simulates a problem force enrolling + stub.rejects(new Error("Testing error")); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:studies?optin_collection=collection123&optin_branch=branch123&optin_slug=slug123", + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".opt-in-box"), + "Should show the opt in message" + ); + + Assert.ok( + content.document + .querySelector(".opt-in-box") + .classList.contains("opt-in-error"), + "should have an error class since the enrollment rejected" + ); + + Assert.equal( + content.document.querySelector(".opt-in-box").textContent, + "Testing error", + "should render the error" + ); + + return true; + }); + } + ); + + sandbox.restore(); +}); diff --git a/toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js b/toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js new file mode 100644 index 0000000000..b6db1d1a2c --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js @@ -0,0 +1,246 @@ +"use strict"; + +const { AddonRollbackAction } = ChromeUtils.importESModule( + "resource://normandy/actions/AddonRollbackAction.sys.mjs" +); +const { AddonRolloutAction } = ChromeUtils.importESModule( + "resource://normandy/actions/AddonRolloutAction.sys.mjs" +); +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { AddonRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonRollouts.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +// Test that a simple recipe unenrolls as expected +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + async function simple_recipe_unenrollment({ + mockNormandyApi, + setExperimentInactiveStub, + sendEventSpy, + }) { + const rolloutRecipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [rolloutRecipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: rolloutRecipe.arguments.extensionApiId, + }), + }; + + const webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + const rolloutAction = new AddonRolloutAction(); + await rolloutAction.processRecipe( + rolloutRecipe, + BaseAction.suitability.FILTER_MATCH + ); + is(rolloutAction.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + const rollbackRecipe = { + id: 2, + arguments: { + rolloutSlug: "test-rollout", + }, + }; + + const rollbackAction = new AddonRollbackAction(); + ok( + await AddonRollouts.has(rolloutRecipe.arguments.slug), + "Rollout should have been added" + ); + await rollbackAction.processRecipe( + rollbackRecipe, + BaseAction.suitability.FILTER_MATCH + ); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(!addon, "add-on is uninstalled"); + + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: rolloutRecipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ROLLED_BACK, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollback should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "enrollmentId should be a UUID" + ); + + sendEventSpy.assertEvents([ + ["enroll", "addon_rollout", rollbackRecipe.arguments.rolloutSlug], + ["unenroll", "addon_rollback", rollbackRecipe.arguments.rolloutSlug], + ]); + + Assert.deepEqual( + setExperimentInactiveStub.args, + [["test-rollout"]], + "the telemetry experiment should deactivated" + ); + } +); + +// Add-on already uninstalled +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + async function addon_already_uninstalled({ mockNormandyApi, sendEventSpy }) { + const rolloutRecipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [rolloutRecipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: rolloutRecipe.arguments.extensionApiId, + }), + }; + + const webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + const rolloutAction = new AddonRolloutAction(); + await rolloutAction.processRecipe( + rolloutRecipe, + BaseAction.suitability.FILTER_MATCH + ); + is(rolloutAction.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + const rollbackRecipe = { + id: 2, + arguments: { + rolloutSlug: "test-rollout", + }, + }; + + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + await addon.uninstall(); + + const rollbackAction = new AddonRollbackAction(); + await rollbackAction.processRecipe( + rollbackRecipe, + BaseAction.suitability.FILTER_MATCH + ); + + addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(!addon, "add-on is uninstalled"); + + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: rolloutRecipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ROLLED_BACK, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollback should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "enrollment ID should be a UUID" + ); + + sendEventSpy.assertEvents([ + ["enroll", "addon_rollout", rollbackRecipe.arguments.rolloutSlug], + ["unenroll", "addon_rollback", rollbackRecipe.arguments.rolloutSlug], + ]); + } +); + +// Already rolled back, do nothing +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + async function already_rolled_back({ sendEventSpy }) { + const rollout = { + recipeId: 1, + slug: "test-rollout", + state: AddonRollouts.STATE_ROLLED_BACK, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + }; + AddonRollouts.add(rollout); + + const action = new AddonRollbackAction(); + await action.processRecipe( + { + id: 2, + arguments: { + rolloutSlug: "test-rollout", + }, + }, + BaseAction.suitability.FILTER_MATCH + ); + + Assert.deepEqual( + await AddonRollouts.getAll(), + [ + { + recipeId: 1, + slug: "test-rollout", + state: AddonRollouts.STATE_ROLLED_BACK, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + }, + ], + "Rollback should be stored in db" + ); + + sendEventSpy.assertEvents([]); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js b/toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js new file mode 100644 index 0000000000..d1f2a7246e --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js @@ -0,0 +1,539 @@ +"use strict"; + +const { AddonRolloutAction } = ChromeUtils.importESModule( + "resource://normandy/actions/AddonRolloutAction.sys.mjs" +); +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { AddonRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonRollouts.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +// Test that a simple recipe enrolls as expected +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withStub(TelemetryEnvironment, "setExperimentActive"), + withSendEventSpy(), + async function simple_recipe_enrollment({ + mockNormandyApi, + setExperimentActiveStub, + sendEventSpy, + }) { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [recipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + }), + }; + + const webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + const action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + // addon was installed + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should be installed"); + + // rollout was stored + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: recipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "enrollmentId should be a UUID" + ); + + sendEventSpy.assertEvents([ + ["enroll", "addon_rollout", recipe.arguments.slug], + ]); + ok( + setExperimentActiveStub.calledWithExactly("test-rollout", "active", { + type: "normandy-addonrollout", + }), + "a telemetry experiment should be activated" + ); + + // cleanup installed addon + await addon.uninstall(); + } +); + +// Test that a rollout can update the addon +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + async function update_rollout({ mockNormandyApi, sendEventSpy }) { + // first enrollment + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [recipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + }), + 2: extensionDetailsFactory({ + id: 2, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url, + version: "2.0", + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash, + }), + }; + + let webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + let action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + // addon was installed + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should be installed"); + is(addon.version, "1.0", "addon should be the correct version"); + + // update existing enrollment + recipe.arguments.extensionApiId = 2; + webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed"); + is(addon.version, "2.0", "addon should be the correct version"); + + // rollout in the DB has been updated + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: recipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extensionApiId: 2, + addonId: FIXTURE_ADDON_ID, + addonVersion: "2.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "enrollmentId should be a UUID" + ); + + sendEventSpy.assertEvents([ + ["enroll", "addon_rollout", "test-rollout"], + ["update", "addon_rollout", "test-rollout"], + ]); + + // Cleanup + await addon.uninstall(); + } +); + +// Re-running a recipe does nothing +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + async function rerun_recipe({ mockNormandyApi, sendEventSpy }) { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [recipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + }), + }; + + const webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + let action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + // addon was installed + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should be installed"); + is(addon.version, "1.0", "addon should be the correct version"); + + // re-run the same recipe + action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed"); + is(addon.version, "1.0", "addon should be the correct version"); + + // rollout in the DB has not been updated + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: recipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "Enrollment ID should be a UUID" + ); + + sendEventSpy.assertEvents([["enroll", "addon_rollout", "test-rollout"]]); + + // Cleanup + await addon.uninstall(); + } +); + +// Conflicting rollouts +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + async function conflicting_rollout({ mockNormandyApi, sendEventSpy }) { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [recipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + }), + }; + + const webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + let action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + // addon was installed + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should be installed"); + is(addon.version, "1.0", "addon should be the correct version"); + + // update existing enrollment + action = new AddonRolloutAction(); + await action.processRecipe( + { + ...recipe, + id: 2, + arguments: { + ...recipe.arguments, + slug: "test-conflict", + }, + }, + BaseAction.suitability.FILTER_MATCH + ); + is(action.lastError, null, "lastError should be null"); + + addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed"); + is(addon.version, "1.0", "addon should be the correct version"); + + // rollout in the DB has not been updated + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: recipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be stored in db" + ); + ok(NormandyTestUtils.isUuid(rollouts[0].enrollmentId)); + + sendEventSpy.assertEvents([ + [ + "enroll", + "addon_rollout", + "test-rollout", + { addonId: FIXTURE_ADDON_ID, enrollmentId: rollouts[0].enrollmentId }, + ], + [ + "enrollFailed", + "addon_rollout", + "test-conflict", + { enrollmentId: rollouts[0].enrollmentId, reason: "conflict" }, + ], + ]); + + // Cleanup + await addon.uninstall(); + } +); + +// Add-on ID changed +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + async function enroll_failed_addon_id_changed({ + mockNormandyApi, + sendEventSpy, + }) { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [recipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + }), + 2: extensionDetailsFactory({ + id: 2, + extension_id: "normandydriver-b@example.com", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].url, + version: "1.0", + hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash, + }), + }; + + const webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + let action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + // addon was installed + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should be installed"); + is(addon.version, "1.0", "addon should be the correct version"); + + // update existing enrollment + recipe.arguments.extensionApiId = 2; + action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed"); + is(addon.version, "1.0", "addon should be the correct version"); + + // rollout in the DB has not been updated + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: recipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "1.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "enrollment ID should be a UUID" + ); + + sendEventSpy.assertEvents([ + ["enroll", "addon_rollout", "test-rollout"], + [ + "updateFailed", + "addon_rollout", + "test-rollout", + { reason: "addon-id-changed" }, + ], + ]); + + // Cleanup + await addon.uninstall(); + } +); + +// Add-on upgrade required +decorate_task( + AddonRollouts.withTestMock(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + async function enroll_failed_upgrade_required({ + mockNormandyApi, + sendEventSpy, + }) { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + extensionApiId: 1, + }, + }; + mockNormandyApi.extensionDetails = { + [recipe.arguments.extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url, + version: "2.0", + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash, + }), + 2: extensionDetailsFactory({ + id: 2, + }), + }; + + const webExtStartupPromise = + AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); + + let action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + await webExtStartupPromise; + + // addon was installed + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should be installed"); + is(addon.version, "2.0", "addon should be the correct version"); + + // update existing enrollment + recipe.arguments.extensionApiId = 2; + action = new AddonRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed"); + is(addon.version, "2.0", "addon should be the correct version"); + + // rollout in the DB has not been updated + const rollouts = await AddonRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + recipeId: recipe.id, + slug: "test-rollout", + state: AddonRollouts.STATE_ACTIVE, + extensionApiId: 1, + addonId: FIXTURE_ADDON_ID, + addonVersion: "2.0", + xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url, + xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash, + xpiHashAlgorithm: "sha256", + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "enrollment ID should be a UUID" + ); + + sendEventSpy.assertEvents([ + ["enroll", "addon_rollout", "test-rollout"], + [ + "updateFailed", + "addon_rollout", + "test-rollout", + { reason: "upgrade-required" }, + ], + ]); + + // Cleanup + await addon.uninstall(); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js b/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js new file mode 100644 index 0000000000..5a3e959be9 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js @@ -0,0 +1,1662 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { BranchedAddonStudyAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); + +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); +const { branchedAddonStudyFactory } = NormandyTestUtils.factories; + +function branchedAddonStudyRecipeFactory(overrides = {}) { + let args = { + slug: "fake-slug", + userFacingName: "Fake name", + userFacingDescription: "fake description", + isEnrollmentPaused: false, + branches: [ + { + slug: "a", + ratio: 1, + extensionApiId: 1, + }, + ], + }; + if (Object.hasOwnProperty.call(overrides, "arguments")) { + args = Object.assign(args, overrides.arguments); + delete overrides.arguments; + } + return recipeFactory( + Object.assign( + { + action: "branched-addon-study", + arguments: args, + }, + overrides + ) + ); +} + +function recipeFromStudy(study, overrides = {}) { + let args = { + slug: study.slug, + userFacingName: study.userFacingName, + isEnrollmentPaused: false, + branches: [ + { + slug: "a", + ratio: 1, + extensionApiId: study.extensionApiId, + }, + ], + }; + + if (Object.hasOwnProperty.call(overrides, "arguments")) { + args = Object.assign(args, overrides.arguments); + delete overrides.arguments; + } + + return branchedAddonStudyRecipeFactory( + Object.assign( + { + id: study.recipeId, + arguments: args, + }, + overrides + ) + ); +} + +// Test that enroll is not called if recipe is already enrolled and update does nothing +// if recipe is unchanged +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([branchedAddonStudyFactory()]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: FIXTURE_ADDON_ID, version: "1.0" }), + async function enrollTwiceFail({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const recipe = recipeFromStudy(study); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + hash: study.extensionHash, + }), + }; + const action = new BranchedAddonStudyAction(); + const enrollSpy = sinon.spy(action, "enroll"); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(!enrollSpy.called, "enroll should not be called"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([]); + } +); + +// Test that if the add-on fails to install, the database is cleaned up and the +// error is correctly reported. +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + AddonStudies.withStudies(), + async function enrollDownloadFail({ mockNormandyApi, sendEventSpy }) { + const recipe = branchedAddonStudyRecipeFactory({ + arguments: { + branches: [{ slug: "missing", ratio: 1, extensionApiId: 404 }], + }, + }); + mockNormandyApi.extensionDetails = { + [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.branches[0].extensionApiId, + xpi: "https://example.com/404.xpi", + }), + }; + const action = new BranchedAddonStudyAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + const studies = await AddonStudies.getAll(); + Assert.deepEqual(studies, [], "the study should not be in the database"); + + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "addon_study", + recipe.arguments.name, + { + reason: "download-failure", + detail: "ERROR_NETWORK_FAILURE", + branch: "missing", + }, + ], + ]); + } +); + +// Ensure that the database is clean and error correctly reported if hash check fails +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + AddonStudies.withStudies(), + async function enrollHashCheckFails({ mockNormandyApi, sendEventSpy }) { + const recipe = branchedAddonStudyRecipeFactory(); + mockNormandyApi.extensionDetails = { + [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.branches[0].extensionApiId, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + hash: "badhash", + }), + }; + const action = new BranchedAddonStudyAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + const studies = await AddonStudies.getAll(); + Assert.deepEqual(studies, [], "the study should not be in the database"); + + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "addon_study", + recipe.arguments.name, + { + reason: "download-failure", + detail: "ERROR_INCORRECT_HASH", + branch: "a", + }, + ], + ]); + } +); + +// Ensure that the database is clean and error correctly reported if there is a metadata mismatch +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + AddonStudies.withStudies(), + async function enrollFailsMetadataMismatch({ + mockNormandyApi, + sendEventSpy, + }) { + const recipe = branchedAddonStudyRecipeFactory(); + mockNormandyApi.extensionDetails = { + [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.branches[0].extensionApiId, + version: "1.5", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + }), + }; + const action = new BranchedAddonStudyAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + const studies = await AddonStudies.getAll(); + Assert.deepEqual(studies, [], "the study should not be in the database"); + + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "addon_study", + recipe.arguments.name, + { + reason: "metadata-mismatch", + branch: "a", + }, + ], + ]); + } +); + +// Test that in the case of a study add-on conflicting with a non-study add-on, the study does not enroll +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + withInstalledWebExtensionSafe({ version: "0.1", id: FIXTURE_ADDON_ID }), + AddonStudies.withStudies(), + async function conflictingEnrollment({ + mockNormandyApi, + sendEventSpy, + installedWebExtensionSafe: { addonId }, + }) { + is( + addonId, + FIXTURE_ADDON_ID, + "Generated, installed add-on should have the same ID as the fixture" + ); + const recipe = branchedAddonStudyRecipeFactory({ + arguments: { slug: "conflicting" }, + }); + mockNormandyApi.extensionDetails = { + [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + }), + }; + const action = new BranchedAddonStudyAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + is(addon.version, "0.1", "The installed add-on should not be replaced"); + + Assert.deepEqual( + await AddonStudies.getAll(), + [], + "There should be no enrolled studies" + ); + + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "addon_study", + recipe.arguments.slug, + { reason: "conflicting-addon-id" }, + ], + ]); + } +); + +// Test a successful update +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + addonId: FIXTURE_ADDON_ID, + extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + extensionHashAlgorithm: "sha256", + addonVersion: "1.0", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: FIXTURE_ADDON_ID, version: "1.0" }), + async function successfulUpdate({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url; + const recipe = recipeFromStudy(study, { + arguments: { + branches: [ + { slug: "a", extensionApiId: study.extensionApiId, ratio: 1 }, + ], + }, + }); + const hash = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash; + mockNormandyApi.extensionDetails = { + [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.branches[0].extensionApiId, + extension_id: FIXTURE_ADDON_ID, + xpi: addonUrl, + hash, + version: "2.0", + }), + }; + const action = new BranchedAddonStudyAction(); + const enrollSpy = sinon.spy(action, "enroll"); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(!enrollSpy.called, "enroll should not be called"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "update", + "addon_study", + recipe.arguments.name, + { + addonId: FIXTURE_ADDON_ID, + addonVersion: "2.0", + branch: "a", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const updatedStudy = await AddonStudies.get(recipe.id); + Assert.deepEqual( + updatedStudy, + { + ...study, + addonVersion: "2.0", + addonUrl, + extensionApiId: recipe.arguments.branches[0].extensionApiId, + extensionHash: hash, + }, + "study data should be updated" + ); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(addon.version === "2.0", "add-on should be updated"); + } +); + +// Test update fails when addon ID does not match +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + addonId: "test@example.com", + extensionHash: "01d", + extensionHashAlgorithm: "sha256", + addonVersion: "0.1", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: FIXTURE_ADDON_ID, version: "0.1" }), + async function updateFailsAddonIdMismatch({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const recipe = recipeFromStudy(study); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: FIXTURE_ADDON_ID, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + }), + }; + const action = new BranchedAddonStudyAction(); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "updateFailed", + "addon_study", + recipe.arguments.name, + { + reason: "addon-id-mismatch", + branch: "a", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const updatedStudy = await AddonStudies.get(recipe.id); + Assert.deepEqual(updatedStudy, study, "study data should be unchanged"); + + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(addon.version === "0.1", "add-on should be unchanged"); + } +); + +// Test update fails when original addon does not exist +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + extensionHash: "01d", + extensionHashAlgorithm: "sha256", + addonVersion: "0.1", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: "test@example.com", version: "0.1" }), + async function updateFailsAddonDoesNotExist({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const recipe = recipeFromStudy(study); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + }), + }; + const action = new BranchedAddonStudyAction(); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "updateFailed", + "addon_study", + recipe.arguments.name, + { + reason: "addon-does-not-exist", + branch: "a", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const updatedStudy = await AddonStudies.get(recipe.id); + Assert.deepEqual(updatedStudy, study, "study data should be unchanged"); + + let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(!addon, "new add-on should not be installed"); + + addon = await AddonManager.getAddonByID("test@example.com"); + ok(addon, "old add-on should still be installed"); + } +); + +// Test update fails when download fails +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + addonId: FIXTURE_ADDON_ID, + extensionHash: "01d", + extensionHashAlgorithm: "sha256", + addonVersion: "0.1", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: FIXTURE_ADDON_ID, version: "0.1" }), + async function updateDownloadFailure({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const recipe = recipeFromStudy(study); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + xpi: "https://example.com/404.xpi", + }), + }; + const action = new BranchedAddonStudyAction(); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "updateFailed", + "addon_study", + recipe.arguments.name, + { + branch: "a", + reason: "download-failure", + detail: "ERROR_NETWORK_FAILURE", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const updatedStudy = await AddonStudies.get(recipe.id); + Assert.deepEqual(updatedStudy, study, "study data should be unchanged"); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(addon.version === "0.1", "add-on should be unchanged"); + } +); + +// Test update fails when hash check fails +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + addonId: FIXTURE_ADDON_ID, + extensionHash: "01d", + extensionHashAlgorithm: "sha256", + addonVersion: "0.1", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: FIXTURE_ADDON_ID, version: "0.1" }), + async function updateFailsHashCheckFail({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const recipe = recipeFromStudy(study); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + hash: "badhash", + }), + }; + const action = new BranchedAddonStudyAction(); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "updateFailed", + "addon_study", + recipe.arguments.name, + { + branch: "a", + reason: "download-failure", + detail: "ERROR_INCORRECT_HASH", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const updatedStudy = await AddonStudies.get(recipe.id); + Assert.deepEqual(updatedStudy, study, "study data should be unchanged"); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(addon.version === "0.1", "add-on should be unchanged"); + } +); + +// Test update fails on downgrade when study version is greater than extension version +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + addonId: FIXTURE_ADDON_ID, + extensionHash: "01d", + extensionHashAlgorithm: "sha256", + addonVersion: "2.0", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: FIXTURE_ADDON_ID, version: "2.0" }), + async function upgradeFailsNoDowngrades({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const recipe = recipeFromStudy(study); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + version: "1.0", + }), + }; + const action = new BranchedAddonStudyAction(); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "updateFailed", + "addon_study", + recipe.arguments.name, + { + reason: "no-downgrade", + branch: "a", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const updatedStudy = await AddonStudies.get(recipe.id); + Assert.deepEqual(updatedStudy, study, "study data should be unchanged"); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(addon.version === "2.0", "add-on should be unchanged"); + } +); + +// Test update fails when there is a version mismatch with metadata +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + addonId: FIXTURE_ADDON_ID, + extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + extensionHashAlgorithm: "sha256", + addonVersion: "1.0", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionFromURL( + FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url + ), + async function upgradeFailsMetadataMismatchVersion({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const recipe = recipeFromStudy(study); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url, + version: "3.0", + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash, + }), + }; + const action = new BranchedAddonStudyAction(); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "updateFailed", + "addon_study", + recipe.arguments.name, + { + branch: "a", + reason: "metadata-mismatch", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const updatedStudy = await AddonStudies.get(recipe.id); + Assert.deepEqual(updatedStudy, study, "study data should be unchanged"); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(addon.version === "1.0", "add-on should be unchanged"); + + let addonSourceURI = addon.getResourceURI(); + if (addonSourceURI instanceof Ci.nsIJARURI) { + addonSourceURI = addonSourceURI.JARFile; + } + const xpiFile = addonSourceURI.QueryInterface(Ci.nsIFileURL).file; + const installedHash = CryptoUtils.getFileHash( + xpiFile, + study.extensionHashAlgorithm + ); + ok(installedHash === study.extensionHash, "add-on should be unchanged"); + } +); + +// Test that unenrolling fails if the study doesn't exist +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + AddonStudies.withStudies(), + async function unenrollNonexistent() { + const action = new BranchedAddonStudyAction(); + await Assert.rejects( + action.unenroll(42), + /no study found/i, + "unenroll should fail when no study exists" + ); + } +); + +// Test that unenrolling an inactive study fails +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + AddonStudies.withStudies([branchedAddonStudyFactory({ active: false })]), + withSendEventSpy(), + async ({ addonStudies: [study], sendEventSpy }) => { + const action = new BranchedAddonStudyAction(); + await Assert.rejects( + action.unenroll(study.recipeId), + /cannot stop study.*already inactive/i, + "unenroll should fail when the requested study is inactive" + ); + } +); + +// test a successful unenrollment +const testStopId = "testStop@example.com"; +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + active: true, + addonId: testStopId, + studyEndDate: null, + }), + ]), + withInstalledWebExtension({ id: testStopId }, { expectUninstall: true }), + withSendEventSpy(), + withStub(TelemetryEnvironment, "setExperimentInactive"), + async function unenrollTest({ + addonStudies: [study], + installedWebExtension: { addonId }, + sendEventSpy, + setExperimentInactiveStub, + }) { + let addon = await AddonManager.getAddonByID(addonId); + ok(addon, "the add-on should be installed before unenrolling"); + + const action = new BranchedAddonStudyAction(); + await action.unenroll(study.recipeId, "test-reason"); + + const newStudy = AddonStudies.get(study.recipeId); + is(!newStudy, false, "stop should mark the study as inactive"); + ok(newStudy.studyEndDate !== null, "the study should have an end date"); + + addon = await AddonManager.getAddonByID(addonId); + is(addon, null, "the add-on should be uninstalled after unenrolling"); + + sendEventSpy.assertEvents([ + [ + "unenroll", + "addon_study", + study.name, + { + addonId, + addonVersion: study.addonVersion, + reason: "test-reason", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + Assert.deepEqual( + setExperimentInactiveStub.args, + [[study.slug]], + "setExperimentInactive should be called" + ); + } +); + +// If the add-on for a study isn't installed, a warning should be logged, but the action is still successful +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + active: true, + addonId: "missingAddon@example.com", + }), + ]), + withSendEventSpy(), + async function unenrollMissingAddonTest({ + addonStudies: [study], + sendEventSpy, + }) { + const action = new BranchedAddonStudyAction(); + + await action.unenroll(study.recipeId); + + sendEventSpy.assertEvents([ + [ + "unenroll", + "addon_study", + study.name, + { + addonId: study.addonId, + addonVersion: study.addonVersion, + reason: "unknown", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + SimpleTest.endMonitorConsole(); + } +); + +// Test that the action respects the study opt-out +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + withMockPreferences(), + AddonStudies.withStudies(), + async function testOptOut({ + mockNormandyApi, + sendEventSpy, + mockPreferences, + }) { + mockPreferences.set("app.shield.optoutstudies.enabled", false); + const action = new BranchedAddonStudyAction(); + const enrollSpy = sinon.spy(action, "enroll"); + const recipe = branchedAddonStudyRecipeFactory(); + mockNormandyApi.extensionDetails = { + [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({ + id: recipe.arguments.branches[0].extensionApiId, + }), + }; + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is( + action.state, + BranchedAddonStudyAction.STATE_DISABLED, + "the action should be disabled" + ); + await action.finalize(); + is( + action.state, + BranchedAddonStudyAction.STATE_FINALIZED, + "the action should be finalized" + ); + is(action.lastError, null, "lastError should be null"); + Assert.deepEqual(enrollSpy.args, [], "enroll should not be called"); + sendEventSpy.assertEvents([]); + } +); + +// Test that the action does not enroll paused recipes +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + AddonStudies.withStudies(), + async function testEnrollmentPaused({ mockNormandyApi, sendEventSpy }) { + const action = new BranchedAddonStudyAction(); + const enrollSpy = sinon.spy(action, "enroll"); + const updateSpy = sinon.spy(action, "update"); + const recipe = branchedAddonStudyRecipeFactory({ + arguments: { isEnrollmentPaused: true }, + }); + const extensionDetails = extensionDetailsFactory({ + id: recipe.arguments.extensionApiId, + }); + mockNormandyApi.extensionDetails = { + [recipe.arguments.extensionApiId]: extensionDetails, + }; + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + const addon = await AddonManager.getAddonByID( + extensionDetails.extension_id + ); + is(addon, null, "the add-on should not have been installed"); + await action.finalize(); + ok(!updateSpy.called, "update should not be called"); + ok(enrollSpy.called, "enroll should be called"); + sendEventSpy.assertEvents([]); + } +); + +// Test that the action updates paused recipes +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + addonId: FIXTURE_ADDON_ID, + extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + extensionHashAlgorithm: "sha256", + addonVersion: "1.0", + }), + ]), + withSendEventSpy(), + withInstalledWebExtensionSafe({ id: FIXTURE_ADDON_ID, version: "1.0" }), + async function testUpdateEnrollmentPaused({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + }) { + const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url; + const recipe = recipeFromStudy(study, { + arguments: { isEnrollmentPaused: true }, + }); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + xpi: addonUrl, + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash, + version: "2.0", + }), + }; + const action = new BranchedAddonStudyAction(); + const enrollSpy = sinon.spy(action, "enroll"); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + ok(!enrollSpy.called, "enroll should not be called"); + ok(updateSpy.called, "update should be called"); + sendEventSpy.assertEvents([ + [ + "update", + "addon_study", + recipe.arguments.name, + { + addonId: FIXTURE_ADDON_ID, + addonVersion: "2.0", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); + ok(addon.version === "2.0", "add-on should be updated"); + } +); + +// Test that unenroll called if the study is no longer sent from the server +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + AddonStudies.withStudies([branchedAddonStudyFactory()]), + async function unenroll({ addonStudies: [study] }) { + const action = new BranchedAddonStudyAction(); + const unenrollSpy = sinon.stub(action, "unenroll"); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + Assert.deepEqual( + unenrollSpy.args, + [[study.recipeId, "recipe-not-seen"]], + "unenroll should be called" + ); + } +); + +// A test function that will be parameterized over the argument "branch" below. +// Mocks the branch selector, and then tests that the user correctly gets enrolled in that branch. +const successEnrollBranchedTest = decorate( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + withStub(TelemetryEnvironment, "setExperimentActive"), + AddonStudies.withStudies(), + async function ({ + branch, + mockNormandyApi, + sendEventSpy, + setExperimentActiveStub, + }) { + ok(branch == "a" || branch == "b", "Branch should be either a or b"); + const initialAddonIds = (await AddonManager.getAllAddons()).map( + addon => addon.id + ); + const addonId = `normandydriver-${branch}@example.com`; + const otherBranchAddonId = `normandydriver-${branch == "a" ? "b" : "a"}`; + is( + await AddonManager.getAddonByID(addonId), + null, + "The add-on should not be installed at the beginning of the test" + ); + is( + await AddonManager.getAddonByID(otherBranchAddonId), + null, + "The other branch's add-on should not be installed at the beginning of the test" + ); + + const recipe = branchedAddonStudyRecipeFactory({ + arguments: { + slug: "success", + branches: [ + { slug: "a", ratio: 1, extensionApiId: 1 }, + { slug: "b", ratio: 1, extensionApiId: 2 }, + ], + }, + }); + mockNormandyApi.extensionDetails = { + [recipe.arguments.branches[0].extensionApiId]: { + id: recipe.arguments.branches[0].extensionApiId, + name: "Normandy Fixture A", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + extension_id: "normandydriver-a@example.com", + version: "1.0", + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + hash_algorithm: "sha256", + }, + [recipe.arguments.branches[1].extensionApiId]: { + id: recipe.arguments.branches[1].extensionApiId, + name: "Normandy Fixture B", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].url, + extension_id: "normandydriver-b@example.com", + version: "1.0", + hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash, + hash_algorithm: "sha256", + }, + }; + const extensionApiId = + recipe.arguments.branches[branch == "a" ? 0 : 1].extensionApiId; + const extensionDetails = mockNormandyApi.extensionDetails[extensionApiId]; + + const action = new BranchedAddonStudyAction(); + const chooseBranchStub = sinon.stub(action, "chooseBranch"); + chooseBranchStub.callsFake(async ({ branches }) => + branches.find(b => b.slug === branch) + ); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + const study = await AddonStudies.get(recipe.id); + sendEventSpy.assertEvents([ + [ + "enroll", + "addon_study", + recipe.arguments.slug, + { + addonId, + addonVersion: "1.0", + branch, + enrollmentId: study.enrollmentId, + }, + ], + ]); + + Assert.deepEqual( + setExperimentActiveStub.args, + [ + [ + recipe.arguments.slug, + branch, + { type: "normandy-addonstudy", enrollmentId: study.enrollmentId }, + ], + ], + "setExperimentActive should be called" + ); + + const addon = await AddonManager.getAddonByID(addonId); + ok(addon, "The chosen branch's add-on should be installed"); + is( + await AddonManager.getAddonByID(otherBranchAddonId), + null, + "The other branch's add-on should not be installed" + ); + + Assert.deepEqual( + study, + { + recipeId: recipe.id, + slug: recipe.arguments.slug, + userFacingName: recipe.arguments.userFacingName, + userFacingDescription: recipe.arguments.userFacingDescription, + addonId, + addonVersion: "1.0", + addonUrl: FIXTURE_ADDON_DETAILS[`normandydriver-${branch}-1.0`].url, + active: true, + branch, + studyStartDate: study.studyStartDate, // This is checked below + studyEndDate: null, + extensionApiId: extensionDetails.id, + extensionHash: extensionDetails.hash, + extensionHashAlgorithm: extensionDetails.hash_algorithm, + enrollmentId: study.enrollmentId, + temporaryErrorDeadline: null, + }, + "the correct study data should be stored" + ); + + // cleanup + await safeUninstallAddon(addon); + Assert.deepEqual( + (await AddonManager.getAllAddons()).map(addon => addon.id), + initialAddonIds, + "all test add-ons are removed" + ); + } +); + +add_task(args => successEnrollBranchedTest({ ...args, branch: "a" })); +add_task(args => successEnrollBranchedTest({ ...args, branch: "b" })); + +// If the enrolled branch no longer exists, unenroll +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + AddonStudies.withStudies([branchedAddonStudyFactory()]), + withSendEventSpy(), + withInstalledWebExtensionSafe( + { id: FIXTURE_ADDON_ID, version: "1.0" }, + { expectUninstall: true } + ), + async function unenrollIfBranchDisappears({ + mockNormandyApi, + addonStudies: [study], + sendEventSpy, + installedWebExtensionSafe: { addonId }, + }) { + const recipe = recipeFromStudy(study, { + arguments: { + branches: [ + { + slug: "b", // different from enrolled study + ratio: 1, + extensionApiId: study.extensionApiId, + }, + ], + }, + }); + mockNormandyApi.extensionDetails = { + [study.extensionApiId]: extensionDetailsFactory({ + id: study.extensionApiId, + extension_id: study.addonId, + hash: study.extensionHash, + }), + }; + const action = new BranchedAddonStudyAction(); + const enrollSpy = sinon.spy(action, "enroll"); + const unenrollSpy = sinon.spy(action, "unenroll"); + const updateSpy = sinon.spy(action, "update"); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + + ok(!enrollSpy.called, "Enroll should not be called"); + ok(updateSpy.called, "Update should be called"); + ok(unenrollSpy.called, "Unenroll should be called"); + + sendEventSpy.assertEvents([ + [ + "unenroll", + "addon_study", + study.name, + { + addonId, + addonVersion: study.addonVersion, + reason: "branch-removed", + branch: "a", // the original study branch + enrollmentId: study.enrollmentId, + }, + ], + ]); + + is( + await AddonManager.getAddonByID(addonId), + null, + "the add-on should be uninstalled" + ); + + const storedStudy = await AddonStudies.get(recipe.id); + ok(!storedStudy.active, "Study should be inactive"); + ok(storedStudy.branch == "a", "Study's branch should not change"); + ok(storedStudy.studyEndDate, "Study's end date should be set"); + } +); + +// Test that branches without an add-on can be enrolled and unenrolled succesfully. +decorate_task( + withStudiesEnabled(), + ensureAddonCleanup(), + withMockNormandyApi(), + withSendEventSpy(), + AddonStudies.withStudies(), + async function noAddonBranches({ sendEventSpy }) { + const initialAddonIds = (await AddonManager.getAllAddons()).map( + addon => addon.id + ); + + const recipe = branchedAddonStudyRecipeFactory({ + arguments: { + slug: "no-op-branch", + branches: [{ slug: "a", ratio: 1, extensionApiId: null }], + }, + }); + + let action = new BranchedAddonStudyAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + let study = await AddonStudies.get(recipe.id); + sendEventSpy.assertEvents([ + [ + "enroll", + "addon_study", + recipe.arguments.name, + { + addonId: AddonStudies.NO_ADDON_MARKER, + addonVersion: AddonStudies.NO_ADDON_MARKER, + branch: "a", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + Assert.deepEqual( + (await AddonManager.getAllAddons()).map(addon => addon.id), + initialAddonIds, + "No add-on should be installed for the study" + ); + + Assert.deepEqual( + study, + { + recipeId: recipe.id, + slug: recipe.arguments.slug, + userFacingName: recipe.arguments.userFacingName, + userFacingDescription: recipe.arguments.userFacingDescription, + addonId: null, + addonVersion: null, + addonUrl: null, + active: true, + branch: "a", + studyStartDate: study.studyStartDate, // This is checked below + studyEndDate: null, + extensionApiId: null, + extensionHash: null, + extensionHashAlgorithm: null, + enrollmentId: study.enrollmentId, + temporaryErrorDeadline: null, + }, + "the correct study data should be stored" + ); + ok(study.studyStartDate, "studyStartDate should have a value"); + NormandyTestUtils.isUuid(study.enrollmentId); + + // Now unenroll + action = new BranchedAddonStudyAction(); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + sendEventSpy.assertEvents([ + // The event from before + [ + "enroll", + "addon_study", + recipe.arguments.name, + { + addonId: AddonStudies.NO_ADDON_MARKER, + addonVersion: AddonStudies.NO_ADDON_MARKER, + branch: "a", + enrollmentId: study.enrollmentId, + }, + ], + // And a new unenroll event + [ + "unenroll", + "addon_study", + recipe.arguments.name, + { + addonId: AddonStudies.NO_ADDON_MARKER, + addonVersion: AddonStudies.NO_ADDON_MARKER, + branch: "a", + enrollmentId: study.enrollmentId, + }, + ], + ]); + + Assert.deepEqual( + (await AddonManager.getAllAddons()).map(addon => addon.id), + initialAddonIds, + "The set of add-ons should not change" + ); + + study = await AddonStudies.get(recipe.id); + Assert.deepEqual( + study, + { + recipeId: recipe.id, + slug: recipe.arguments.slug, + userFacingName: recipe.arguments.userFacingName, + userFacingDescription: recipe.arguments.userFacingDescription, + addonId: null, + addonVersion: null, + addonUrl: null, + active: false, + branch: "a", + studyStartDate: study.studyStartDate, // This is checked below + studyEndDate: study.studyEndDate, // This is checked below + extensionApiId: null, + extensionHash: null, + extensionHashAlgorithm: null, + enrollmentId: study.enrollmentId, + temporaryErrorDeadline: null, + }, + "the correct study data should be stored" + ); + ok(study.studyStartDate, "studyStartDate should have a value"); + ok(study.studyEndDate, "studyEndDate should have a value"); + NormandyTestUtils.isUuid(study.enrollmentId); + } +); + +// Check that the appropriate set of suitabilities are considered temporary errors +decorate_task( + withStudiesEnabled(), + async function test_temporary_errors_set_deadline() { + let suitabilities = [ + { + suitability: BaseAction.suitability.SIGNATURE_ERROR, + isTemporaryError: true, + }, + { + suitability: BaseAction.suitability.CAPABILITIES_MISMATCH, + isTemporaryError: false, + }, + { + suitability: BaseAction.suitability.FILTER_MATCH, + isTemporaryError: false, + }, + { + suitability: BaseAction.suitability.FILTER_MISMATCH, + isTemporaryError: false, + }, + { + suitability: BaseAction.suitability.FILTER_ERROR, + isTemporaryError: true, + }, + { + suitability: BaseAction.suitability.ARGUMENTS_INVALID, + isTemporaryError: false, + }, + ]; + + Assert.deepEqual( + suitabilities.map(({ suitability }) => suitability).sort(), + Array.from(Object.values(BaseAction.suitability)).sort(), + "This test covers all suitabilities" + ); + + // The action should set a deadline 1 week from now. To avoid intermittent + // failures, give this a generous bound of 2 hours on either side. + let now = Date.now(); + let hour = 60 * 60 * 1000; + let expectedDeadline = now + 7 * 24 * hour; + let minDeadline = new Date(expectedDeadline - 2 * hour); + let maxDeadline = new Date(expectedDeadline + 2 * hour); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const { suitability, isTemporaryError } of suitabilities) { + const decorator = AddonStudies.withStudies([ + branchedAddonStudyFactory({ + slug: `test-for-suitability-${suitability}`, + }), + ]); + await decorator(async ({ addonStudies: [study] }) => { + let action = new BranchedAddonStudyAction(); + let recipe = recipeFromStudy(study); + await action.processRecipe(recipe, suitability); + let modifiedStudy = await AddonStudies.get(recipe.id); + + if (isTemporaryError) { + ok( + // The constructor of this object is a Date, but is not the same as + // the Date that we have in our global scope, because it got sent + // through IndexedDB. Check the name of the constructor instead. + modifiedStudy.temporaryErrorDeadline.constructor.name == "Date", + `A temporary failure deadline should be set as a date for suitability ${suitability}` + ); + let deadline = modifiedStudy.temporaryErrorDeadline; + ok( + deadline >= minDeadline && deadline <= maxDeadline, + `The temporary failure deadline should be in the expected range for ` + + `suitability ${suitability} (got ${deadline}, expected between ${minDeadline} and ${maxDeadline})` + ); + } else { + ok( + !modifiedStudy.temporaryErrorDeadline, + `No temporary failure deadline should be set for suitability ${suitability}` + ); + } + })(); + } + } +); + +// Check that if there is an existing deadline, temporary errors don't overwrite it +decorate_task( + withStudiesEnabled(), + async function test_temporary_errors_dont_overwrite_deadline() { + let temporaryFailureSuitabilities = [ + BaseAction.suitability.SIGNATURE_ERROR, + BaseAction.suitability.FILTER_ERROR, + ]; + + // A deadline two hours in the future won't be hit during the test. + let now = Date.now(); + let hour = 2 * 60 * 60 * 1000; + let unhitDeadline = new Date(now + hour); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of temporaryFailureSuitabilities) { + const decorator = AddonStudies.withStudies([ + branchedAddonStudyFactory({ + slug: `test-for-suitability-${suitability}`, + active: true, + temporaryErrorDeadline: unhitDeadline, + }), + ]); + await decorator(async ({ addonStudies: [study] }) => { + let action = new BranchedAddonStudyAction(); + let recipe = recipeFromStudy(study); + await action.processRecipe(recipe, suitability); + let modifiedStudy = await AddonStudies.get(recipe.id); + is( + modifiedStudy.temporaryErrorDeadline.toJSON(), + unhitDeadline.toJSON(), + `The temporary failure deadline should not be cleared for suitability ${suitability}` + ); + })(); + } + } +); + +// Check that if the deadline is past, temporary errors end the study. +decorate_task( + withStudiesEnabled(), + async function test_temporary_errors_hit_deadline() { + let temporaryFailureSuitabilities = [ + BaseAction.suitability.SIGNATURE_ERROR, + BaseAction.suitability.FILTER_ERROR, + ]; + + // Set a deadline of two hours in the past, so that the deadline is triggered. + let now = Date.now(); + let hour = 60 * 60 * 1000; + let hitDeadline = new Date(now - 2 * hour); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of temporaryFailureSuitabilities) { + const decorator = AddonStudies.withStudies([ + branchedAddonStudyFactory({ + slug: `test-for-suitability-${suitability}`, + active: true, + temporaryErrorDeadline: hitDeadline, + }), + ]); + await decorator(async ({ addonStudies: [study] }) => { + let action = new BranchedAddonStudyAction(); + let recipe = recipeFromStudy(study); + await action.processRecipe(recipe, suitability); + let modifiedStudy = await AddonStudies.get(recipe.id); + ok( + !modifiedStudy.active, + `The study should end for suitability ${suitability}` + ); + })(); + } + } +); + +// Check that non-temporary-error suitabilities clear the temporary deadline +decorate_task( + withStudiesEnabled(), + async function test_non_temporary_error_clears_temporary_error_deadline() { + let suitabilitiesThatShouldClearDeadline = [ + BaseAction.suitability.CAPABILITIES_MISMATCH, + BaseAction.suitability.FILTER_MATCH, + BaseAction.suitability.FILTER_MISMATCH, + BaseAction.suitability.ARGUMENTS_INVALID, + ]; + + // Use a deadline in the past to demonstrate that even if the deadline has + // passed, only a temporary error suitability ends the study. + let now = Date.now(); + let hour = 60 * 60 * 1000; + let hitDeadline = new Date(now - 2 * hour); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of suitabilitiesThatShouldClearDeadline) { + const decorator = AddonStudies.withStudies([ + branchedAddonStudyFactory({ + slug: `test-for-suitability-${suitability}`.toLocaleLowerCase(), + active: true, + temporaryErrorDeadline: hitDeadline, + }), + ]); + await decorator(async ({ addonStudies: [study] }) => { + let action = new BranchedAddonStudyAction(); + let recipe = recipeFromStudy(study); + await action.processRecipe(recipe, suitability); + let modifiedStudy = await AddonStudies.get(recipe.id); + ok( + !modifiedStudy.temporaryErrorDeadline, + `The temporary failure deadline should be cleared for suitabilitiy ${suitability}` + ); + })(); + } + } +); + +// Check that invalid deadlines are reset +decorate_task( + withStudiesEnabled(), + async function test_non_temporary_error_clears_temporary_error_deadline() { + let temporaryFailureSuitabilities = [ + BaseAction.suitability.SIGNATURE_ERROR, + BaseAction.suitability.FILTER_ERROR, + ]; + + // The action should set a deadline 1 week from now. To avoid intermittent + // failures, give this a generous bound of 2 hours on either side. + let invalidDeadline = new Date("not a valid date"); + let now = Date.now(); + let hour = 60 * 60 * 1000; + let expectedDeadline = now + 7 * 24 * hour; + let minDeadline = new Date(expectedDeadline - 2 * hour); + let maxDeadline = new Date(expectedDeadline + 2 * hour); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of temporaryFailureSuitabilities) { + const decorator = AddonStudies.withStudies([ + branchedAddonStudyFactory({ + slug: `test-for-suitability-${suitability}`.toLocaleLowerCase(), + active: true, + temporaryErrorDeadline: invalidDeadline, + }), + ]); + await decorator(async ({ addonStudies: [study] }) => { + let action = new BranchedAddonStudyAction(); + let recipe = recipeFromStudy(study); + await action.processRecipe(recipe, suitability); + is(action.lastError, null, "No errors should be reported"); + let modifiedStudy = await AddonStudies.get(recipe.id); + ok( + modifiedStudy.temporaryErrorDeadline != invalidDeadline, + `The temporary failure deadline should be reset for suitabilitiy ${suitability}` + ); + let deadline = new Date(modifiedStudy.temporaryErrorDeadline); + ok( + deadline >= minDeadline && deadline <= maxDeadline, + `The temporary failure deadline should be reset to a valid deadline for ${suitability}` + ); + })(); + } + } +); + +// Check that an already unenrolled study doesn't try to unenroll again if +// the recipe doesn't apply the client anymore. +decorate_task( + withStudiesEnabled(), + async function test_unenroll_when_already_expired() { + // Use a deadline that is already past + const now = new Date(); + const hour = 1000 * 60 * 60; + const temporaryErrorDeadline = new Date(now - hour * 2).toJSON(); + + const suitabilitiesToCheck = Object.values(BaseAction.suitability); + + const subtest = decorate( + AddonStudies.withStudies([ + branchedAddonStudyFactory({ + active: false, + temporaryErrorDeadline, + }), + ]), + + async ({ addonStudies: [study], suitability }) => { + const recipe = recipeFromStudy(study); + const action = new BranchedAddonStudyAction(); + const unenrollSpy = sinon.spy(action.unenroll); + await action.processRecipe(recipe, suitability); + Assert.deepEqual( + unenrollSpy.args, + [], + `Stop should not be called for ${suitability}` + ); + } + ); + + for (const suitability of suitabilitiesToCheck) { + await subtest({ suitability }); + } + } +); + +// If no recipes are received, it should be considered a temporary error +decorate_task( + withStudiesEnabled(), + AddonStudies.withStudies([branchedAddonStudyFactory({ active: true })]), + withSpy(BranchedAddonStudyAction.prototype, "unenroll"), + withStub(BranchedAddonStudyAction.prototype, "_considerTemporaryError"), + async function testNoRecipes({ + unenrollSpy, + _considerTemporaryErrorStub, + addonStudies: [study], + }) { + let action = new BranchedAddonStudyAction(); + await action.finalize({ noRecipes: true }); + + Assert.deepEqual(unenrollSpy.args, [], "Unenroll should not be called"); + Assert.deepEqual( + _considerTemporaryErrorStub.args, + [[{ study, reason: "no-recipes" }]], + "The experiment should accumulate a temporary error" + ); + } +); + +// If recipes are received, but the flag that none were received is set, an error should be thrown +decorate_task( + withStudiesEnabled(), + AddonStudies.withStudies([branchedAddonStudyFactory({ active: true })]), + async function testNoRecipes({ addonStudies: [study] }) { + let action = new BranchedAddonStudyAction(); + let recipe = recipeFromStudy(study); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MISMATCH); + await action.finalize({ noRecipes: true }); + ok( + action.lastError instanceof BranchedAddonStudyAction.BadNoRecipesArg, + "An error should be logged since some recipes were received" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js b/toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js new file mode 100644 index 0000000000..910de357f6 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js @@ -0,0 +1,62 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { ConsoleLogAction } = ChromeUtils.importESModule( + "resource://normandy/actions/ConsoleLogAction.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); + +// Test that logging works +add_task(async function logging_works() { + const action = new ConsoleLogAction(); + const infoStub = sinon.stub(action.log, "info"); + try { + const recipe = { id: 1, arguments: { message: "Hello, world!" } }; + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + Assert.deepEqual( + infoStub.args, + ["Hello, world!"], + "the message should be logged" + ); + } finally { + infoStub.restore(); + } +}); + +// test that argument validation works +decorate_task( + withStub(Uptake, "reportRecipe"), + async function arguments_are_validated({ reportRecipeStub }) { + const action = new ConsoleLogAction(); + const infoStub = sinon.stub(action.log, "info"); + + try { + // message is required + let recipe = { id: 1, arguments: {} }; + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + Assert.deepEqual(infoStub.args, [], "no message should be logged"); + Assert.deepEqual(reportRecipeStub.args, [ + [recipe, Uptake.RECIPE_EXECUTION_ERROR], + ]); + + reportRecipeStub.reset(); + + // message must be a string + recipe = { id: 1, arguments: { message: 1 } }; + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "lastError should be null"); + Assert.deepEqual(infoStub.args, [], "no message should be logged"); + Assert.deepEqual(reportRecipeStub.args, [ + [recipe, Uptake.RECIPE_EXECUTION_ERROR], + ]); + } finally { + infoStub.restore(); + } + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_MessagingExperimentAction.js b/toolkit/components/normandy/test/browser/browser_actions_MessagingExperimentAction.js new file mode 100644 index 0000000000..0f16ff1436 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_MessagingExperimentAction.js @@ -0,0 +1,67 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); +const { MessagingExperimentAction } = ChromeUtils.importESModule( + "resource://normandy/actions/MessagingExperimentAction.sys.mjs" +); + +const { _ExperimentManager, ExperimentManager } = ChromeUtils.importESModule( + "resource://nimbus/lib/ExperimentManager.sys.mjs" +); + +decorate_task( + withStudiesEnabled(), + withStub(Uptake, "reportRecipe"), + async function arguments_are_validated({ reportRecipeStub }) { + const action = new MessagingExperimentAction(); + + is( + action.manager, + ExperimentManager, + "should set .manager to ExperimentManager singleton" + ); + // Override this for the purposes of the test + action.manager = new _ExperimentManager(); + await action.manager.onStartup(); + const onRecipeStub = sinon.spy(action.manager, "onRecipe"); + + const recipe = { + id: 1, + arguments: { + slug: "foo", + isEnrollmentPaused: false, + branches: [ + { + slug: "control", + ratio: 1, + groups: ["green"], + value: { title: "hello" }, + }, + { + slug: "variant", + ratio: 1, + groups: ["green"], + value: { title: "world" }, + }, + ], + }, + }; + + ok(action.validateArguments(recipe.arguments), "should validate arguments"); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(reportRecipeStub.args, [[recipe, Uptake.RECIPE_SUCCESS]]); + Assert.deepEqual( + onRecipeStub.args, + [[recipe.arguments, "normandy"]], + "should call onRecipe with recipe args and 'normandy' source" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_PreferenceExperimentAction.js b/toolkit/components/normandy/test/browser/browser_actions_PreferenceExperimentAction.js new file mode 100644 index 0000000000..ad0ec49913 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceExperimentAction.js @@ -0,0 +1,914 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { Sampling } = ChromeUtils.importESModule( + "resource://gre/modules/components-utils/Sampling.sys.mjs" +); + +const { ClientEnvironment } = ChromeUtils.importESModule( + "resource://normandy/lib/ClientEnvironment.sys.mjs" +); +const { PreferenceExperiments } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceExperiments.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { PreferenceExperimentAction } = ChromeUtils.importESModule( + "resource://normandy/actions/PreferenceExperimentAction.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +function branchFactory(opts = {}) { + const defaultPreferences = { + "fake.preference": {}, + }; + const defaultPrefInfo = { + preferenceType: "string", + preferenceBranchType: "default", + preferenceValue: "foo", + }; + const preferences = {}; + for (const [prefName, prefInfo] of Object.entries( + opts.preferences || defaultPreferences + )) { + preferences[prefName] = { ...defaultPrefInfo, ...prefInfo }; + } + return { + slug: "test", + ratio: 1, + ...opts, + preferences, + }; +} + +function argumentsFactory(args) { + const defaultBranches = (args && args.branches) || [{ preferences: [] }]; + const branches = defaultBranches.map(branchFactory); + return { + slug: "test", + userFacingName: "Super Cool Test Experiment", + userFacingDescription: + "Test experiment from browser_actions_PreferenceExperimentAction.", + isHighPopulation: false, + isEnrollmentPaused: false, + ...args, + branches, + }; +} + +function prefExperimentRecipeFactory(args) { + return recipeFactory({ + name: "preference-experiment", + arguments: argumentsFactory(args), + }); +} + +decorate_task( + withStudiesEnabled(), + withStub(Uptake, "reportRecipe"), + async function run_without_errors({ reportRecipeStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + // Errors thrown in actions are caught and silenced, so instead check for an + // explicit success here. + Assert.deepEqual(reportRecipeStub.args, [[recipe, Uptake.RECIPE_SUCCESS]]); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(Uptake, "reportRecipe"), + withStub(Uptake, "reportAction"), + withPrefEnv({ set: [["app.shield.optoutstudies.enabled", false]] }), + async function checks_disabled({ reportRecipeStub, reportActionStub }) { + const action = new PreferenceExperimentAction(); + action.log = mockLogger(); + + const recipe = prefExperimentRecipeFactory(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + + Assert.ok(action.log.debug.args.length === 1); + Assert.deepEqual(action.log.debug.args[0], [ + "User has opted-out of opt-out experiments, disabling action.", + ]); + Assert.deepEqual(action.log.warn.args, [ + [ + "Skipping recipe preference-experiment because PreferenceExperimentAction " + + "was disabled during preExecution.", + ], + ]); + + await action.finalize(); + Assert.ok(action.log.debug.args.length === 2); + Assert.deepEqual(action.log.debug.args[1], [ + "Skipping post-execution hook for PreferenceExperimentAction because it is disabled.", + ]); + Assert.deepEqual(reportRecipeStub.args, [ + [recipe, Uptake.RECIPE_ACTION_DISABLED], + ]); + Assert.deepEqual(reportActionStub.args, [ + [action.name, Uptake.ACTION_SUCCESS], + ]); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "start"), + PreferenceExperiments.withMockExperiments([]), + async function enroll_user_if_never_been_in_experiment({ startStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + slug: "test", + branches: [ + { + slug: "branch1", + preferences: { + "fake.preference": { + preferenceBranchType: "user", + preferenceValue: "branch1", + }, + }, + ratio: 1, + }, + { + slug: "branch2", + preferences: { + "fake.preference": { + preferenceBranchType: "user", + preferenceValue: "branch2", + }, + }, + ratio: 1, + }, + ], + }); + sinon + .stub(action, "chooseBranch") + .callsFake(async function (slug, branches) { + return branches[0]; + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(startStub.args, [ + [ + { + slug: "test", + actionName: "PreferenceExperimentAction", + branch: "branch1", + preferences: { + "fake.preference": { + preferenceValue: "branch1", + preferenceBranchType: "user", + preferenceType: "string", + }, + }, + experimentType: "exp", + userFacingName: "Super Cool Test Experiment", + userFacingDescription: + "Test experiment from browser_actions_PreferenceExperimentAction.", + }, + ], + ]); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "markLastSeen"), + PreferenceExperiments.withMockExperiments([{ slug: "test", expired: false }]), + async function markSeen_if_experiment_active({ markLastSeenStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + name: "test", + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(markLastSeenStub.args, [["test"]]); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "markLastSeen"), + PreferenceExperiments.withMockExperiments([{ slug: "test", expired: true }]), + async function dont_markSeen_if_experiment_expired({ markLastSeenStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + name: "test", + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(markLastSeenStub.args, [], "markLastSeen was not called"); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "start"), + async function do_nothing_if_enrollment_paused({ startStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + isEnrollmentPaused: true, + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(startStub.args, [], "start was not called"); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "stop"), + PreferenceExperiments.withMockExperiments([ + { slug: "seen", expired: false, actionName: "PreferenceExperimentAction" }, + { + slug: "unseen", + expired: false, + actionName: "PreferenceExperimentAction", + }, + ]), + async function stop_experiments_not_seen({ stopStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + slug: "seen", + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(stopStub.args, [ + [ + "unseen", + { + resetValue: true, + reason: "recipe-not-seen", + caller: "PreferenceExperimentAction._finalize", + }, + ], + ]); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "stop"), + PreferenceExperiments.withMockExperiments([ + { + slug: "seen", + expired: false, + actionName: "SinglePreferenceExperimentAction", + }, + { + slug: "unseen", + expired: false, + actionName: "SinglePreferenceExperimentAction", + }, + ]), + async function dont_stop_experiments_for_other_action({ stopStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + name: "seen", + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual( + stopStub.args, + [], + "stop not called for other action's experiments" + ); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "start"), + withStub(Uptake, "reportRecipe"), + PreferenceExperiments.withMockExperiments([ + { + slug: "conflict", + preferences: { + "conflict.pref": {}, + }, + expired: false, + }, + ]), + async function do_nothing_if_preference_is_already_being_tested({ + startStub, + reportRecipeStub, + }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + name: "new", + branches: [ + { + preferences: { "conflict.pref": {} }, + }, + ], + }); + action.chooseBranch = sinon + .stub() + .callsFake(async function (slug, branches) { + return branches[0]; + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(reportRecipeStub.args, [ + [recipe, Uptake.RECIPE_EXECUTION_ERROR], + ]); + Assert.deepEqual(startStub.args, [], "start not called"); + // No way to get access to log message/Error thrown + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "start"), + PreferenceExperiments.withMockExperiments([]), + async function experimentType_with_isHighPopulation_false({ startStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + isHighPopulation: false, + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(startStub.args[0][0].experimentType, "exp"); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(PreferenceExperiments, "start"), + PreferenceExperiments.withMockExperiments([]), + async function experimentType_with_isHighPopulation_true({ startStub }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ + isHighPopulation: true, + }); + + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + Assert.deepEqual(startStub.args[0][0].experimentType, "exp-highpop"); + } +); + +decorate_task( + withStudiesEnabled(), + withStub(Sampling, "ratioSample"), + async function chooseBranch_uses_ratioSample({ ratioSampleStub }) { + ratioSampleStub.returns(Promise.resolve(1)); + const action = new PreferenceExperimentAction(); + const branches = [ + { + preferences: { + "fake.preference": { + preferenceValue: "branch0", + }, + }, + ratio: 1, + }, + { + preferences: { + "fake.preference": { + preferenceValue: "branch1", + }, + }, + ratio: 2, + }, + ]; + const sandbox = sinon.createSandbox(); + let result; + try { + sandbox.stub(ClientEnvironment, "userId").get(() => "fake-id"); + result = await action.chooseBranch("exp-slug", branches); + } finally { + sandbox.restore(); + } + + Assert.deepEqual(ratioSampleStub.args, [ + ["fake-id-exp-slug-branch", [1, 2]], + ]); + Assert.deepEqual(result, branches[1]); + } +); + +decorate_task( + withStudiesEnabled(), + withMockPreferences(), + PreferenceExperiments.withMockExperiments([]), + async function integration_test_enroll_and_unenroll({ mockPreferences }) { + mockPreferences.set("fake.preference", "oldvalue", "user"); + const recipe = prefExperimentRecipeFactory({ + slug: "integration test experiment", + branches: [ + { + slug: "branch1", + preferences: { + "fake.preference": { + preferenceBranchType: "user", + preferenceValue: "branch1", + }, + }, + ratio: 1, + }, + { + slug: "branch2", + preferences: { + "fake.preference": { + preferenceBranchType: "user", + preferenceValue: "branch2", + }, + }, + ratio: 1, + }, + ], + userFacingName: "userFacingName", + userFacingDescription: "userFacingDescription", + }); + + // Session 1: we see the above recipe and enroll in the experiment. + const action = new PreferenceExperimentAction(); + sinon + .stub(action, "chooseBranch") + .callsFake(async function (slug, branches) { + return branches[0]; + }); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + + const activeExperiments = await PreferenceExperiments.getAllActive(); + ok(!!activeExperiments.length); + Assert.deepEqual(activeExperiments, [ + { + slug: "integration test experiment", + actionName: "PreferenceExperimentAction", + branch: "branch1", + preferences: { + "fake.preference": { + preferenceBranchType: "user", + preferenceValue: "branch1", + preferenceType: "string", + previousPreferenceValue: "oldvalue", + }, + }, + expired: false, + lastSeen: activeExperiments[0].lastSeen, // can't predict date + experimentType: "exp", + userFacingName: "userFacingName", + userFacingDescription: "userFacingDescription", + enrollmentId: activeExperiments[0].enrollmentId, + }, + ]); + + // Session 2: recipe is filtered out and so does not run. + const action2 = new PreferenceExperimentAction(); + await action2.finalize(); + + // Experiment should be unenrolled + Assert.deepEqual(await PreferenceExperiments.getAllActive(), []); + } +); + +// Check that the appropriate set of suitabilities are considered temporary errors +decorate_task( + withStudiesEnabled(), + async function test_temporary_errors_set_deadline() { + let suitabilities = [ + { + suitability: BaseAction.suitability.SIGNATURE_ERROR, + isTemporaryError: true, + }, + { + suitability: BaseAction.suitability.CAPABILITIES_MISMATCH, + isTemporaryError: false, + }, + { + suitability: BaseAction.suitability.FILTER_MATCH, + isTemporaryError: false, + }, + { + suitability: BaseAction.suitability.FILTER_MISMATCH, + isTemporaryError: false, + }, + { + suitability: BaseAction.suitability.FILTER_ERROR, + isTemporaryError: true, + }, + { + suitability: BaseAction.suitability.ARGUMENTS_INVALID, + isTemporaryError: false, + }, + ]; + + Assert.deepEqual( + suitabilities.map(({ suitability }) => suitability).sort(), + Array.from(Object.values(BaseAction.suitability)).sort(), + "This test covers all suitabilities" + ); + + // The action should set a deadline 1 week from now. To avoid intermittent + // failures, give this a generous bound of 2 hour on either side. + let now = Date.now(); + let hour = 60 * 60 * 1000; + let expectedDeadline = now + 7 * 24 * hour; + let minDeadline = new Date(expectedDeadline - 2 * hour); + let maxDeadline = new Date(expectedDeadline + 2 * hour); + + // For each suitability, build a decorator that sets up a suitable + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const { suitability, isTemporaryError } of suitabilities) { + const decorator = PreferenceExperiments.withMockExperiments([ + { slug: `test-for-suitability-${suitability}` }, + ]); + await decorator(async ({ prefExperiments: [experiment] }) => { + let action = new PreferenceExperimentAction(); + let recipe = prefExperimentRecipeFactory({ slug: experiment.slug }); + await action.processRecipe(recipe, suitability); + let modifiedExperiment = await PreferenceExperiments.get( + experiment.slug + ); + if (isTemporaryError) { + is( + typeof modifiedExperiment.temporaryErrorDeadline, + "string", + `A temporary failure deadline should be set as a string for suitability ${suitability}` + ); + let deadline = new Date(modifiedExperiment.temporaryErrorDeadline); + ok( + deadline >= minDeadline && deadline <= maxDeadline, + `The temporary failure deadline should be in the expected range for ` + + `suitability ${suitability} (got ${deadline})` + ); + } else { + ok( + !modifiedExperiment.temporaryErrorDeadline, + `No temporary failure deadline should be set for suitability ${suitability}` + ); + } + })(); + } + } +); + +// Check that if there is an existing deadline, temporary errors don't overwrite it +decorate_task( + withStudiesEnabled(), + PreferenceExperiments.withMockExperiments([]), + async function test_temporary_errors_dont_overwrite_deadline() { + let temporaryFailureSuitabilities = [ + BaseAction.suitability.SIGNATURE_ERROR, + BaseAction.suitability.FILTER_ERROR, + ]; + + // A deadline two hours in the future won't be hit during the test. + let now = Date.now(); + let hour = 2 * 60 * 60 * 1000; + let unhitDeadline = new Date(now + hour).toJSON(); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of temporaryFailureSuitabilities) { + const decorator = PreferenceExperiments.withMockExperiments([ + { + slug: `test-for-suitability-${suitability}`, + expired: false, + temporaryErrorDeadline: unhitDeadline, + }, + ]); + await decorator(async ({ prefExperiments: [experiment] }) => { + let action = new PreferenceExperimentAction(); + let recipe = prefExperimentRecipeFactory({ slug: experiment.slug }); + await action.processRecipe(recipe, suitability); + let modifiedExperiment = await PreferenceExperiments.get( + experiment.slug + ); + is( + modifiedExperiment.temporaryErrorDeadline, + unhitDeadline, + `The temporary failure deadline should not be cleared for suitability ${suitability}` + ); + })(); + } + } +); + +// Check that if the deadline is past, temporary errors end the experiment. +decorate_task( + withStudiesEnabled(), + async function test_temporary_errors_hit_deadline() { + let temporaryFailureSuitabilities = [ + BaseAction.suitability.SIGNATURE_ERROR, + BaseAction.suitability.FILTER_ERROR, + ]; + + // Set a deadline of two hours in the past, so that the experiment expires. + let now = Date.now(); + let hour = 2 * 60 * 60 * 1000; + let hitDeadline = new Date(now - hour).toJSON(); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of temporaryFailureSuitabilities) { + const decorator = PreferenceExperiments.withMockExperiments([ + { + slug: `test-for-suitability-${suitability}`, + expired: false, + temporaryErrorDeadline: hitDeadline, + preferences: [], + branch: "test-branch", + }, + ]); + await decorator(async ({ prefExperiments: [experiment] }) => { + let action = new PreferenceExperimentAction(); + let recipe = prefExperimentRecipeFactory({ slug: experiment.slug }); + await action.processRecipe(recipe, suitability); + let modifiedExperiment = await PreferenceExperiments.get( + experiment.slug + ); + ok( + modifiedExperiment.expired, + `The experiment should be expired for suitability ${suitability}` + ); + })(); + } + } +); + +// Check that non-temporary-error suitabilities clear the temporary deadline +decorate_task( + withStudiesEnabled(), + PreferenceExperiments.withMockExperiments([]), + async function test_non_temporary_error_clears_temporary_error_deadline() { + let suitabilitiesThatShouldClearDeadline = [ + BaseAction.suitability.CAPABILITIES_MISMATCH, + BaseAction.suitability.FILTER_MATCH, + BaseAction.suitability.FILTER_MISMATCH, + BaseAction.suitability.ARGUMENTS_INVALID, + ]; + + // Use a deadline in the past to demonstrate that even if the deadline has + // passed, only a temporary error suitability ends the experiment. + let now = Date.now(); + let hour = 60 * 60 * 1000; + let hitDeadline = new Date(now - 2 * hour).toJSON(); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of suitabilitiesThatShouldClearDeadline) { + const decorator = PreferenceExperiments.withMockExperiments([ + NormandyTestUtils.factories.preferenceStudyFactory({ + slug: `test-for-suitability-${suitability}`.toLocaleLowerCase(), + expired: false, + temporaryErrorDeadline: hitDeadline, + }), + ]); + await decorator(async ({ prefExperiments: [experiment] }) => { + let action = new PreferenceExperimentAction(); + let recipe = prefExperimentRecipeFactory({ slug: experiment.slug }); + await action.processRecipe(recipe, suitability); + let modifiedExperiment = await PreferenceExperiments.get( + experiment.slug + ); + ok( + !modifiedExperiment.temporaryErrorDeadline, + `The temporary failure deadline should be cleared for suitabilitiy ${suitability}` + ); + })(); + } + } +); + +// Check that invalid deadlines are reset +decorate_task( + withStudiesEnabled(), + PreferenceExperiments.withMockExperiments([]), + async function test_non_temporary_error_clears_temporary_error_deadline() { + let temporaryFailureSuitabilities = [ + BaseAction.suitability.SIGNATURE_ERROR, + BaseAction.suitability.FILTER_ERROR, + ]; + + // The action should set a deadline 1 week from now. To avoid intermittent + // failures, give this a generous bound of 2 hours on either side. + let invalidDeadline = "not a valid date"; + let now = Date.now(); + let hour = 60 * 60 * 1000; + let expectedDeadline = now + 7 * 24 * hour; + let minDeadline = new Date(expectedDeadline - 2 * hour); + let maxDeadline = new Date(expectedDeadline + 2 * hour); + + // For each suitability, build a decorator that sets up a suitabilty + // environment, and then call that decorator with a sub-test that asserts + // the suitability is handled correctly. + for (const suitability of temporaryFailureSuitabilities) { + const decorator = PreferenceExperiments.withMockExperiments([ + NormandyTestUtils.factories.preferenceStudyFactory({ + slug: `test-for-suitability-${suitability}`.toLocaleLowerCase(), + expired: false, + temporaryErrorDeadline: invalidDeadline, + }), + ]); + await decorator(async ({ prefExperiments: [experiment] }) => { + let action = new PreferenceExperimentAction(); + let recipe = prefExperimentRecipeFactory({ slug: experiment.slug }); + await action.processRecipe(recipe, suitability); + is(action.lastError, null, "No errors should be reported"); + let modifiedExperiment = await PreferenceExperiments.get( + experiment.slug + ); + ok( + modifiedExperiment.temporaryErrorDeadline != invalidDeadline, + `The temporary failure deadline should be reset for suitabilitiy ${suitability}` + ); + let deadline = new Date(modifiedExperiment.temporaryErrorDeadline); + ok( + deadline >= minDeadline && deadline <= maxDeadline, + `The temporary failure deadline should be reset to a valid deadline for ${suitability}` + ); + })(); + } + } +); + +// Check that an already unenrolled experiment doesn't try to unenroll again if +// the filter does not match. +decorate_task( + withStudiesEnabled(), + withSpy(PreferenceExperiments, "stop"), + async function test_stop_when_already_expired({ stopSpy }) { + // Use a deadline that is already past + const now = new Date(); + const hour = 1000 * 60 * 60; + const temporaryErrorDeadline = new Date(now - hour * 2).toJSON(); + + const suitabilitiesToCheck = Object.values(BaseAction.suitability); + + const subtest = decorate( + PreferenceExperiments.withMockExperiments([ + NormandyTestUtils.factories.preferenceStudyFactory({ + expired: true, + temporaryErrorDeadline, + }), + ]), + + async ({ prefExperiments: [experiment], suitability }) => { + const recipe = prefExperimentRecipeFactory({ slug: experiment.slug }); + const action = new PreferenceExperimentAction(); + await action.processRecipe(recipe, suitability); + Assert.deepEqual( + stopSpy.args, + [], + `Stop should not be called for ${suitability}` + ); + } + ); + + for (const suitability of suitabilitiesToCheck) { + await subtest({ suitability }); + } + } +); + +// If no recipes are received, it should be considered a temporary error +decorate_task( + withStudiesEnabled(), + PreferenceExperiments.withMockExperiments([ + NormandyTestUtils.factories.preferenceStudyFactory({ expired: false }), + ]), + withSpy(PreferenceExperiments, "stop"), + withStub(PreferenceExperimentAction.prototype, "_considerTemporaryError"), + async function testNoRecipes({ + stopSpy, + _considerTemporaryErrorStub, + prefExperiments: [experiment], + }) { + let action = new PreferenceExperimentAction(); + await action.finalize({ noRecipes: true }); + + Assert.deepEqual(stopSpy.args, [], "Stop should not be called"); + Assert.deepEqual( + _considerTemporaryErrorStub.args, + [[{ experiment, reason: "no-recipes" }]], + "The experiment should accumulate a temporary error" + ); + } +); + +// If recipes are received, but the flag that none were received is set, an error should be thrown +decorate_task( + withStudiesEnabled(), + PreferenceExperiments.withMockExperiments([ + NormandyTestUtils.factories.preferenceStudyFactory({ expired: false }), + ]), + withSpy(PreferenceExperiments, "stop"), + withStub(PreferenceExperimentAction.prototype, "_considerTemporaryError"), + async function testNoRecipes({ + stopSpy, + _considerTemporaryErrorStub, + prefExperiments: [experiment], + }) { + const action = new PreferenceExperimentAction(); + const recipe = prefExperimentRecipeFactory({ slug: experiment.slug }); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MISMATCH); + await action.finalize({ noRecipes: true }); + ok( + action.lastError instanceof PreferenceExperimentAction.BadNoRecipesArg, + "An error should be logged since some recipes were received" + ); + } +); + +// Unenrolling from an experiment where a user has changed some prefs should not override user choice +decorate_task( + withStudiesEnabled(), + withMockPreferences(), + PreferenceExperiments.withMockExperiments(), + async function testUserPrefNoReset({ mockPreferences }) { + mockPreferences.set("test.pref.should-reset", "builtin value", "default"); + mockPreferences.set("test.pref.user-override", "builtin value", "default"); + + await PreferenceExperiments.start({ + slug: "test-experiment", + actionName: "PreferenceExperimentAction", + isHighPopulation: false, + isEnrollmentPaused: false, + userFacingName: "Test Experiment", + userFacingDescription: "Test description", + branch: "test", + preferences: { + "test.pref.should-reset": { + preferenceValue: "experiment value", + preferenceType: "string", + previousPreferenceValue: "builtin value", + preferenceBranchType: "user", + overridden: false, + }, + "test.pref.user-override": { + preferenceValue: "experiment value", + preferenceType: "string", + previousPreferenceValue: "builtin value", + preferenceBranchType: "user", + overridden: false, + }, + }, + }); + + mockPreferences.set("test.pref.user-override", "user value", "user"); + + let exp = await PreferenceExperiments.get("test-experiment"); + is( + exp.preferences["test.pref.user-override"].overridden, + true, + "Changed pref should be marked as overridden" + ); + is( + exp.preferences["test.pref.should-reset"].overridden, + false, + "Unchanged pref should not be marked as overridden" + ); + + await PreferenceExperiments.stop("test-experiment", { + resetValue: true, + reason: "test-reason", + }); + + is( + Services.prefs.getCharPref("test.pref.should-reset"), + "builtin value", + "pref that was not overridden should reset to builtin" + ); + is( + Services.prefs.getCharPref("test.pref.user-override"), + "user value", + "pref that was overridden should keep user value" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js new file mode 100644 index 0000000000..36d71b72fc --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js @@ -0,0 +1,355 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { PreferenceRollbackAction } = ChromeUtils.importESModule( + "resource://normandy/actions/PreferenceRollbackAction.sys.mjs" +); +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); +const { PreferenceRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceRollouts.sys.mjs" +); + +// Test that a simple recipe rollsback as expected +decorate_task( + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function simple_rollback({ setExperimentInactiveStub, sendEventSpy }) { + Services.prefs.getDefaultBranch("").setIntPref("test.pref1", 2); + Services.prefs + .getDefaultBranch("") + .setCharPref("test.pref2", "rollout value"); + Services.prefs.getDefaultBranch("").setBoolPref("test.pref3", true); + + await PreferenceRollouts.add({ + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref1", value: 2, previousValue: 1 }, + { + preferenceName: "test.pref2", + value: "rollout value", + previousValue: "builtin value", + }, + { preferenceName: "test.pref3", value: true, previousValue: false }, + ], + enrollmentId: "test-enrollment-id", + }); + + const recipe = { id: 1, arguments: { rolloutSlug: "test-rollout" } }; + + const action = new PreferenceRollbackAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + // rollout prefs are reset + is( + Services.prefs.getIntPref("test.pref1"), + 1, + "integer pref should be rolled back" + ); + is( + Services.prefs.getCharPref("test.pref2"), + "builtin value", + "string pref should be rolled back" + ); + is( + Services.prefs.getBoolPref("test.pref3"), + false, + "boolean pref should be rolled back" + ); + + // start up prefs are unset + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref1"), + Services.prefs.PREF_INVALID, + "integer startup pref should be unset" + ); + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref2"), + Services.prefs.PREF_INVALID, + "string startup pref should be unset" + ); + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref3"), + Services.prefs.PREF_INVALID, + "boolean startup pref should be unset" + ); + + // rollout in db was updated + const rollouts = await PreferenceRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ROLLED_BACK, + preferences: [ + { preferenceName: "test.pref1", value: 2, previousValue: 1 }, + { + preferenceName: "test.pref2", + value: "rollout value", + previousValue: "builtin value", + }, + { preferenceName: "test.pref3", value: true, previousValue: false }, + ], + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be updated in db" + ); + + // Telemetry is updated + sendEventSpy.assertEvents([ + [ + "unenroll", + "preference_rollback", + recipe.arguments.rolloutSlug, + { reason: "rollback" }, + ], + ]); + Assert.deepEqual( + setExperimentInactiveStub.args, + [["test-rollout"]], + "the telemetry experiment should deactivated" + ); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref1"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref2"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref3"); + } +); + +// Test that a graduated rollout can't be rolled back +decorate_task( + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function cant_rollback_graduated({ sendEventSpy }) { + Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1); + await PreferenceRollouts.add({ + slug: "graduated-rollout", + state: PreferenceRollouts.STATE_GRADUATED, + preferences: [ + { preferenceName: "test.pref", value: 1, previousValue: 1 }, + ], + enrollmentId: "test-enrollment-id", + }); + + let recipe = { id: 1, arguments: { rolloutSlug: "graduated-rollout" } }; + + const action = new PreferenceRollbackAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is(Services.prefs.getIntPref("test.pref"), 1, "pref should not change"); + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), + Services.prefs.PREF_INVALID, + "no startup pref should be added" + ); + + // rollout in the DB hasn't changed + Assert.deepEqual( + await PreferenceRollouts.getAll(), + [ + { + slug: "graduated-rollout", + state: PreferenceRollouts.STATE_GRADUATED, + preferences: [ + { preferenceName: "test.pref", value: 1, previousValue: 1 }, + ], + enrollmentId: "test-enrollment-id", + }, + ], + "Rollout should not change in db" + ); + + sendEventSpy.assertEvents([ + [ + "unenrollFailed", + "preference_rollback", + "graduated-rollout", + { reason: "graduated", enrollmentId: "test-enrollment-id" }, + ], + ]); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); + +// Test that a rollback without a matching rollout does not send telemetry +decorate_task( + withSendEventSpy(), + withStub(Uptake, "reportRecipe"), + PreferenceRollouts.withTestMock(), + async function rollback_without_rollout({ sendEventSpy, reportRecipeStub }) { + let recipe = { id: 1, arguments: { rolloutSlug: "missing-rollout" } }; + + const action = new PreferenceRollbackAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + sendEventSpy.assertEvents([]); + Assert.deepEqual( + reportRecipeStub.args, + [[recipe, Uptake.RECIPE_SUCCESS]], + "recipe should be reported as succesful" + ); + } +); + +// Test that rolling back an already rolled back recipe doesn't do anything +decorate_task( + withStub(TelemetryEnvironment, "setExperimentInactive"), + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function rollback_already_rolled_back({ + setExperimentInactiveStub, + sendEventSpy, + }) { + Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1); + + const recipe = { id: 1, arguments: { rolloutSlug: "test-rollout" } }; + const rollout = { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ROLLED_BACK, + preferences: [ + { preferenceName: "test.pref", value: 2, previousValue: 1 }, + ], + enrollmentId: "test-rollout-id", + }; + await PreferenceRollouts.add(rollout); + + const action = new PreferenceRollbackAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is(Services.prefs.getIntPref("test.pref"), 1, "pref shouldn't change"); + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), + Services.prefs.PREF_INVALID, + "startup pref should not be set" + ); + + // rollout in db was updated + Assert.deepEqual( + await PreferenceRollouts.getAll(), + [rollout], + "Rollout shouldn't change in db" + ); + + // Telemetry is updated + sendEventSpy.assertEvents([]); + Assert.deepEqual( + setExperimentInactiveStub.args, + [], + "telemetry experiments should not be updated" + ); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); + +// Test that a rollback doesn't affect user prefs +decorate_task( + PreferenceRollouts.withTestMock(), + async function simple_rollback() { + Services.prefs + .getDefaultBranch("") + .setCharPref("test.pref", "rollout value"); + Services.prefs.setCharPref("test.pref", "user value"); + + await PreferenceRollouts.add({ + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { + preferenceName: "test.pref", + value: "rollout value", + previousValue: "builtin value", + }, + ], + enrollmentId: "test-enrollment-id", + }); + + const recipe = { id: 1, arguments: { rolloutSlug: "test-rollout" } }; + + const action = new PreferenceRollbackAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is( + Services.prefs.getDefaultBranch("").getCharPref("test.pref"), + "builtin value", + "default branch should be reset" + ); + is( + Services.prefs.getCharPref("test.pref"), + "user value", + "user branch should remain the same" + ); + + // Cleanup + Services.prefs.deleteBranch("test.pref"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); + +// Test that a rollouts in the graduation set can't be rolled back +decorate_task( + withSendEventSpy(), + PreferenceRollouts.withTestMock({ + graduationSet: new Set(["graduated-rollout"]), + }), + async function cant_rollback_graduation_set({ sendEventSpy }) { + Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1); + + let recipe = { id: 1, arguments: { rolloutSlug: "graduated-rollout" } }; + + const action = new PreferenceRollbackAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is(Services.prefs.getIntPref("test.pref"), 1, "pref should not change"); + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), + Services.prefs.PREF_INVALID, + "no startup pref should be added" + ); + + // No entry in the DB + Assert.deepEqual( + await PreferenceRollouts.getAll(), + [], + "Rollout should be in the db" + ); + + sendEventSpy.assertEvents([ + [ + "unenrollFailed", + "preference_rollback", + "graduated-rollout", + { + reason: "in-graduation-set", + enrollmentId: TelemetryEvents.NO_ENROLLMENT_ID, + }, + ], + ]); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js new file mode 100644 index 0000000000..64b60b3483 --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js @@ -0,0 +1,725 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { PreferenceRolloutAction } = ChromeUtils.importESModule( + "resource://normandy/actions/PreferenceRolloutAction.sys.mjs" +); +const { PreferenceRollouts } = ChromeUtils.importESModule( + "resource://normandy/lib/PreferenceRollouts.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +// Test that a simple recipe enrolls as expected +decorate_task( + withStub(TelemetryEnvironment, "setExperimentActive"), + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function simple_recipe_enrollment({ + setExperimentActiveStub, + sendEventSpy, + }) { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [ + { preferenceName: "test.pref1", value: 1 }, + { preferenceName: "test.pref2", value: true }, + { preferenceName: "test.pref3", value: "it works" }, + ], + }, + }; + + const action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + // rollout prefs are set + is( + Services.prefs.getIntPref("test.pref1"), + 1, + "integer pref should be set" + ); + is( + Services.prefs.getBoolPref("test.pref2"), + true, + "boolean pref should be set" + ); + is( + Services.prefs.getCharPref("test.pref3"), + "it works", + "string pref should be set" + ); + + // start up prefs are set + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), + 1, + "integer startup pref should be set" + ); + is( + Services.prefs.getBoolPref("app.normandy.startupRolloutPrefs.test.pref2"), + true, + "boolean startup pref should be set" + ); + is( + Services.prefs.getCharPref("app.normandy.startupRolloutPrefs.test.pref3"), + "it works", + "string startup pref should be set" + ); + + // rollout was stored + let rollouts = await PreferenceRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref1", value: 1, previousValue: null }, + { preferenceName: "test.pref2", value: true, previousValue: null }, + { + preferenceName: "test.pref3", + value: "it works", + previousValue: null, + }, + ], + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Rollout should be stored in db" + ); + ok( + NormandyTestUtils.isUuid(rollouts[0].enrollmentId), + "Rollout should have a UUID enrollmentId" + ); + + sendEventSpy.assertEvents([ + [ + "enroll", + "preference_rollout", + recipe.arguments.slug, + { enrollmentId: rollouts[0].enrollmentId }, + ], + ]); + ok( + setExperimentActiveStub.calledWithExactly("test-rollout", "active", { + type: "normandy-prefrollout", + enrollmentId: rollouts[0].enrollmentId, + }), + "a telemetry experiment should be activated" + ); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref1"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref2"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref3"); + } +); + +// Test that an enrollment's values can change, be removed, and be added +decorate_task( + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function update_enrollment({ sendEventSpy }) { + // first enrollment + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [ + { preferenceName: "test.pref1", value: 1 }, + { preferenceName: "test.pref2", value: 1 }, + ], + }, + }; + + let action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + const defaultBranch = Services.prefs.getDefaultBranch(""); + is(defaultBranch.getIntPref("test.pref1"), 1, "pref1 should be set"); + is(defaultBranch.getIntPref("test.pref2"), 1, "pref2 should be set"); + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), + 1, + "startup pref1 should be set" + ); + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"), + 1, + "startup pref2 should be set" + ); + + // update existing enrollment + recipe.arguments.preferences = [ + // pref1 is removed + // pref2's value is updated + { preferenceName: "test.pref2", value: 2 }, + // pref3 is added + { preferenceName: "test.pref3", value: 2 }, + ]; + action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + /* Todo because of bug 1502410 and bug 1505941 */ + todo_is( + Services.prefs.getPrefType("test.pref1"), + Services.prefs.PREF_INVALID, + "pref1 should be removed" + ); + is(Services.prefs.getIntPref("test.pref2"), 2, "pref2 should be updated"); + is(Services.prefs.getIntPref("test.pref3"), 2, "pref3 should be added"); + + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref1"), + Services.prefs.PREF_INVALID, + "startup pref1 should be removed" + ); + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"), + 2, + "startup pref2 should be updated" + ); + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref3"), + 2, + "startup pref3 should be added" + ); + + // rollout in the DB has been updated + const rollouts = await PreferenceRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref2", value: 2, previousValue: null }, + { preferenceName: "test.pref3", value: 2, previousValue: null }, + ], + }, + ], + "Rollout should be updated in db" + ); + + sendEventSpy.assertEvents([ + [ + "enroll", + "preference_rollout", + "test-rollout", + { enrollmentId: rollouts[0].enrollmentId }, + ], + [ + "update", + "preference_rollout", + "test-rollout", + { previousState: "active", enrollmentId: rollouts[0].enrollmentId }, + ], + ]); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref1"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref2"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref3"); + } +); + +// Test that a graduated rollout can be ungraduated +decorate_task( + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function ungraduate_enrollment({ sendEventSpy }) { + Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1); + await PreferenceRollouts.add({ + slug: "test-rollout", + state: PreferenceRollouts.STATE_GRADUATED, + preferences: [ + { preferenceName: "test.pref", value: 1, previousValue: 1 }, + ], + enrollmentId: "test-enrollment-id", + }); + + let recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [{ preferenceName: "test.pref", value: 2 }], + }, + }; + + const action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is(Services.prefs.getIntPref("test.pref"), 2, "pref should be updated"); + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref"), + 2, + "startup pref should be set" + ); + + // rollout in the DB has been ungraduated + const rollouts = await PreferenceRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref", value: 2, previousValue: 1 }, + ], + }, + ], + "Rollout should be updated in db" + ); + + sendEventSpy.assertEvents([ + [ + "update", + "preference_rollout", + "test-rollout", + { previousState: "graduated", enrollmentId: "test-enrollment-id" }, + ], + ]); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); + +// Test when recipes conflict, only one is applied +decorate_task( + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function conflicting_recipes({ sendEventSpy }) { + // create two recipes that each share a pref and have a unique pref. + const recipe1 = { + id: 1, + arguments: { + slug: "test-rollout-1", + preferences: [ + { preferenceName: "test.pref1", value: 1 }, + { preferenceName: "test.pref2", value: 1 }, + ], + }, + }; + const recipe2 = { + id: 2, + arguments: { + slug: "test-rollout-2", + preferences: [ + { preferenceName: "test.pref1", value: 2 }, + { preferenceName: "test.pref3", value: 2 }, + ], + }, + }; + + // running both in the same session + let action = new PreferenceRolloutAction(); + await action.processRecipe(recipe1, BaseAction.suitability.FILTER_MATCH); + await action.processRecipe(recipe2, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + // running recipe2 in a separate session shouldn't change things + action = new PreferenceRolloutAction(); + await action.processRecipe(recipe2, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is( + Services.prefs.getIntPref("test.pref1"), + 1, + "pref1 is set to recipe1's value" + ); + is( + Services.prefs.getIntPref("test.pref2"), + 1, + "pref2 is set to recipe1's value" + ); + is( + Services.prefs.getPrefType("test.pref3"), + Services.prefs.PREF_INVALID, + "pref3 is not set" + ); + + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), + 1, + "startup pref1 is set to recipe1's value" + ); + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"), + 1, + "startup pref2 is set to recipe1's value" + ); + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref3"), + Services.prefs.PREF_INVALID, + "startup pref3 is not set" + ); + + // only successful rollout was stored + const rollouts = await PreferenceRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + slug: "test-rollout-1", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref1", value: 1, previousValue: null }, + { preferenceName: "test.pref2", value: 1, previousValue: null }, + ], + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "Only recipe1's rollout should be stored in db" + ); + + sendEventSpy.assertEvents([ + ["enroll", "preference_rollout", recipe1.arguments.slug], + [ + "enrollFailed", + "preference_rollout", + recipe2.arguments.slug, + { reason: "conflict", preference: "test.pref1" }, + ], + [ + "enrollFailed", + "preference_rollout", + recipe2.arguments.slug, + { reason: "conflict", preference: "test.pref1" }, + ], + ]); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref1"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref2"); + Services.prefs.getDefaultBranch("").deleteBranch("test.pref3"); + } +); + +// Test when the wrong value type is given, the recipe is not applied +decorate_task( + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function wrong_preference_value({ sendEventSpy }) { + Services.prefs.getDefaultBranch("").setCharPref("test.pref", "not an int"); + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [{ preferenceName: "test.pref", value: 1 }], + }, + }; + + const action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is( + Services.prefs.getCharPref("test.pref"), + "not an int", + "the pref should not be modified" + ); + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), + Services.prefs.PREF_INVALID, + "startup pref is not set" + ); + + Assert.deepEqual( + await PreferenceRollouts.getAll(), + [], + "no rollout is stored in the db" + ); + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "preference_rollout", + recipe.arguments.slug, + { reason: "invalid type", preference: "test.pref" }, + ], + ]); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); + +// Test that even when applying a rollout, user prefs are preserved +decorate_task( + PreferenceRollouts.withTestMock(), + async function preserves_user_prefs() { + Services.prefs + .getDefaultBranch("") + .setCharPref("test.pref", "builtin value"); + Services.prefs.setCharPref("test.pref", "user value"); + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [{ preferenceName: "test.pref", value: "rollout value" }], + }, + }; + + const action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is( + Services.prefs.getCharPref("test.pref"), + "user value", + "user branch value should be preserved" + ); + is( + Services.prefs.getDefaultBranch("").getCharPref("test.pref"), + "rollout value", + "default branch value should change" + ); + + const rollouts = await PreferenceRollouts.getAll(); + Assert.deepEqual( + rollouts, + [ + { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { + preferenceName: "test.pref", + value: "rollout value", + previousValue: "builtin value", + }, + ], + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "the rollout is added to the db with the correct previous value" + ); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + Services.prefs.deleteBranch("test.pref"); + } +); + +// Enrollment works for prefs with only a user branch value, and no default value. +decorate_task( + PreferenceRollouts.withTestMock(), + async function simple_recipe_enrollment() { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [{ preferenceName: "test.pref", value: 1 }], + }, + }; + + // Set a pref on the user branch only + Services.prefs.setIntPref("test.pref", 2); + + const action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is( + Services.prefs.getIntPref("test.pref"), + 2, + "original user branch value still visible" + ); + is( + Services.prefs.getDefaultBranch("").getIntPref("test.pref"), + 1, + "default branch was set" + ); + is( + Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref"), + 1, + "startup pref is est" + ); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); + +// When running a rollout a second time on a pref that doesn't have an existing +// value, the previous value is handled correctly. +decorate_task( + PreferenceRollouts.withTestMock(), + withSendEventSpy(), + async function ({ sendEventSpy }) { + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [{ preferenceName: "test.pref", value: 1 }], + }, + }; + + // run once + let action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + // run a second time + action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + const rollouts = await PreferenceRollouts.getAll(); + + Assert.deepEqual( + rollouts, + [ + { + slug: "test-rollout", + state: PreferenceRollouts.STATE_ACTIVE, + preferences: [ + { preferenceName: "test.pref", value: 1, previousValue: null }, + ], + enrollmentId: rollouts[0].enrollmentId, + }, + ], + "the DB should have the correct value stored for previousValue" + ); + + sendEventSpy.assertEvents([ + [ + "enroll", + "preference_rollout", + "test-rollout", + { enrollmentId: rollouts[0].enrollmentId }, + ], + ]); + } +); + +// New rollouts that are no-ops should send errors +decorate_task( + withStub(TelemetryEnvironment, "setExperimentActive"), + withSendEventSpy(), + PreferenceRollouts.withTestMock(), + async function no_op_new_recipe({ setExperimentActiveStub, sendEventSpy }) { + Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1); + + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [{ preferenceName: "test.pref", value: 1 }], + }, + }; + + const action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is(Services.prefs.getIntPref("test.pref"), 1, "pref should not change"); + + // start up pref isn't set + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), + Services.prefs.PREF_INVALID, + "startup pref1 should not be set" + ); + + // rollout was not stored + Assert.deepEqual( + await PreferenceRollouts.getAll(), + [], + "Rollout should not be stored in db" + ); + + sendEventSpy.assertEvents([ + [ + "enrollFailed", + "preference_rollout", + recipe.arguments.slug, + { reason: "would-be-no-op" }, + ], + ]); + Assert.deepEqual( + setExperimentActiveStub.args, + [], + "a telemetry experiment should not be activated" + ); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); + +// New rollouts in the graduation set should silently do nothing +decorate_task( + withStub(TelemetryEnvironment, "setExperimentActive"), + withSendEventSpy(), + PreferenceRollouts.withTestMock({ graduationSet: new Set(["test-rollout"]) }), + async function graduationSetNewRecipe({ + setExperimentActiveStub, + sendEventSpy, + }) { + Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1); + + const recipe = { + id: 1, + arguments: { + slug: "test-rollout", + preferences: [{ preferenceName: "test.pref", value: 1 }], + }, + }; + + const action = new PreferenceRolloutAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is(action.lastError, null, "lastError should be null"); + + is(Services.prefs.getIntPref("test.pref"), 1, "pref should not change"); + + // start up pref isn't set + is( + Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), + Services.prefs.PREF_INVALID, + "startup pref1 should not be set" + ); + + // rollout was not stored + Assert.deepEqual( + await PreferenceRollouts.getAll(), + [], + "Rollout should not be stored in db" + ); + + sendEventSpy.assertEvents([]); + Assert.deepEqual( + setExperimentActiveStub.args, + [], + "a telemetry experiment should not be activated" + ); + + // Cleanup + Services.prefs.getDefaultBranch("").deleteBranch("test.pref"); + } +); diff --git a/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js b/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js new file mode 100644 index 0000000000..393f31b5ae --- /dev/null +++ b/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js @@ -0,0 +1,377 @@ +"use strict"; + +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { ClientEnvironment } = ChromeUtils.importESModule( + "resource://normandy/lib/ClientEnvironment.sys.mjs" +); +const { Heartbeat } = ChromeUtils.importESModule( + "resource://normandy/lib/Heartbeat.sys.mjs" +); + +const { Uptake } = ChromeUtils.importESModule( + "resource://normandy/lib/Uptake.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +const HOUR_IN_MS = 60 * 60 * 1000; + +function heartbeatRecipeFactory(overrides = {}) { + const defaults = { + revision_id: 1, + name: "Test Recipe", + action: "show-heartbeat", + arguments: { + surveyId: "a survey", + message: "test message", + engagementButtonLabel: "", + thanksMessage: "thanks!", + postAnswerUrl: "http://example.com", + learnMoreMessage: "Learn More", + learnMoreUrl: "http://example.com", + repeatOption: "once", + }, + }; + + if (overrides.arguments) { + defaults.arguments = Object.assign(defaults.arguments, overrides.arguments); + delete overrides.arguments; + } + + return recipeFactory(Object.assign(defaults, overrides)); +} + +// Test that a normal heartbeat works as expected +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testHappyPath({ heartbeatClassStub, heartbeatInstanceStub }) { + const recipe = heartbeatRecipeFactory(); + const action = new ShowHeartbeatAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + await action.finalize(); + is( + action.state, + ShowHeartbeatAction.STATE_FINALIZED, + "Action should be finalized" + ); + is(action.lastError, null, "No errors should have been thrown"); + + const options = heartbeatClassStub.args[0][1]; + Assert.deepEqual( + heartbeatClassStub.args, + [ + [ + heartbeatClassStub.args[0][0], // target window + { + surveyId: options.surveyId, + message: recipe.arguments.message, + engagementButtonLabel: recipe.arguments.engagementButtonLabel, + thanksMessage: recipe.arguments.thanksMessage, + learnMoreMessage: recipe.arguments.learnMoreMessage, + learnMoreUrl: recipe.arguments.learnMoreUrl, + postAnswerUrl: options.postAnswerUrl, + flowId: options.flowId, + surveyVersion: recipe.revision_id, + }, + ], + ], + "expected arguments were passed" + ); + + ok(NormandyTestUtils.isUuid(options.flowId, "flowId should be a uuid")); + + // postAnswerUrl gains several query string parameters. Check that the prefix is right + ok(options.postAnswerUrl.startsWith(recipe.arguments.postAnswerUrl)); + + ok( + heartbeatInstanceStub.eventEmitter.once.calledWith("Voted"), + "Voted event handler should be registered" + ); + ok( + heartbeatInstanceStub.eventEmitter.once.calledWith("Engaged"), + "Engaged event handler should be registered" + ); + } +); + +/* Test that heartbeat doesn't show if an unrelated heartbeat has shown recently. */ +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testRepeatGeneral({ heartbeatClassStub }) { + const allHeartbeatStorage = new Storage("normandy-heartbeat"); + await allHeartbeatStorage.setItem("lastShown", Date.now()); + const recipe = heartbeatRecipeFactory(); + + const action = new ShowHeartbeatAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + + is( + heartbeatClassStub.args.length, + 0, + "Heartbeat should not be called once" + ); + } +); + +/* Test that a heartbeat shows if an unrelated heartbeat showed more than 24 hours ago. */ +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testRepeatUnrelated({ heartbeatClassStub }) { + const allHeartbeatStorage = new Storage("normandy-heartbeat"); + await allHeartbeatStorage.setItem( + "lastShown", + Date.now() - 25 * HOUR_IN_MS + ); + const recipe = heartbeatRecipeFactory(); + + const action = new ShowHeartbeatAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + + is(heartbeatClassStub.args.length, 1, "Heartbeat should be called once"); + } +); + +/* Test that a repeat=once recipe is not shown again, even more than 24 hours ago. */ +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testRepeatTypeOnce({ heartbeatClassStub }) { + const recipe = heartbeatRecipeFactory({ + arguments: { repeatOption: "once" }, + }); + const recipeStorage = new Storage(recipe.id); + await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS); + + const action = new ShowHeartbeatAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + + is(heartbeatClassStub.args.length, 0, "Heartbeat should not be called"); + } +); + +/* Test that a repeat=xdays recipe is shown again, only after the expected number of days. */ +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testRepeatTypeXdays({ heartbeatClassStub }) { + const recipe = heartbeatRecipeFactory({ + arguments: { + repeatOption: "xdays", + repeatEvery: 2, + }, + }); + const recipeStorage = new Storage(recipe.id); + const allHeartbeatStorage = new Storage("normandy-heartbeat"); + + await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS); + await allHeartbeatStorage.setItem( + "lastShown", + Date.now() - 25 * HOUR_IN_MS + ); + const action = new ShowHeartbeatAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + is(heartbeatClassStub.args.length, 0, "Heartbeat should not be called"); + + await recipeStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS); + await allHeartbeatStorage.setItem( + "lastShown", + Date.now() - 50 * HOUR_IN_MS + ); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + is( + heartbeatClassStub.args.length, + 1, + "Heartbeat should have been called once" + ); + } +); + +/* Test that a repeat=nag recipe is shown again until lastInteraction is set */ +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testRepeatTypeNag({ heartbeatClassStub }) { + const recipe = heartbeatRecipeFactory({ + arguments: { repeatOption: "nag" }, + }); + const recipeStorage = new Storage(recipe.id); + const allHeartbeatStorage = new Storage("normandy-heartbeat"); + + await allHeartbeatStorage.setItem( + "lastShown", + Date.now() - 25 * HOUR_IN_MS + ); + await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS); + const action = new ShowHeartbeatAction(); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + is(heartbeatClassStub.args.length, 1, "Heartbeat should be called"); + + await allHeartbeatStorage.setItem( + "lastShown", + Date.now() - 50 * HOUR_IN_MS + ); + await recipeStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + is(heartbeatClassStub.args.length, 2, "Heartbeat should be called again"); + + await allHeartbeatStorage.setItem( + "lastShown", + Date.now() - 75 * HOUR_IN_MS + ); + await recipeStorage.setItem("lastShown", Date.now() - 75 * HOUR_IN_MS); + await recipeStorage.setItem( + "lastInteraction", + Date.now() - 50 * HOUR_IN_MS + ); + await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH); + is(action.lastError, null, "No errors should have been thrown"); + is( + heartbeatClassStub.args.length, + 2, + "Heartbeat should not be called again" + ); + } +); + +/* generatePostAnswerURL shouldn't annotate empty strings */ +add_task(async function postAnswerEmptyString() { + const recipe = heartbeatRecipeFactory({ arguments: { postAnswerUrl: "" } }); + const action = new ShowHeartbeatAction(); + is( + await action.generatePostAnswerURL(recipe), + "", + "an empty string should not be annotated" + ); +}); + +/* generatePostAnswerURL should include the right details */ +add_task(async function postAnswerUrl() { + const recipe = heartbeatRecipeFactory({ + arguments: { + postAnswerUrl: "https://example.com/survey?survey_id=42", + includeTelemetryUUID: false, + message: "Hello, World!", + }, + }); + const action = new ShowHeartbeatAction(); + const url = new URL(await action.generatePostAnswerURL(recipe)); + + is( + url.searchParams.get("survey_id"), + "42", + "Pre-existing search parameters should be preserved" + ); + is( + url.searchParams.get("fxVersion"), + Services.appinfo.version, + "Firefox version should be included" + ); + is( + url.searchParams.get("surveyversion"), + Services.appinfo.version, + "Survey version should also be the Firefox version" + ); + ok( + ["0", "1"].includes(url.searchParams.get("syncSetup")), + `syncSetup should be 0 or 1, got ${url.searchParams.get("syncSetup")}` + ); + is( + url.searchParams.get("updateChannel"), + UpdateUtils.getUpdateChannel("false"), + "Update channel should be included" + ); + ok(!url.searchParams.has("userId"), "no user id should be included"); + is( + url.searchParams.get("utm_campaign"), + "Hello%2CWorld!", + "utm_campaign should be an encoded version of the message" + ); + is( + url.searchParams.get("utm_medium"), + "show-heartbeat", + "utm_medium should be the action name" + ); + is( + url.searchParams.get("utm_source"), + "firefox", + "utm_source should be firefox" + ); +}); + +/* generatePostAnswerURL shouldn't override existing values in the url */ +add_task(async function postAnswerUrlNoOverwite() { + const recipe = heartbeatRecipeFactory({ + arguments: { + postAnswerUrl: + "https://example.com/survey?utm_source=shady_tims_firey_fox", + }, + }); + const action = new ShowHeartbeatAction(); + const url = new URL(await action.generatePostAnswerURL(recipe)); + is( + url.searchParams.get("utm_source"), + "shady_tims_firey_fox", + "utm_source should not be overwritten" + ); +}); + +/* generatePostAnswerURL should only include userId if requested */ +add_task(async function postAnswerUrlUserIdIfRequested() { + const recipeWithId = heartbeatRecipeFactory({ + arguments: { includeTelemetryUUID: true }, + }); + const recipeWithoutId = heartbeatRecipeFactory({ + arguments: { includeTelemetryUUID: false }, + }); + const action = new ShowHeartbeatAction(); + + const urlWithId = new URL(await action.generatePostAnswerURL(recipeWithId)); + is( + urlWithId.searchParams.get("userId"), + ClientEnvironment.userId, + "clientId should be included" + ); + + const urlWithoutId = new URL( + await action.generatePostAnswerURL(recipeWithoutId) + ); + ok(!urlWithoutId.searchParams.has("userId"), "userId should not be included"); +}); + +/* generateSurveyId should include userId only if requested */ +decorate_task( + withStubbedHeartbeat(), + withClearStorage(), + async function testGenerateSurveyId() { + const recipeWithoutId = heartbeatRecipeFactory({ + arguments: { surveyId: "test-id", includeTelemetryUUID: false }, + }); + const recipeWithId = heartbeatRecipeFactory({ + arguments: { surveyId: "test-id", includeTelemetryUUID: true }, + }); + const action = new ShowHeartbeatAction(); + is( + action.generateSurveyId(recipeWithoutId), + "test-id", + "userId should not be included if not requested" + ); + is( + action.generateSurveyId(recipeWithId), + `test-id::${ClientEnvironment.userId}`, + "userId should be included if requested" + ); + } +); diff --git a/toolkit/components/normandy/test/browser/head.js b/toolkit/components/normandy/test/browser/head.js new file mode 100644 index 0000000000..354c38647e --- /dev/null +++ b/toolkit/components/normandy/test/browser/head.js @@ -0,0 +1,642 @@ +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { AboutPages } = ChromeUtils.importESModule( + "resource://normandy-content/AboutPages.sys.mjs" +); +const { AddonStudies } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonStudies.sys.mjs" +); +const { NormandyApi } = ChromeUtils.importESModule( + "resource://normandy/lib/NormandyApi.sys.mjs" +); +const { TelemetryEvents } = ChromeUtils.importESModule( + "resource://normandy/lib/TelemetryEvents.sys.mjs" +); +const { ShowHeartbeatAction } = ChromeUtils.importESModule( + "resource://normandy/actions/ShowHeartbeatAction.sys.mjs" +); + +// The name of this module conflicts with the window.Storage +// DOM global - https://developer.mozilla.org/en-US/docs/Web/API/Storage . +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { Storage } = ChromeUtils.importESModule( + "resource://normandy/lib/Storage.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); +const FileInputStream = Components.Constructor( + "@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// Make sinon assertions fail in a way that mochitest understands +sinon.assert.fail = function (message) { + ok(false, message); +}; + +// Prep Telemetry to receive events from tests +TelemetryEvents.init(); + +this.TEST_XPI_URL = (function () { + const dir = getChromeDir(getResolvedURI(gTestPath)); + dir.append("addons"); + dir.append("normandydriver-a-1.0.xpi"); + return Services.io.newFileURI(dir).spec; +})(); + +this.withWebExtension = function ( + manifestOverrides = {}, + { as = "webExtension" } = {} +) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const random = Math.random().toString(36).replace(/0./, "").substr(-3); + let addonId = `normandydriver_${random}@example.com`; + if ("id" in manifestOverrides) { + addonId = manifestOverrides.id; + delete manifestOverrides.id; + } + + const manifest = Object.assign( + { + manifest_version: 2, + name: "normandy_fixture", + version: "1.0", + description: "Dummy test fixture that's a webextension", + browser_specific_settings: { + gecko: { id: addonId }, + }, + }, + manifestOverrides + ); + + const addonFile = AddonTestUtils.createTempWebExtensionFile({ manifest }); + + // Workaround: Add-on files are cached by URL, and + // createTempWebExtensionFile re-uses filenames if the previous file has + // been deleted. So we need to flush the cache to avoid it. + Services.obs.notifyObservers(addonFile, "flush-cache-entry"); + + try { + await testFunction({ ...args, [as]: { addonId, addonFile } }); + } finally { + AddonTestUtils.cleanupTempXPIs(); + } + }; + }; +}; + +this.withCorruptedWebExtension = function (options) { + // This should be an invalid manifest version, so that installing this add-on fails. + return this.withWebExtension({ manifest_version: -1 }, options); +}; + +this.withInstalledWebExtension = function ( + manifestOverrides = {}, + { expectUninstall = false, as = "installedWebExtension" } = {} +) { + return function wrapper(testFunction) { + return decorate( + withWebExtension(manifestOverrides, { as }), + async function wrappedTestFunction(args) { + const { addonId, addonFile } = args[as]; + const startupPromise = + AddonTestUtils.promiseWebExtensionStartup(addonId); + const addonInstall = await AddonManager.getInstallForFile( + addonFile, + "application/x-xpinstall" + ); + await addonInstall.install(); + await startupPromise; + + try { + await testFunction(args); + } finally { + const addonToUninstall = await AddonManager.getAddonByID(addonId); + if (addonToUninstall) { + await addonToUninstall.uninstall(); + } else { + ok( + expectUninstall, + "Add-on should not be unexpectedly uninstalled during test" + ); + } + } + } + ); + }; +}; + +this.withMockNormandyApi = function () { + return function (testFunction) { + return async function inner(args) { + const mockNormandyApi = { + actions: [], + recipes: [], + implementations: {}, + extensionDetails: {}, + }; + + // Use callsFake instead of resolves so that the current values in mockApi are used. + mockNormandyApi.fetchExtensionDetails = sinon + .stub(NormandyApi, "fetchExtensionDetails") + .callsFake(async extensionId => { + const details = mockNormandyApi.extensionDetails[extensionId]; + if (!details) { + throw new Error(`Missing extension details for ${extensionId}`); + } + return details; + }); + + try { + await testFunction({ ...args, mockNormandyApi }); + } finally { + mockNormandyApi.fetchExtensionDetails.restore(); + } + }; + }; +}; + +const preferenceBranches = { + user: Preferences, + default: new Preferences({ defaultBranch: true }), +}; + +this.withMockPreferences = function () { + return function (testFunction) { + return async function inner(args) { + const mockPreferences = new MockPreferences(); + try { + await testFunction({ ...args, mockPreferences }); + } finally { + mockPreferences.cleanup(); + } + }; + }; +}; + +class MockPreferences { + constructor() { + this.oldValues = { user: {}, default: {} }; + } + + set(name, value, branch = "user") { + this.preserve(name, branch); + preferenceBranches[branch].set(name, value); + } + + preserve(name, branch) { + if (branch !== "user" && branch !== "default") { + throw new Error(`Unexpected branch ${branch}`); + } + if (!(name in this.oldValues[branch])) { + const preferenceBranch = preferenceBranches[branch]; + let oldValue; + let existed; + try { + oldValue = preferenceBranch.get(name); + existed = preferenceBranch.has(name); + } catch (e) { + oldValue = null; + existed = false; + } + this.oldValues[branch][name] = { oldValue, existed }; + } + } + + cleanup() { + for (const [branchName, values] of Object.entries(this.oldValues)) { + const preferenceBranch = preferenceBranches[branchName]; + for (const [name, { oldValue, existed }] of Object.entries(values)) { + const before = preferenceBranch.get(name); + + if (before === oldValue) { + continue; + } + + if (existed) { + preferenceBranch.set(name, oldValue); + } else if (branchName === "default") { + Services.prefs.getDefaultBranch(name).deleteBranch(""); + } else { + preferenceBranch.reset(name); + } + + const after = preferenceBranch.get(name); + if (before === after && before !== undefined) { + throw new Error( + `Couldn't reset pref "${name}" to "${oldValue}" on "${branchName}" branch ` + + `(value stayed "${before}", did ${existed ? "" : "not "}exist)` + ); + } + } + } + } +} + +this.withPrefEnv = function (inPrefs) { + return function wrapper(testFunc) { + return async function inner(args) { + await SpecialPowers.pushPrefEnv(inPrefs); + try { + await testFunc(args); + } finally { + await SpecialPowers.popPrefEnv(); + } + }; + }; +}; + +this.withStudiesEnabled = function () { + return function (testFunc) { + return async function inner(args) { + await SpecialPowers.pushPrefEnv({ + set: [["app.shield.optoutstudies.enabled", true]], + }); + try { + await testFunc(args); + } finally { + await SpecialPowers.popPrefEnv(); + } + }; + }; +}; + +/** + * Combine a list of functions right to left. The rightmost function is passed + * to the preceding function as the argument; the result of this is passed to + * the next function until all are exhausted. For example, this: + * + * decorate(func1, func2, func3); + * + * is equivalent to this: + * + * func1(func2(func3)); + */ +this.decorate = function (...args) { + const funcs = Array.from(args); + let decorated = funcs.pop(); + const origName = decorated.name; + funcs.reverse(); + for (const func of funcs) { + decorated = func(decorated); + } + Object.defineProperty(decorated, "name", { value: origName }); + return decorated; +}; + +/** + * Wrapper around add_task for declaring tests that use several with-style + * wrappers. The last argument should be your test function; all other arguments + * should be functions that accept a single test function argument. + * + * The arguments are combined using decorate and passed to add_task as a single + * test function. + * + * @param {[Function]} args + * @example + * decorate_task( + * withMockPreferences(), + * withMockNormandyApi(), + * async function myTest(mockPreferences, mockApi) { + * // Do a test + * } + * ); + */ +this.decorate_task = function (...args) { + return add_task(decorate(...args)); +}; + +this.withStub = function ( + object, + method, + { returnValue, as = `${method}Stub` } = {} +) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const stub = sinon.stub(object, method); + stub.returnValue = returnValue; + try { + await testFunction({ ...args, [as]: stub }); + } finally { + stub.restore(); + } + }; + }; +}; + +this.withSpy = function (object, method, { as = `${method}Spy` } = {}) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const spy = sinon.spy(object, method); + try { + await testFunction({ ...args, [as]: spy }); + } finally { + spy.restore(); + } + }; + }; +}; + +this.studyEndObserved = function (recipeId) { + return TestUtils.topicObserved( + "shield-study-ended", + (subject, endedRecipeId) => Number.parseInt(endedRecipeId) === recipeId + ); +}; + +this.withSendEventSpy = function () { + return function (testFunction) { + return async function wrappedTestFunction(args) { + const sendEventSpy = sinon.spy(TelemetryEvents, "sendEvent"); + sendEventSpy.assertEvents = expected => { + expected = expected.map(event => ["normandy"].concat(event)); + TelemetryTestUtils.assertEvents( + expected, + { category: "normandy" }, + { clear: false } + ); + }; + Services.telemetry.clearEvents(); + try { + await testFunction({ ...args, sendEventSpy }); + } finally { + sendEventSpy.restore(); + Assert.ok(!sendEventSpy.threw(), "Telemetry events should not fail"); + } + }; + }; +}; + +let _recipeId = 1; +this.recipeFactory = function (overrides = {}) { + return Object.assign( + { + id: _recipeId++, + arguments: overrides.arguments || {}, + }, + overrides + ); +}; + +function mockLogger() { + const logStub = sinon.stub(); + logStub.fatal = sinon.stub(); + logStub.error = sinon.stub(); + logStub.warn = sinon.stub(); + logStub.info = sinon.stub(); + logStub.config = sinon.stub(); + logStub.debug = sinon.stub(); + logStub.trace = sinon.stub(); + return logStub; +} + +this.CryptoUtils = { + _getHashStringForCrypto(aCrypto) { + // return the two-digit hexadecimal code for a byte + let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2); + + // convert the binary hash data to a hex string. + let binary = aCrypto.finish(false); + let hash = Array.from(binary, c => toHexString(c.charCodeAt(0))); + return hash.join("").toLowerCase(); + }, + + /** + * Get the computed hash for a given file + * @param {nsIFile} file The file to be hashed + * @param {string} [algorithm] The hashing algorithm to use + */ + getFileHash(file, algorithm = "sha256") { + const crypto = CryptoHash(algorithm); + const fis = new FileInputStream(file, -1, -1, false); + crypto.updateFromStream(fis, file.fileSize); + const hash = this._getHashStringForCrypto(crypto); + fis.close(); + return hash; + }, +}; + +const FIXTURE_ADDON_ID = "normandydriver-a@example.com"; +const FIXTURE_ADDON_BASE_URL = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" + ) + "/addons/"; + +const FIXTURE_ADDONS = [ + "normandydriver-a-1.0", + "normandydriver-b-1.0", + "normandydriver-a-2.0", +]; + +// Generate fixture add-on details +this.FIXTURE_ADDON_DETAILS = {}; +FIXTURE_ADDONS.forEach(addon => { + const filename = `${addon}.xpi`; + const dir = getChromeDir(getResolvedURI(gTestPath)); + dir.append("addons"); + dir.append(filename); + const xpiFile = Services.io + .newFileURI(dir) + .QueryInterface(Ci.nsIFileURL).file; + + FIXTURE_ADDON_DETAILS[addon] = { + url: `${FIXTURE_ADDON_BASE_URL}${filename}`, + hash: CryptoUtils.getFileHash(xpiFile, "sha256"), + }; +}); + +this.extensionDetailsFactory = function (overrides = {}) { + return Object.assign( + { + id: 1, + name: "Normandy Fixture", + xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url, + extension_id: FIXTURE_ADDON_ID, + version: "1.0", + hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash, + hash_algorithm: "sha256", + }, + overrides + ); +}; + +/** + * Utility function to uninstall addons safely. Preventing the issue mentioned + * in bug 1485569. + * + * addon.uninstall is async, but it also triggers the AddonStudies onUninstall + * listener, which is not awaited. Wrap it here and trigger a promise once it's + * done so we can wait until AddonStudies cleanup is finished. + */ +this.safeUninstallAddon = async function (addon) { + const activeStudies = (await AddonStudies.getAll()).filter( + study => study.active + ); + const matchingStudy = activeStudies.find(study => study.addonId === addon.id); + + let studyEndedPromise; + if (matchingStudy) { + studyEndedPromise = TestUtils.topicObserved( + "shield-study-ended", + (subject, message) => { + return message === `${matchingStudy.recipeId}`; + } + ); + } + + const addonUninstallPromise = addon.uninstall(); + + return Promise.all([studyEndedPromise, addonUninstallPromise]); +}; + +/** + * Test decorator that is a modified version of the withInstalledWebExtension + * decorator that safely uninstalls the created addon. + */ +this.withInstalledWebExtensionSafe = function ( + manifestOverrides = {}, + { as = "installedWebExtensionSafe" } = {} +) { + return testFunction => { + return async function wrappedTestFunction(args) { + const decorated = withInstalledWebExtension(manifestOverrides, { + expectUninstall: true, + as, + })(async ({ [as]: { addonId, addonFile } }) => { + try { + await testFunction({ ...args, [as]: { addonId, addonFile } }); + } finally { + let addon = await AddonManager.getAddonByID(addonId); + if (addon) { + await safeUninstallAddon(addon); + addon = await AddonManager.getAddonByID(addonId); + ok(!addon, "add-on should be uninstalled"); + } + } + }); + await decorated(); + }; + }; +}; + +/** + * Test decorator to provide a web extension installed from a URL. + */ +this.withInstalledWebExtensionFromURL = function ( + url, + { as = "installedWebExtension" } = {} +) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + let startupPromise; + let addonId; + + const install = await AddonManager.getInstallForURL(url); + const listener = { + onInstallStarted(cbInstall) { + addonId = cbInstall.addon.id; + startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId); + }, + }; + install.addListener(listener); + + await install.install(); + await startupPromise; + + try { + await testFunction({ ...args, [as]: { addonId, url } }); + } finally { + const addonToUninstall = await AddonManager.getAddonByID(addonId); + await safeUninstallAddon(addonToUninstall); + } + }; + }; +}; + +/** + * Test decorator that checks that the test cleans up all add-ons installed + * during the test. Likely needs to be the first decorator used. + */ +this.ensureAddonCleanup = function () { + return function (testFunction) { + return async function wrappedTestFunction(args) { + const beforeAddons = new Set(await AddonManager.getAllAddons()); + + try { + await testFunction(args); + } finally { + const afterAddons = new Set(await AddonManager.getAllAddons()); + Assert.deepEqual( + beforeAddons, + afterAddons, + "The add-ons should be same before and after the test" + ); + } + }; + }; +}; + +class MockHeartbeat { + constructor() { + this.eventEmitter = new MockEventEmitter(); + } +} + +class MockEventEmitter { + constructor() { + this.once = sinon.stub(); + } +} + +function withStubbedHeartbeat() { + return function (testFunction) { + return async function wrappedTestFunction(args) { + const heartbeatInstanceStub = new MockHeartbeat(); + const heartbeatClassStub = sinon.stub(); + heartbeatClassStub.returns(heartbeatInstanceStub); + ShowHeartbeatAction.overrideHeartbeatForTests(heartbeatClassStub); + + try { + await testFunction({ + ...args, + heartbeatClassStub, + heartbeatInstanceStub, + }); + } finally { + ShowHeartbeatAction.overrideHeartbeatForTests(); + } + }; + }; +} + +function withClearStorage() { + return function (testFunction) { + return async function wrappedTestFunction(args) { + Storage.clearAllStorage(); + try { + await testFunction(args); + } finally { + Storage.clearAllStorage(); + } + }; + }; +} diff --git a/toolkit/components/normandy/test/browser/moz.build b/toolkit/components/normandy/test/browser/moz.build new file mode 100644 index 0000000000..a6fcd8c09a --- /dev/null +++ b/toolkit/components/normandy/test/browser/moz.build @@ -0,0 +1,27 @@ +# -*- 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/. + +BROWSER_CHROME_MANIFESTS += [ + "browser.ini", +] + +addons = [ + "normandydriver-a-1.0", + "normandydriver-b-1.0", + "normandydriver-a-2.0", +] + +output_dir = ( + OBJDIR_FILES._tests.testing.mochitest.browser.toolkit.components.normandy.test.browser.addons +) + +for addon in addons: + indir = "addons/%s" % addon + xpi = "%s.xpi" % indir + + GeneratedFile(xpi, script="../create_xpi.py", inputs=[indir]) + + output_dir += ["!%s" % xpi] diff --git a/toolkit/components/normandy/test/create_xpi.py b/toolkit/components/normandy/test/create_xpi.py new file mode 100644 index 0000000000..a34f25a2ed --- /dev/null +++ b/toolkit/components/normandy/test/create_xpi.py @@ -0,0 +1,12 @@ +# 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/. + +from os.path import abspath + +from mozbuild.action.zip import main as create_zip + + +def main(output, input_dir): + output.close() + return create_zip(["-C", input_dir, abspath(output.name), "**"]) diff --git a/toolkit/components/normandy/test/unit/cookie_server.sjs b/toolkit/components/normandy/test/unit/cookie_server.sjs new file mode 100644 index 0000000000..ab6099f6a4 --- /dev/null +++ b/toolkit/components/normandy/test/unit/cookie_server.sjs @@ -0,0 +1,12 @@ +/** + * Sends responses that sets a cookie. + */ +function handleRequest(request, response) { + // Allow cross-origin, so you can XHR to it! + response.setHeader("Access-Control-Allow-Origin", "*", false); + // Avoid confusing cache behaviors + response.setHeader("Cache-Control", "no-cache", false); + // Set a cookie + response.setHeader("Set-Cookie", "type=chocolate-chip", false); + response.write(""); +} diff --git a/toolkit/components/normandy/test/unit/echo_server.sjs b/toolkit/components/normandy/test/unit/echo_server.sjs new file mode 100644 index 0000000000..012f2b406e --- /dev/null +++ b/toolkit/components/normandy/test/unit/echo_server.sjs @@ -0,0 +1,21 @@ +/** + * Reads an HTTP status code and response body from the querystring and sends + * back a matching response. + */ +function handleRequest(request, response) { + // Allow cross-origin, so you can XHR to it! + response.setHeader("Access-Control-Allow-Origin", "*", false); + // Avoid confusing cache behaviors + response.setHeader("Cache-Control", "no-cache", false); + + const params = request.queryString.split("&"); + for (const param of params) { + const [key, value] = param.split("="); + if (key === "status") { + response.setStatusLine(null, value); + } else if (key === "body") { + response.write(value); + } + } + response.write(""); +} diff --git a/toolkit/components/normandy/test/unit/head_xpc.js b/toolkit/components/normandy/test/unit/head_xpc.js new file mode 100644 index 0000000000..ad2192be4b --- /dev/null +++ b/toolkit/components/normandy/test/unit/head_xpc.js @@ -0,0 +1,5 @@ +"use strict"; + +var { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); diff --git a/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json new file mode 100644 index 0000000000..5bef8d1302 --- /dev/null +++ b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json @@ -0,0 +1,4 @@ +{ + "recipe-signed": "/api/v1/recipe/signed/", + "classify-client": "/api/v1/classify_client/" +} diff --git a/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json new file mode 100644 index 0000000000..d5495fa87f --- /dev/null +++ b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json @@ -0,0 +1,24 @@ +[ + { + "recipe": { + "action": "console-log", + "arguments": { "message": "this signature does not match this recipe" }, + "channels": [], + "countries": [], + "enabled": true, + "extra_filter_expression": "true || true", + "filter_expression": "true || true", + "id": 1, + "last_updated": "2017-02-17T18:29:09.839239Z", + "locales": [], + "name": "system-addon-test", + "revision_id": "b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950" + }, + "signature": { + "public_key": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p", + "signature": "p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W", + "timestamp": "2017-02-17T18:29:09.847614Z", + "x5u": "/normandy.content-signature.mozilla.org-20210705.dev.chain" + } + } +] diff --git a/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain new file mode 100644 index 0000000000..5bf53787d8 --- /dev/null +++ b/toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain @@ -0,0 +1,123 @@ +-----BEGIN CERTIFICATE----- +MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV +UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv +BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw +HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx +EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp +b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j +b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj +dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB +GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO +hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC +VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU +/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD +VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu +dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp +Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290 +Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA +MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E +PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu +bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z +aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC +MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v +emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt +c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7 +ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq +rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr +Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun +aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ +j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0 +x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG +iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW +gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt +DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16 ++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv +JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK +Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv +bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj +K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx +NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs +bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs +YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys +wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP +bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv +tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc +UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk +0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c +t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ +F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB +GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ +7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0 +BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ +e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG +MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M +G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB +wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp +biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm +MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG +9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu +Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl +LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG +AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0 +L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA +gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J +8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5 +H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM +kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR +UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV +5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS +lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW +6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3 +69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt +VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb +jwzgpfquuYnnxe0CNBA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx +CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D +b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl +bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl +dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy +MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW +MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1 +cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y +b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp +Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml +6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR +t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd +ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d +n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB +IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ +tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB +64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e +Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+ +CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI +ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB +2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK +BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud +IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT +AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE +ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j +b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl +YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB +hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l +dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6 +Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ +KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6 +uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK +KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A +nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h +6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t +lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX +T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U +wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O +Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g +zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf +Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72 +-----END CERTIFICATE----- diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json new file mode 100644 index 0000000000..a9b6239e48 --- /dev/null +++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json @@ -0,0 +1,4 @@ +{ + "country": "US", + "request_time": "2017-02-22T17:43:24.657841Z" +} diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json new file mode 100644 index 0000000000..f088592a9b --- /dev/null +++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "name": "Normandy Fixture", + "xpi": "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi", + "extension_id": "normandydriver@example.com", + "version": "1.0", + "hash": "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171", + "hash_algorithm": "sha256" +} diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json new file mode 100644 index 0000000000..d2414056c0 --- /dev/null +++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json @@ -0,0 +1,5 @@ +{ + "classify-client": "/api/v1/classify_client/", + "extension-list": "/api/v1/extension/", + "recipe-signed": "/api/v1/recipe/signed/" +} diff --git a/toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json b/toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json new file mode 100644 index 0000000000..5f3515dc97 --- /dev/null +++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json @@ -0,0 +1,24 @@ +[ + { + "recipe": { + "action": "console-log", + "arguments": { "message": "asdfasfda sdf sa" }, + "channels": [], + "countries": [], + "enabled": true, + "extra_filter_expression": "true || true", + "filter_expression": "true || true", + "id": 1, + "last_updated": "2017-02-17T18:29:09.839239Z", + "locales": [], + "name": "system-addon-test", + "revision_id": "b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950" + }, + "signature": { + "public_key": "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p", + "signature": "p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W", + "timestamp": "2017-02-17T18:29:09.847614Z", + "x5u": "/normandy.content-signature.mozilla.org-20210705.dev.chain" + } + } +] diff --git a/toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain b/toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain new file mode 100644 index 0000000000..5bf53787d8 --- /dev/null +++ b/toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain @@ -0,0 +1,123 @@ +-----BEGIN CERTIFICATE----- +MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV +UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv +BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw +HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx +EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp +b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j +b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj +dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB +GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO +hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC +VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU +/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD +VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu +dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp +Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290 +Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA +MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E +PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu +bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z +aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC +MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v +emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt +c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7 +ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq +rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr +Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun +aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ +j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0 +x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG +iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW +gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt +DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16 ++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv +JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK +Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv +bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj +K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx +NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs +bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs +YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys +wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP +bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv +tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc +UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk +0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c +t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ +F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB +GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ +7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0 +BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ +e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG +MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M +G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB +wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp +biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm +MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG +9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu +Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl +LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG +AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0 +L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA +gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J +8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5 +H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM +kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR +UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV +5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS +lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW +6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3 +69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt +VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb +jwzgpfquuYnnxe0CNBA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx +CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D +b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl +bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl +dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy +MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW +MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1 +cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y +b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp +Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml +6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR +t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd +ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d +n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB +IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ +tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB +64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e +Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+ +CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI +ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB +2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK +BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud +IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT +AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE +ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j +b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl +YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB +hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l +dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6 +Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ +KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6 +uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK +KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A +nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h +6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t +lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX +T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U +wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O +Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g +zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf +Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72 +-----END CERTIFICATE----- diff --git a/toolkit/components/normandy/test/unit/query_server.sjs b/toolkit/components/normandy/test/unit/query_server.sjs new file mode 100644 index 0000000000..dd00d74bf6 --- /dev/null +++ b/toolkit/components/normandy/test/unit/query_server.sjs @@ -0,0 +1,34 @@ +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +// Returns a JSON string containing the query string arguments and the +// request body parsed as JSON. +function handleRequest(request, response) { + // Allow cross-origin, so you can XHR to it! + response.setHeader("Access-Control-Allow-Origin", "*", false); + // Avoid confusing cache behaviors + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "application/json", false); + + // Read request body + const inputStream = new BinaryInputStream(request.bodyInputStream); + let bytes = []; + let available; + while ((available = inputStream.available()) > 0) { + bytes = bytes.concat(inputStream.readByteArray(available)); + } + const body = String.fromCharCode.apply(null, bytes); + + // Write response body + const data = { queryString: {}, body: body ? JSON.parse(body) : {} }; + const params = request.queryString.split("&"); + for (const param of params) { + const [key, value] = param.split("="); + data.queryString[key] = value; + } + response.write(JSON.stringify(data)); +} diff --git a/toolkit/components/normandy/test/unit/test_Normandy.js b/toolkit/components/normandy/test/unit/test_Normandy.js new file mode 100644 index 0000000000..5bb1655fb8 --- /dev/null +++ b/toolkit/components/normandy/test/unit/test_Normandy.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Normandy } = ChromeUtils.importESModule( + "resource://normandy/Normandy.sys.mjs" +); +const { NormandyMigrations } = ChromeUtils.importESModule( + "resource://normandy/NormandyMigrations.sys.mjs" +); +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +/* import-globals-from utils.js */ +load("utils.js"); + +NormandyTestUtils.init({ add_task }); +const { decorate_task } = NormandyTestUtils; + +// Normandy's initialization function should set the start preferences before +// its first `await`. +decorate_task( + NormandyTestUtils.withStub(Normandy, "finishInit"), + NormandyTestUtils.withStub(NormandyMigrations, "applyAll"), + NormandyTestUtils.withMockPreferences(), + async function test_normandy_init_applies_startup_prefs_synchronously({ + mockPreferences, + }) { + const experimentPref = "test.experiment"; + const rolloutPref = "test.rollout"; + const experimentStartupPref = `app.normandy.startupExperimentPrefs.${experimentPref}`; + const rolloutStartupPref = `app.normandy.startupRolloutPrefs.${rolloutPref}`; + + mockPreferences.preserve(experimentPref, "default"); + mockPreferences.preserve(rolloutPref, "default"); + mockPreferences.set(experimentStartupPref, "experiment"); + mockPreferences.set(rolloutStartupPref, "rollout"); + + Assert.equal( + Services.prefs.getCharPref(experimentPref, "default"), + "default" + ); + Assert.equal(Services.prefs.getCharPref(rolloutPref, "default"), "default"); + + let initPromise = Normandy.init({ runAsync: false }); + + // note: There are no awaits before these asserts, so only the part of + // Normandy's initialization before its first await can run. + Assert.equal( + Services.prefs.getCharPref(experimentPref, "default"), + "experiment" + ); + Assert.equal(Services.prefs.getCharPref(rolloutPref, "default"), "rollout"); + + await initPromise; + await Normandy.uninit(); + } +); + +// Normandy's initialization function should register the observer for UI +// startup before it's first await. +decorate_task( + NormandyTestUtils.withStub(Normandy, "finishInit"), + NormandyTestUtils.withStub(NormandyMigrations, "applyAll"), + async function test_normandy_init_applies_startup_prefs_synchronously({ + applyAllStub, + }) { + let originalDeferred = Normandy.uiAvailableNotificationObserved; + let mockUiAvailableDeferred = PromiseUtils.defer(); + Normandy.uiAvailableNotificationObserved = mockUiAvailableDeferred; + + let applyAllDeferred = PromiseUtils.defer(); + applyAllStub.returns(applyAllStub); + + let promiseResolvedCount = 0; + mockUiAvailableDeferred.promise.then(() => promiseResolvedCount++); + + let initPromise = Normandy.init(); + + Assert.equal(promiseResolvedCount, 0); + Normandy.observe(null, "sessionstore-windows-restored"); + await TestUtils.waitForCondition(() => promiseResolvedCount === 1); + + applyAllDeferred.resolve(); + + await initPromise; + await Normandy.uninit(); + Normandy.uiAvailableNotificationObserved = originalDeferred; + } +); diff --git a/toolkit/components/normandy/test/unit/test_NormandyApi.js b/toolkit/components/normandy/test/unit/test_NormandyApi.js new file mode 100644 index 0000000000..885bd9fbdb --- /dev/null +++ b/toolkit/components/normandy/test/unit/test_NormandyApi.js @@ -0,0 +1,257 @@ +/* globals sinon */ +"use strict"; + +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +/* import-globals-from utils.js */ +load("utils.js"); + +NormandyTestUtils.init({ add_task }); +const { decorate_task } = NormandyTestUtils; + +Cu.importGlobalProperties(["fetch"]); + +decorate_task(withMockApiServer(), async function test_get({ serverUrl }) { + // Test that NormandyApi can fetch from the test server. + const response = await NormandyApi.get(`${serverUrl}/api/v1/`); + const data = await response.json(); + equal( + data["recipe-signed"], + "/api/v1/recipe/signed/", + "Expected data in response" + ); +}); + +decorate_task( + withMockApiServer(), + async function test_getApiUrl({ serverUrl }) { + const apiBase = `${serverUrl}/api/v1`; + // Test that NormandyApi can use the self-describing API's index + const recipeListUrl = await NormandyApi.getApiUrl("extension-list"); + equal( + recipeListUrl, + `${apiBase}/extension/`, + "Can retrieve extension-list URL from API" + ); + } +); + +decorate_task( + withMockApiServer(), + async function test_getApiUrlSlashes({ serverUrl, mockPreferences }) { + const fakeResponse = new MockResponse( + JSON.stringify({ "test-endpoint": `${serverUrl}/test/` }) + ); + const mockGet = sinon + .stub(NormandyApi, "get") + .callsFake(async () => fakeResponse); + + // without slash + { + NormandyApi.clearIndexCache(); + mockPreferences.set("app.normandy.api_url", `${serverUrl}/api/v1`); + const endpoint = await NormandyApi.getApiUrl("test-endpoint"); + equal(endpoint, `${serverUrl}/test/`); + ok( + mockGet.calledWithExactly(`${serverUrl}/api/v1/`), + "trailing slash was added" + ); + mockGet.resetHistory(); + } + + // with slash + { + NormandyApi.clearIndexCache(); + mockPreferences.set("app.normandy.api_url", `${serverUrl}/api/v1/`); + const endpoint = await NormandyApi.getApiUrl("test-endpoint"); + equal(endpoint, `${serverUrl}/test/`); + ok( + mockGet.calledWithExactly(`${serverUrl}/api/v1/`), + "existing trailing slash was preserved" + ); + mockGet.resetHistory(); + } + + NormandyApi.clearIndexCache(); + mockGet.restore(); + } +); + +// Test validation errors due to validation throwing an exception (e.g. when +// parameters passed to validation are malformed). +decorate_task( + withMockApiServer(), + async function test_validateSignedObject_validation_error() { + // Mock the x5u URL + const getStub = sinon.stub(NormandyApi, "get").callsFake(async url => { + ok(url.endsWith("x5u/"), "the only request should be to fetch the x5u"); + return new MockResponse("certchain"); + }); + + const signedObject = { a: 1, b: 2 }; + const signature = { + signature: "invalidsignature", + x5u: "http://localhost/x5u/", + }; + + // Validation should fail due to a malformed x5u and signature. + try { + await NormandyApi.verifyObjectSignature( + signedObject, + signature, + "object" + ); + ok(false, "validateSignedObject did not throw for a validation error"); + } catch (err) { + ok( + err instanceof NormandyApi.InvalidSignatureError, + "Error is an InvalidSignatureError" + ); + ok(/signature/.test(err), "Error is due to a validation error"); + } + + getStub.restore(); + } +); + +// Test validation errors due to validation returning false (e.g. when parameters +// passed to validation are correctly formed, but not valid for the data). +decorate_task( + withMockApiServer("invalid_recipe_signature_api"), + async function test_verifySignedObject_invalid_signature() { + // Get the test recipe and signature from the mock server. + const recipesUrl = await NormandyApi.getApiUrl("recipe-signed"); + const recipeResponse = await NormandyApi.get(recipesUrl); + const recipes = await recipeResponse.json(); + equal(recipes.length, 1, "Test data has one recipe"); + const [{ recipe, signature }] = recipes; + + try { + await NormandyApi.verifyObjectSignature(recipe, signature, "recipe"); + ok(false, "verifyObjectSignature did not throw for an invalid signature"); + } catch (err) { + ok( + err instanceof NormandyApi.InvalidSignatureError, + "Error is an InvalidSignatureError" + ); + ok(/signature/.test(err), "Error is due to an invalid signature"); + } + } +); + +decorate_task(withMockApiServer(), async function test_classifyClient() { + const classification = await NormandyApi.classifyClient(); + Assert.deepEqual(classification, { + country: "US", + request_time: new Date("2017-02-22T17:43:24.657841Z"), + }); +}); + +decorate_task(withMockApiServer(), async function test_fetchExtensionDetails() { + const extensionDetails = await NormandyApi.fetchExtensionDetails(1); + deepEqual(extensionDetails, { + id: 1, + name: "Normandy Fixture", + xpi: "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi", + extension_id: "normandydriver@example.com", + version: "1.0", + hash: "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171", + hash_algorithm: "sha256", + }); +}); + +decorate_task( + withScriptServer("query_server.sjs"), + async function test_getTestServer({ serverUrl }) { + // Test that NormandyApi can fetch from the test server. + const response = await NormandyApi.get(serverUrl); + const data = await response.json(); + Assert.deepEqual( + data, + { queryString: {}, body: {} }, + "NormandyApi returned incorrect server data." + ); + } +); + +decorate_task( + withScriptServer("query_server.sjs"), + async function test_getQueryString({ serverUrl }) { + // Test that NormandyApi can send query string parameters to the test server. + const response = await NormandyApi.get(serverUrl, { + foo: "bar", + baz: "biff", + }); + const data = await response.json(); + Assert.deepEqual( + data, + { queryString: { foo: "bar", baz: "biff" }, body: {} }, + "NormandyApi sent an incorrect query string." + ); + } +); + +// Test that no credentials are sent, even if the cookie store contains them. +decorate_task( + withScriptServer("cookie_server.sjs"), + async function test_sendsNoCredentials({ serverUrl }) { + // This test uses cookie_server.sjs, which responds to all requests with a + // response that sets a cookie. + + // send a request, to store a cookie in the cookie store + await fetch(serverUrl); + + // A normal request should send that cookie + const cookieExpectedDeferred = PromiseUtils.defer(); + function cookieExpectedObserver(aSubject, aTopic, aData) { + equal( + aTopic, + "http-on-modify-request", + "Only the expected topic should be observed" + ); + let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel); + equal( + httpChannel.getRequestHeader("Cookie"), + "type=chocolate-chip", + "The header should be sent" + ); + Services.obs.removeObserver( + cookieExpectedObserver, + "http-on-modify-request" + ); + cookieExpectedDeferred.resolve(); + } + Services.obs.addObserver(cookieExpectedObserver, "http-on-modify-request"); + await fetch(serverUrl); + await cookieExpectedDeferred.promise; + + // A request through the NormandyApi method should not send that cookie + const cookieNotExpectedDeferred = PromiseUtils.defer(); + function cookieNotExpectedObserver(aSubject, aTopic, aData) { + equal( + aTopic, + "http-on-modify-request", + "Only the expected topic should be observed" + ); + let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel); + Assert.throws( + () => httpChannel.getRequestHeader("Cookie"), + /NS_ERROR_NOT_AVAILABLE/, + "The cookie header should not be sent" + ); + Services.obs.removeObserver( + cookieNotExpectedObserver, + "http-on-modify-request" + ); + cookieNotExpectedDeferred.resolve(); + } + Services.obs.addObserver( + cookieNotExpectedObserver, + "http-on-modify-request" + ); + await NormandyApi.get(serverUrl); + await cookieNotExpectedDeferred.promise; + } +); diff --git a/toolkit/components/normandy/test/unit/test_PrefUtils.js b/toolkit/components/normandy/test/unit/test_PrefUtils.js new file mode 100644 index 0000000000..57130d8783 --- /dev/null +++ b/toolkit/components/normandy/test/unit/test_PrefUtils.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PrefUtils } = ChromeUtils.importESModule( + "resource://normandy/lib/PrefUtils.sys.mjs" +); + +add_task(function getPrefGetsValues() { + const defaultBranch = Services.prefs.getDefaultBranch(""); + const userBranch = Services.prefs; + + defaultBranch.setBoolPref("test.bool", false); + userBranch.setBoolPref("test.bool", true); + defaultBranch.setIntPref("test.int", 1); + userBranch.setIntPref("test.int", 2); + defaultBranch.setStringPref("test.string", "default"); + userBranch.setStringPref("test.string", "user"); + + equal( + PrefUtils.getPref("test.bool", { branch: "user" }), + true, + "should read user branch bools" + ); + equal( + PrefUtils.getPref("test.int", { branch: "user" }), + 2, + "should read user branch ints" + ); + equal( + PrefUtils.getPref("test.string", { branch: "user" }), + "user", + "should read user branch strings" + ); + + equal( + PrefUtils.getPref("test.bool", { branch: "default" }), + false, + "should read default branch bools" + ); + equal( + PrefUtils.getPref("test.int", { branch: "default" }), + 1, + "should read default branch ints" + ); + equal( + PrefUtils.getPref("test.string", { branch: "default" }), + "default", + "should read default branch strings" + ); + + equal( + PrefUtils.getPref("test.bool"), + true, + "should read bools from the user branch by default" + ); + equal( + PrefUtils.getPref("test.int"), + 2, + "should read ints from the user branch by default" + ); + equal( + PrefUtils.getPref("test.string"), + "user", + "should read strings from the user branch by default" + ); + + equal( + PrefUtils.getPref("test.does_not_exist"), + null, + "Should return null for non-existent prefs by default" + ); + let defaultValue = Symbol(); + equal( + PrefUtils.getPref("test.does_not_exist", { defaultValue }), + defaultValue, + "Should use the passed default value" + ); +}); + +// This is an important test because the pref system can behave in strange ways +// when the user branch has a value, but the default branch does not. +add_task(function getPrefHandlesUserValueNoDefaultValue() { + Services.prefs.setStringPref("test.only-user-value", "user"); + + let defaultValue = Symbol(); + equal( + PrefUtils.getPref("test.only-user-value", { + branch: "default", + defaultValue, + }), + defaultValue + ); + equal(PrefUtils.getPref("test.only-user-value", { branch: "default" }), null); + equal(PrefUtils.getPref("test.only-user-value", { branch: "user" }), "user"); + equal(PrefUtils.getPref("test.only-user-value"), "user"); +}); + +add_task(function getPrefInvalidBranch() { + Assert.throws( + () => PrefUtils.getPref("test.pref", { branch: "invalid" }), + PrefUtils.UnexpectedPreferenceBranch + ); +}); + +add_task(function setPrefSetsValues() { + const defaultBranch = Services.prefs.getDefaultBranch(""); + const userBranch = Services.prefs; + + defaultBranch.setIntPref("test.int", 1); + userBranch.setIntPref("test.int", 2); + defaultBranch.setStringPref("test.string", "default"); + userBranch.setStringPref("test.string", "user"); + defaultBranch.setBoolPref("test.bool", false); + userBranch.setBoolPref("test.bool", true); + + PrefUtils.setPref("test.int", 3); + equal( + userBranch.getIntPref("test.int"), + 3, + "the user branch should change for ints" + ); + PrefUtils.setPref("test.int", 4, { branch: "default" }); + equal( + userBranch.getIntPref("test.int"), + 3, + "changing the default branch shouldn't affect the user branch for ints" + ); + PrefUtils.setPref("test.int", null, { branch: "user" }); + equal( + userBranch.getIntPref("test.int"), + 4, + "clearing the user branch should reveal the default value for ints" + ); + + PrefUtils.setPref("test.string", "user override"); + equal( + userBranch.getStringPref("test.string"), + "user override", + "the user branch should change for strings" + ); + PrefUtils.setPref("test.string", "default override", { branch: "default" }); + equal( + userBranch.getStringPref("test.string"), + "user override", + "changing the default branch shouldn't affect the user branch for strings" + ); + PrefUtils.setPref("test.string", null, { branch: "user" }); + equal( + userBranch.getStringPref("test.string"), + "default override", + "clearing the user branch should reveal the default value for strings" + ); + + PrefUtils.setPref("test.bool", false); + equal( + userBranch.getBoolPref("test.bool"), + false, + "the user branch should change for bools" + ); + // The above effectively unsets the user branch, since it is now the same as the default branch + PrefUtils.setPref("test.bool", true, { branch: "default" }); + equal( + userBranch.getBoolPref("test.bool"), + true, + "the default branch should change for bools" + ); + + defaultBranch.setBoolPref("test.bool", false); + userBranch.setBoolPref("test.bool", true); + equal( + userBranch.getBoolPref("test.bool"), + true, + "the precondition should hold" + ); + PrefUtils.setPref("test.bool", null, { branch: "user" }); + equal( + userBranch.getBoolPref("test.bool"), + false, + "setting the user branch to null should reveal the default value for bools" + ); +}); + +add_task(function setPrefInvalidBranch() { + Assert.throws( + () => PrefUtils.setPref("test.pref", "value", { branch: "invalid" }), + PrefUtils.UnexpectedPreferenceBranch + ); +}); + +add_task(function clearPrefClearsValues() { + const defaultBranch = Services.prefs.getDefaultBranch(""); + const userBranch = Services.prefs; + + defaultBranch.setStringPref("test.string", "default"); + userBranch.setStringPref("test.string", "user"); + equal( + userBranch.getStringPref("test.string"), + "user", + "the precondition should hold" + ); + PrefUtils.clearPref("test.string"); + equal( + userBranch.getStringPref("test.string"), + "default", + "clearing the user branch should reveal the default value for bools" + ); + + PrefUtils.clearPref("test.string", { branch: "default" }); + equal( + userBranch.getStringPref("test.string"), + "default", + "clearing the default branch shouldn't do anything" + ); +}); + +add_task(function clearPrefInvalidBranch() { + Assert.throws( + () => PrefUtils.clearPref("test.pref", { branch: "invalid" }), + PrefUtils.UnexpectedPreferenceBranch + ); +}); diff --git a/toolkit/components/normandy/test/unit/test_RecipeRunner.js b/toolkit/components/normandy/test/unit/test_RecipeRunner.js new file mode 100644 index 0000000000..710ac4d507 --- /dev/null +++ b/toolkit/components/normandy/test/unit/test_RecipeRunner.js @@ -0,0 +1,34 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +const { RecipeRunner } = ChromeUtils.importESModule( + "resource://normandy/lib/RecipeRunner.sys.mjs" +); + +// Test that new build IDs trigger immediate recipe runs +add_task(async () => { + updateAppInfo({ + appBuildID: "new-build-id", + lastAppBuildID: "old-build-id", + }); + const runStub = sinon.stub(RecipeRunner, "run"); + const registerTimerStub = sinon.stub(RecipeRunner, "registerTimer"); + sinon.stub(RecipeRunner, "watchPrefs"); + + Services.prefs.setBoolPref("app.normandy.first_run", false); + + await RecipeRunner.init(); + Assert.deepEqual( + runStub.args, + [[{ trigger: "newBuildID" }]], + "RecipeRunner.run is called immediately on a new build ID" + ); + ok(registerTimerStub.called, "RecipeRunner.registerTimer registers a timer"); + + sinon.restore(); +}); diff --git a/toolkit/components/normandy/test/unit/test_addon_unenroll.js b/toolkit/components/normandy/test/unit/test_addon_unenroll.js new file mode 100644 index 0000000000..98750fc976 --- /dev/null +++ b/toolkit/components/normandy/test/unit/test_addon_unenroll.js @@ -0,0 +1,310 @@ +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { ExtensionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs" +); +const { BranchedAddonStudyAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs" +); +const { BaseAction } = ChromeUtils.importESModule( + "resource://normandy/actions/BaseAction.sys.mjs" +); +const { TelemetryEvents } = ChromeUtils.importESModule( + "resource://normandy/lib/TelemetryEvents.sys.mjs" +); +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { AddonStudies } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonStudies.sys.mjs" +); +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +/* import-globals-from utils.js */ +load("utils.js"); + +NormandyTestUtils.init({ add_task }); +const { decorate_task } = NormandyTestUtils; + +const global = this; + +add_task(async () => { + ExtensionTestUtils.init(global); + AddonTestUtils.init(global); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" + ); + AddonTestUtils.overrideCertDB(); + await AddonTestUtils.promiseStartupManager(); + + TelemetryEvents.init(); +}); + +decorate_task( + withMockApiServer(), + AddonStudies.withStudies([]), + async function test_addon_unenroll({ server: apiServer }) { + const ID = "study@tests.mozilla.org"; + + // Create a test extension that uses webextension experiments to install + // an unenroll listener. + let xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: "1.0", + + browser_specific_settings: { + gecko: { id: ID }, + }, + + experiment_apis: { + study: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "api.js", + paths: [["study"]], + }, + }, + }, + }, + + files: { + "schema.json": JSON.stringify([ + { + namespace: "study", + events: [ + { + name: "onStudyEnded", + type: "function", + }, + ], + }, + ]), + + "api.js": () => { + // The code below is serialized into a file embedded in an extension. + // But by including it here as code, eslint can analyze it. However, + // this code runs in a different environment with different globals, + // the following two lines avoid false eslint warnings: + /* globals browser, ExtensionAPI */ + /* eslint-disable-next-line no-shadow */ + const { AddonStudies } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonStudies.sys.mjs" + ); + const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" + ); + this.study = class extends ExtensionAPI { + getAPI(context) { + return { + study: { + onStudyEnded: new ExtensionCommon.EventManager({ + context, + name: "study.onStudyEnded", + register: fire => { + AddonStudies.addUnenrollListener( + this.extension.id, + reason => fire.sync(reason) + ); + return () => {}; + }, + }).api(), + }, + }; + } + }; + }, + }, + + background() { + browser.study.onStudyEnded.addListener(reason => { + browser.test.sendMessage("got-event", reason); + return new Promise(resolve => { + browser.test.onMessage.addListener(resolve); + }); + }); + }, + }); + + const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + server.registerFile("/study.xpi", xpi); + + const API_ID = 999; + apiServer.registerPathHandler( + `/api/v1/extension/${API_ID}/`, + (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write( + JSON.stringify({ + id: API_ID, + name: "Addon Unenroll Fixture", + xpi: "http://example.com/study.xpi", + extension_id: ID, + version: "1.0", + hash: CryptoUtils.getFileHash(xpi, "sha256"), + hash_algorithm: "sha256", + }) + ); + } + ); + + // Begin by telling Normandy to install the test extension above + // that uses a webextension experiment to register a blocking callback + // to be invoked when the study ends. + let extension = ExtensionTestUtils.expectExtension(ID); + + const RECIPE_ID = 1; + const UNENROLL_REASON = "test-ending"; + let action = new BranchedAddonStudyAction(); + await action.processRecipe( + { + id: RECIPE_ID, + type: "addon-study", + arguments: { + slug: "addon-unenroll-test", + userFacingDescription: "A recipe to test add-on unenrollment", + userFacingName: "Add-on Unenroll Test", + isEnrollmentPaused: false, + branches: [ + { + ratio: 1, + slug: "only", + extensionApiId: API_ID, + }, + ], + }, + }, + BaseAction.suitability.FILTER_MATCH + ); + + await extension.awaitStartup(); + + let addon = await AddonManager.getAddonByID(ID); + ok(addon, "Extension is installed"); + + // Tell Normandy to end the study, the extension event should be fired. + let unenrollPromise = action.unenroll(RECIPE_ID, UNENROLL_REASON); + + let receivedReason = await extension.awaitMessage("got-event"); + info("Got onStudyEnded event in extension"); + equal(receivedReason, UNENROLL_REASON, "Unenroll reason should be passed"); + + // The extension has not yet finished its unenrollment tasks, so it + // should not yet be uninstalled. + addon = await AddonManager.getAddonByID(ID); + ok(addon, "Extension has not yet been uninstalled"); + + // Once the extension does resolve the promise returned from the + // event listener, the uninstall can proceed. + extension.sendMessage("resolve"); + await unenrollPromise; + + addon = await AddonManager.getAddonByID(ID); + equal( + addon, + null, + "After resolving studyEnded promise, extension is uninstalled" + ); + } +); + +/* Test that a broken unenroll listener doesn't stop the add-on from being removed */ +decorate_task( + withMockApiServer(), + AddonStudies.withStudies([]), + async function test_addon_unenroll({ server: apiServer }) { + const ID = "study@tests.mozilla.org"; + + // Create a dummy webextension + // an unenroll listener that throws an error. + let xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: "1.0", + + browser_specific_settings: { + gecko: { id: ID }, + }, + }, + }); + + const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + server.registerFile("/study.xpi", xpi); + + const API_ID = 999; + apiServer.registerPathHandler( + `/api/v1/extension/${API_ID}/`, + (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write( + JSON.stringify({ + id: API_ID, + name: "Addon Fixture", + xpi: "http://example.com/study.xpi", + extension_id: ID, + version: "1.0", + hash: CryptoUtils.getFileHash(xpi, "sha256"), + hash_algorithm: "sha256", + }) + ); + } + ); + + // Begin by telling Normandy to install the test extension above that uses a + // webextension experiment to register a callback when the study ends that + // throws an error. + let extension = ExtensionTestUtils.expectExtension(ID); + + const RECIPE_ID = 1; + const UNENROLL_REASON = "test-ending"; + let action = new BranchedAddonStudyAction(); + await action.processRecipe( + { + id: RECIPE_ID, + type: "addon-study", + arguments: { + slug: "addon-unenroll-test", + userFacingDescription: "A recipe to test add-on unenrollment", + userFacingName: "Add-on Unenroll Test", + isEnrollmentPaused: false, + branches: [ + { + ratio: 1, + slug: "only", + extensionApiId: API_ID, + }, + ], + }, + }, + BaseAction.suitability.FILTER_MATCH + ); + + await extension.startupPromise; + + let addon = await AddonManager.getAddonByID(ID); + ok(addon, "Extension is installed"); + + let listenerDeferred = PromiseUtils.defer(); + + AddonStudies.addUnenrollListener(ID, () => { + listenerDeferred.resolve(); + throw new Error("This listener is busted"); + }); + + // Tell Normandy to end the study, the extension event should be fired. + await action.unenroll(RECIPE_ID, UNENROLL_REASON); + await listenerDeferred; + + addon = await AddonManager.getAddonByID(ID); + equal( + addon, + null, + "Extension is uninstalled even though it threw an exception in the callback" + ); + } +); diff --git a/toolkit/components/normandy/test/unit/utils.js b/toolkit/components/normandy/test/unit/utils.js new file mode 100644 index 0000000000..cffe634c91 --- /dev/null +++ b/toolkit/components/normandy/test/unit/utils.js @@ -0,0 +1,135 @@ +"use strict"; +/* eslint-disable no-unused-vars */ + +// Loaded into the same scope as head_xpc.js +/* import-globals-from head_xpc.js */ + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); + +const { NormandyApi } = ChromeUtils.importESModule( + "resource://normandy/lib/NormandyApi.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); + +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); +const FileInputStream = Components.Constructor( + "@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init" +); + +class MockResponse { + constructor(content) { + this.content = content; + } + + async text() { + return this.content; + } + + async json() { + return JSON.parse(this.content); + } +} + +function withServer(server) { + return function (testFunction) { + return NormandyTestUtils.decorate( + NormandyTestUtils.withMockPreferences(), + async function inner({ mockPreferences, ...args }) { + const serverUrl = `http://localhost:${server.identity.primaryPort}`; + mockPreferences.set("app.normandy.api_url", `${serverUrl}/api/v1`); + NormandyApi.clearIndexCache(); + + try { + await testFunction({ ...args, serverUrl, mockPreferences, server }); + } finally { + await new Promise(resolve => server.stop(resolve)); + } + } + ); + }; +} + +function makeScriptServer(scriptPath) { + const server = new HttpServer(); + server.registerContentType("sjs", "sjs"); + server.registerFile("/", do_get_file(scriptPath)); + server.start(-1); + return server; +} + +function withScriptServer(scriptPath) { + return withServer(makeScriptServer(scriptPath)); +} + +function makeMockApiServer(directory) { + const server = new HttpServer(); + server.registerDirectory("/", directory); + + server.setIndexHandler(async function (request, response) { + response.processAsync(); + const dir = request.getProperty("directory"); + const index = dir.clone(); + index.append("index.json"); + + if (!index.exists()) { + response.setStatusLine("1.1", 404, "Not Found"); + response.write(`Cannot find path ${index.path}`); + response.finish(); + return; + } + + try { + const contents = await IOUtils.readUTF8(index.path); + response.write(contents); + } catch (e) { + response.setStatusLine("1.1", 500, "Server error"); + response.write(e.toString()); + } finally { + response.finish(); + } + }); + + server.start(-1); + return server; +} + +function withMockApiServer(apiName = "mock_api") { + return withServer(makeMockApiServer(do_get_file(apiName))); +} + +const CryptoUtils = { + _getHashStringForCrypto(aCrypto) { + // return the two-digit hexadecimal code for a byte + let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2); + + // convert the binary hash data to a hex string. + let binary = aCrypto.finish(false); + let hash = Array.from(binary, c => toHexString(c.charCodeAt(0))); + return hash.join("").toLowerCase(); + }, + + /** + * Get the computed hash for a given file + * @param {nsIFile} file The file to be hashed + * @param {string} [algorithm] The hashing algorithm to use + */ + getFileHash(file, algorithm = "sha256") { + const crypto = CryptoHash(algorithm); + const fis = new FileInputStream(file, -1, -1, false); + crypto.updateFromStream(fis, file.fileSize); + const hash = this._getHashStringForCrypto(crypto); + fis.close(); + return hash; + }, +}; diff --git a/toolkit/components/normandy/test/unit/xpcshell.ini b/toolkit/components/normandy/test/unit/xpcshell.ini new file mode 100644 index 0000000000..e2ec476ce9 --- /dev/null +++ b/toolkit/components/normandy/test/unit/xpcshell.ini @@ -0,0 +1,17 @@ +[DEFAULT] +head = head_xpc.js +firefox-appdir = browser +support-files = + mock_api/** + invalid_recipe_signature_api/** + query_server.sjs + echo_server.sjs + cookie_server.sjs + utils.js +tags = normandy + +[test_Normandy.js] +[test_PrefUtils.js] +[test_addon_unenroll.js] +[test_NormandyApi.js] +[test_RecipeRunner.js] diff --git a/toolkit/components/normandy/vendor/LICENSE_THIRDPARTY b/toolkit/components/normandy/vendor/LICENSE_THIRDPARTY new file mode 100644 index 0000000000..13a9d68f50 --- /dev/null +++ b/toolkit/components/normandy/vendor/LICENSE_THIRDPARTY @@ -0,0 +1,241 @@ +fbjs@0.8.16 MIT +MIT License + +Copyright (c) 2013-present, Facebook, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +react-dom@15.6.1 BSD-3-Clause +BSD License + +For React software + +Copyright (c) 2013-present, Facebook, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +object-assign@4.1.1 MIT +The MIT License (MIT) + +Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +react@15.6.1 BSD-3-Clause +BSD License + +For React software + +Copyright (c) 2013-present, Facebook, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +prop-types@15.5.10 BSD-3-Clause +BSD License + +For React software + +Copyright (c) 2013-present, Facebook, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +create-react-class@15.6.2 MIT +MIT License + +Copyright (c) 2013-present, Facebook, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +mozjexl@1.1.5 MIT +Copyright for portions of mozJexl are held by TechnologyAdvice, 2015 as part of Jexl. +All other copyright for mozJexl are held by the Mozilla Foundation, 2017. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +process@0.11.10 MIT +(The MIT License) + +Copyright (c) 2013 Roman Shtylman <shtylman@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +classnames@2.2.5 MIT +The MIT License (MIT) + +Copyright (c) 2016 Jed Watson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. |