From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- toolkit/components/normandy/Normandy.sys.mjs | 301 +++ .../components/normandy/NormandyMigrations.sys.mjs | 139 ++ .../normandy/ShieldContentProcess.sys.mjs | 17 + .../normandy/actions/AddonRollbackAction.sys.mjs | 88 + .../normandy/actions/AddonRolloutAction.sys.mjs | 241 +++ .../components/normandy/actions/BaseAction.sys.mjs | 338 +++ .../normandy/actions/BaseStudyAction.sys.mjs | 37 + .../actions/BranchedAddonStudyAction.sys.mjs | 789 +++++++ .../normandy/actions/ConsoleLogAction.sys.mjs | 20 + .../actions/MessagingExperimentAction.sys.mjs | 34 + .../actions/PreferenceExperimentAction.sys.mjs | 278 +++ .../actions/PreferenceRollbackAction.sys.mjs | 104 + .../actions/PreferenceRolloutAction.sys.mjs | 265 +++ .../normandy/actions/ShowHeartbeatAction.sys.mjs | 226 ++ .../components/normandy/actions/schemas/README.md | 13 + .../normandy/actions/schemas/export_json.js | 20 + .../normandy/actions/schemas/index.sys.mjs | 528 +++++ .../normandy/actions/schemas/package.json | 11 + toolkit/components/normandy/components.conf | 14 + .../components/normandy/content/AboutPages.sys.mjs | 232 ++ .../normandy/content/ShieldFrameChild.sys.mjs | 172 ++ .../normandy/content/ShieldFrameParent.sys.mjs | 53 + .../content/about-studies/about-studies.css | 176 ++ .../content/about-studies/about-studies.html | 29 + .../content/about-studies/about-studies.js | 562 +++++ .../components/normandy/docs/data-collection.rst | 447 ++++ .../components/normandy/docs/execution-model.rst | 95 + toolkit/components/normandy/docs/index.rst | 30 + toolkit/components/normandy/docs/services.rst | 22 + toolkit/components/normandy/docs/suitabilities.rst | 73 + toolkit/components/normandy/jar.mn | 19 + .../components/normandy/lib/ActionsManager.sys.mjs | 100 + .../components/normandy/lib/AddonRollouts.sys.mjs | 224 ++ .../components/normandy/lib/AddonStudies.sys.mjs | 485 +++++ .../components/normandy/lib/CleanupManager.sys.mjs | 49 + .../normandy/lib/ClientEnvironment.sys.mjs | 123 ++ .../components/normandy/lib/EventEmitter.sys.mjs | 59 + toolkit/components/normandy/lib/Heartbeat.sys.mjs | 381 ++++ .../normandy/lib/LegacyHeartbeat.sys.mjs | 48 + toolkit/components/normandy/lib/LogManager.sys.mjs | 34 + .../normandy/lib/NormandyAddonManager.sys.mjs | 112 + .../components/normandy/lib/NormandyApi.sys.mjs | 157 ++ .../components/normandy/lib/NormandyUtils.sys.mjs | 10 + toolkit/components/normandy/lib/PrefUtils.sys.mjs | 132 ++ .../normandy/lib/PreferenceExperiments.sys.mjs | 1069 ++++++++++ .../normandy/lib/PreferenceRollouts.sys.mjs | 350 ++++ .../components/normandy/lib/RecipeRunner.sys.mjs | 645 ++++++ .../normandy/lib/ShieldPreferences.sys.mjs | 78 + toolkit/components/normandy/lib/Storage.sys.mjs | 90 + .../normandy/lib/TelemetryEvents.sys.mjs | 30 + toolkit/components/normandy/lib/Uptake.sys.mjs | 67 + toolkit/components/normandy/moz.build | 28 + .../normandy/schemas/LegacyHeartbeat.schema.json | 73 + .../components/normandy/skin/shared/Heartbeat.css | 106 + .../normandy/skin/shared/heartbeat-icon.svg | 13 + .../normandy/skin/shared/heartbeat-star-lit.svg | 7 + .../normandy/skin/shared/heartbeat-star-off.svg | 7 + toolkit/components/normandy/test/.eslintrc.js | 7 + .../normandy/test/NormandyTestUtils.sys.mjs | 349 ++++ .../normandy/test/browser/action_server.sjs | 10 + .../addons/normandydriver-a-1.0/manifest.json | 11 + .../addons/normandydriver-a-2.0/manifest.json | 11 + .../addons/normandydriver-b-1.0/manifest.json | 11 + .../components/normandy/test/browser/browser.ini | 49 + .../test/browser/browser_ActionsManager.js | 68 + .../normandy/test/browser/browser_AddonRollouts.js | 141 ++ .../normandy/test/browser/browser_AddonStudies.js | 300 +++ .../normandy/test/browser/browser_BaseAction.js | 349 ++++ .../test/browser/browser_CleanupManager.js | 26 + .../test/browser/browser_ClientEnvironment.js | 274 +++ .../normandy/test/browser/browser_EventEmitter.js | 110 + .../normandy/test/browser/browser_Heartbeat.js | 262 +++ .../test/browser/browser_LegacyHeartbeat.js | 88 + .../normandy/test/browser/browser_LogManager.js | 27 + .../normandy/test/browser/browser_Normandy.js | 386 ++++ .../test/browser/browser_NormandyAddonManager.js | 189 ++ .../test/browser/browser_NormandyMigrations.js | 106 + .../test/browser/browser_PreferenceExperiments.js | 2205 ++++++++++++++++++++ .../test/browser/browser_PreferenceRollouts.js | 316 +++ .../normandy/test/browser/browser_RecipeRunner.js | 874 ++++++++ .../test/browser/browser_ShieldPreferences.js | 91 + .../normandy/test/browser/browser_Storage.js | 43 + .../normandy/test/browser/browser_Uptake.js | 15 + .../test/browser/browser_about_preferences.js | 106 + .../normandy/test/browser/browser_about_studies.js | 825 ++++++++ .../browser/browser_actions_AddonRollbackAction.js | 246 +++ .../browser/browser_actions_AddonRolloutAction.js | 539 +++++ .../browser_actions_BranchedAddonStudyAction.js | 1662 +++++++++++++++ .../browser/browser_actions_ConsoleLogAction.js | 62 + .../browser_actions_MessagingExperimentAction.js | 67 + .../browser_actions_PreferenceExperimentAction.js | 914 ++++++++ .../browser_actions_PreferenceRollbackAction.js | 355 ++++ .../browser_actions_PreferenceRolloutAction.js | 725 +++++++ .../browser/browser_actions_ShowHeartbeatAction.js | 377 ++++ toolkit/components/normandy/test/browser/head.js | 642 ++++++ toolkit/components/normandy/test/browser/moz.build | 27 + toolkit/components/normandy/test/create_xpi.py | 12 + .../normandy/test/unit/cookie_server.sjs | 12 + .../components/normandy/test/unit/echo_server.sjs | 21 + toolkit/components/normandy/test/unit/head_xpc.js | 5 + .../invalid_recipe_signature_api/api/v1/index.json | 4 + .../api/v1/recipe/signed/index.json | 24 + ...ontent-signature.mozilla.org-20210705.dev.chain | 123 ++ .../mock_api/api/v1/classify_client/index.json | 4 + .../unit/mock_api/api/v1/extension/1/index.json | 9 + .../test/unit/mock_api/api/v1/extension/index.json | 0 .../normandy/test/unit/mock_api/api/v1/index.json | 5 + .../unit/mock_api/api/v1/recipe/signed/index.json | 24 + ...ontent-signature.mozilla.org-20210705.dev.chain | 123 ++ .../components/normandy/test/unit/query_server.sjs | 34 + .../components/normandy/test/unit/test_Normandy.js | 95 + .../normandy/test/unit/test_NormandyApi.js | 257 +++ .../normandy/test/unit/test_PrefUtils.js | 223 ++ .../normandy/test/unit/test_RecipeRunner.js | 34 + .../normandy/test/unit/test_addon_unenroll.js | 310 +++ toolkit/components/normandy/test/unit/utils.js | 135 ++ toolkit/components/normandy/test/unit/xpcshell.ini | 17 + .../components/normandy/vendor/LICENSE_THIRDPARTY | 241 +++ toolkit/components/normandy/vendor/PropTypes.js | 1 + toolkit/components/normandy/vendor/React.js | 5 + toolkit/components/normandy/vendor/ReactDOM.js | 18 + toolkit/components/normandy/vendor/classnames.js | 1 + 122 files changed, 24452 insertions(+) create mode 100644 toolkit/components/normandy/Normandy.sys.mjs create mode 100644 toolkit/components/normandy/NormandyMigrations.sys.mjs create mode 100644 toolkit/components/normandy/ShieldContentProcess.sys.mjs create mode 100644 toolkit/components/normandy/actions/AddonRollbackAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/AddonRolloutAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/BaseAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/BaseStudyAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/ConsoleLogAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/MessagingExperimentAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/PreferenceRollbackAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/PreferenceRolloutAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs create mode 100644 toolkit/components/normandy/actions/schemas/README.md create mode 100644 toolkit/components/normandy/actions/schemas/export_json.js create mode 100644 toolkit/components/normandy/actions/schemas/index.sys.mjs create mode 100644 toolkit/components/normandy/actions/schemas/package.json create mode 100644 toolkit/components/normandy/components.conf create mode 100644 toolkit/components/normandy/content/AboutPages.sys.mjs create mode 100644 toolkit/components/normandy/content/ShieldFrameChild.sys.mjs create mode 100644 toolkit/components/normandy/content/ShieldFrameParent.sys.mjs create mode 100644 toolkit/components/normandy/content/about-studies/about-studies.css create mode 100644 toolkit/components/normandy/content/about-studies/about-studies.html create mode 100644 toolkit/components/normandy/content/about-studies/about-studies.js create mode 100644 toolkit/components/normandy/docs/data-collection.rst create mode 100644 toolkit/components/normandy/docs/execution-model.rst create mode 100644 toolkit/components/normandy/docs/index.rst create mode 100644 toolkit/components/normandy/docs/services.rst create mode 100644 toolkit/components/normandy/docs/suitabilities.rst create mode 100644 toolkit/components/normandy/jar.mn create mode 100644 toolkit/components/normandy/lib/ActionsManager.sys.mjs create mode 100644 toolkit/components/normandy/lib/AddonRollouts.sys.mjs create mode 100644 toolkit/components/normandy/lib/AddonStudies.sys.mjs create mode 100644 toolkit/components/normandy/lib/CleanupManager.sys.mjs create mode 100644 toolkit/components/normandy/lib/ClientEnvironment.sys.mjs create mode 100644 toolkit/components/normandy/lib/EventEmitter.sys.mjs create mode 100644 toolkit/components/normandy/lib/Heartbeat.sys.mjs create mode 100644 toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs create mode 100644 toolkit/components/normandy/lib/LogManager.sys.mjs create mode 100644 toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs create mode 100644 toolkit/components/normandy/lib/NormandyApi.sys.mjs create mode 100644 toolkit/components/normandy/lib/NormandyUtils.sys.mjs create mode 100644 toolkit/components/normandy/lib/PrefUtils.sys.mjs create mode 100644 toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs create mode 100644 toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs create mode 100644 toolkit/components/normandy/lib/RecipeRunner.sys.mjs create mode 100644 toolkit/components/normandy/lib/ShieldPreferences.sys.mjs create mode 100644 toolkit/components/normandy/lib/Storage.sys.mjs create mode 100644 toolkit/components/normandy/lib/TelemetryEvents.sys.mjs create mode 100644 toolkit/components/normandy/lib/Uptake.sys.mjs create mode 100644 toolkit/components/normandy/moz.build create mode 100644 toolkit/components/normandy/schemas/LegacyHeartbeat.schema.json create mode 100644 toolkit/components/normandy/skin/shared/Heartbeat.css create mode 100644 toolkit/components/normandy/skin/shared/heartbeat-icon.svg create mode 100644 toolkit/components/normandy/skin/shared/heartbeat-star-lit.svg create mode 100644 toolkit/components/normandy/skin/shared/heartbeat-star-off.svg create mode 100644 toolkit/components/normandy/test/.eslintrc.js create mode 100644 toolkit/components/normandy/test/NormandyTestUtils.sys.mjs create mode 100644 toolkit/components/normandy/test/browser/action_server.sjs create mode 100644 toolkit/components/normandy/test/browser/addons/normandydriver-a-1.0/manifest.json create mode 100644 toolkit/components/normandy/test/browser/addons/normandydriver-a-2.0/manifest.json create mode 100644 toolkit/components/normandy/test/browser/addons/normandydriver-b-1.0/manifest.json create mode 100644 toolkit/components/normandy/test/browser/browser.ini create mode 100644 toolkit/components/normandy/test/browser/browser_ActionsManager.js create mode 100644 toolkit/components/normandy/test/browser/browser_AddonRollouts.js create mode 100644 toolkit/components/normandy/test/browser/browser_AddonStudies.js create mode 100644 toolkit/components/normandy/test/browser/browser_BaseAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_CleanupManager.js create mode 100644 toolkit/components/normandy/test/browser/browser_ClientEnvironment.js create mode 100644 toolkit/components/normandy/test/browser/browser_EventEmitter.js create mode 100644 toolkit/components/normandy/test/browser/browser_Heartbeat.js create mode 100644 toolkit/components/normandy/test/browser/browser_LegacyHeartbeat.js create mode 100644 toolkit/components/normandy/test/browser/browser_LogManager.js create mode 100644 toolkit/components/normandy/test/browser/browser_Normandy.js create mode 100644 toolkit/components/normandy/test/browser/browser_NormandyAddonManager.js create mode 100644 toolkit/components/normandy/test/browser/browser_NormandyMigrations.js create mode 100644 toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js create mode 100644 toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js create mode 100644 toolkit/components/normandy/test/browser/browser_RecipeRunner.js create mode 100644 toolkit/components/normandy/test/browser/browser_ShieldPreferences.js create mode 100644 toolkit/components/normandy/test/browser/browser_Storage.js create mode 100644 toolkit/components/normandy/test/browser/browser_Uptake.js create mode 100644 toolkit/components/normandy/test/browser/browser_about_preferences.js create mode 100644 toolkit/components/normandy/test/browser/browser_about_studies.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_MessagingExperimentAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_PreferenceExperimentAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js create mode 100644 toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js create mode 100644 toolkit/components/normandy/test/browser/head.js create mode 100644 toolkit/components/normandy/test/browser/moz.build create mode 100644 toolkit/components/normandy/test/create_xpi.py create mode 100644 toolkit/components/normandy/test/unit/cookie_server.sjs create mode 100644 toolkit/components/normandy/test/unit/echo_server.sjs create mode 100644 toolkit/components/normandy/test/unit/head_xpc.js create mode 100644 toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/index.json create mode 100644 toolkit/components/normandy/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json create mode 100644 toolkit/components/normandy/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain create mode 100644 toolkit/components/normandy/test/unit/mock_api/api/v1/classify_client/index.json create mode 100644 toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json create mode 100644 toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json create mode 100644 toolkit/components/normandy/test/unit/mock_api/api/v1/index.json create mode 100644 toolkit/components/normandy/test/unit/mock_api/api/v1/recipe/signed/index.json create mode 100644 toolkit/components/normandy/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain create mode 100644 toolkit/components/normandy/test/unit/query_server.sjs create mode 100644 toolkit/components/normandy/test/unit/test_Normandy.js create mode 100644 toolkit/components/normandy/test/unit/test_NormandyApi.js create mode 100644 toolkit/components/normandy/test/unit/test_PrefUtils.js create mode 100644 toolkit/components/normandy/test/unit/test_RecipeRunner.js create mode 100644 toolkit/components/normandy/test/unit/test_addon_unenroll.js create mode 100644 toolkit/components/normandy/test/unit/utils.js create mode 100644 toolkit/components/normandy/test/unit/xpcshell.ini create mode 100644 toolkit/components/normandy/vendor/LICENSE_THIRDPARTY create mode 100644 toolkit/components/normandy/vendor/PropTypes.js create mode 100644 toolkit/components/normandy/vendor/React.js create mode 100644 toolkit/components/normandy/vendor/ReactDOM.js create mode 100644 toolkit/components/normandy/vendor/classnames.js (limited to 'toolkit/components/normandy') 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 ", + "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 @@ + + + + + + + + about:studies + + + + + +
+ + + + + + + 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 + // 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/``: + +.. 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/``: + +.. 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 + `_, or it can + represent an error fetching some data that a Jexl recipe needs such + as `bug 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 ` 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 `. 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 ` +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} 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} + */ + 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} + */ + 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} + */ + async getAllActive(options) { + return (await this.getAll(options)).filter(study => study.active); + }, + + /** + * Add a study to storage. + * @return {Promise} 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} 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} 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} 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} 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} 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} 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} 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} runnerCapabilities The capabilities provided by this runner. + * @return {Promise} 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 @@ + + + + + + + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 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.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 + +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. diff --git a/toolkit/components/normandy/vendor/PropTypes.js b/toolkit/components/normandy/vendor/PropTypes.js new file mode 100644 index 0000000000..b91a68a86f --- /dev/null +++ b/toolkit/components/normandy/vendor/PropTypes.js @@ -0,0 +1 @@ +/* eslint-disable */this.PropTypes=function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={i:d,l:!1,exports:{}};return a[d].call(e.exports,e,e.exports,b),e.l=!0,e.exports}var c={};return b.m=a,b.c=c,b.d=function(a,c,d){b.o(a,c)||Object.defineProperty(a,c,{configurable:!1,enumerable:!0,get:d})},b.n=function(a){var c=a&&a.__esModule?function(){return a['default']}:function(){return a};return b.d(c,'a',c),c},b.o=function(a,b){return Object.prototype.hasOwnProperty.call(a,b)},b.p='',b(b.s=100)}({0:function(a){'use strict';var g=function(){};!1,a.exports=function(h,i,j,a,b,c,d,e){if(g(i),!h){var f;if(void 0===i)f=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var k=[j,a,b,c,d,e],l=0;f=new Error(i.replace(/%s/g,function(){return k[l++]})),f.name='Invariant Violation'}throw f.framesToPop=1,f}}},100:function(a,b,c){a.exports=c(101)()},101:function(a,b,c){'use strict';var d=c(5),e=c(0),f=c(19);a.exports=function(){function a(a,b,c,d,g,h){h===f||e(!1,'Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types')}function b(){return a}a.isRequired=a;var c={array:a,bool:a,func:a,number:a,object:a,string:a,symbol:a,any:a,arrayOf:b,element:a,instanceOf:b,node:a,objectOf:b,oneOf:b,oneOfType:b,shape:b};return c.checkPropTypes=d,c.PropTypes=c,c}},19:function(a){'use strict';a.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},5:function(a){'use strict';function b(a){return function(){return a}}var c=function(){};c.thatReturns=b,c.thatReturnsFalse=b(!1),c.thatReturnsTrue=b(!0),c.thatReturnsNull=b(null),c.thatReturnsThis=function(){return this},c.thatReturnsArgument=function(a){return a},a.exports=c}}); diff --git a/toolkit/components/normandy/vendor/React.js b/toolkit/components/normandy/vendor/React.js new file mode 100644 index 0000000000..4a8468746b --- /dev/null +++ b/toolkit/components/normandy/vendor/React.js @@ -0,0 +1,5 @@ +/* eslint-disable */this.React=function(e){function t(o){if(n[o])return n[o].exports;var r=n[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,t),r.l=!0,r.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e['default']}:function(){return e};return t.d(n,'a',n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p='',t(t.s=102)}([function(e){'use strict';var t=function(){};!1,e.exports=function(n,o,r,a,i,p,s,e){if(t(o),!n){var d;if(void 0===o)d=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var l=[r,a,i,p,s,e],u=0;d=new Error(o.replace(/%s/g,function(){return l[u++]})),d.name='Invariant Violation'}throw d.framesToPop=1,d}}},function(e,t,n){'use strict';var o=n(5);e.exports=o},,function(e){'use strict';/* +object-assign +(c) Sindre Sorhus +@license MIT +*/function t(e){if(null===e||e===void 0)throw new TypeError('Object.assign cannot be called with null or undefined');return Object(e)}var n=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable;e.exports=function(){try{if(!Object.assign)return!1;var e=new String('abc');if(e[5]='de','5'===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;10>n;n++)t['_'+String.fromCharCode(n)]=n;var o=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if('0123456789'!==o.join(''))return!1;var r={};return['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t'].forEach(function(e){r[e]=e}),'abcdefghijklmnopqrst'===Object.keys(Object.assign({},r)).join('')}catch(e){return!1}}()?Object.assign:function(e){for(var a,p,d=t(e),l=1;ln;n++)t['_'+String.fromCharCode(n)]=n;var o=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if('0123456789'!==o.join(''))return!1;var a={};return['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t'].forEach(function(e){a[e]=e}),'abcdefghijklmnopqrst'===Object.keys(Object.assign({},a)).join('')}catch(e){return!1}}()?Object.assign:function(e){for(var r=t(e),d=1,s,p;d=h.hasBooleanValue+h.hasNumericValue+h.hasOverloadedBooleanValue?void 0:a('50',u),!1,d.hasOwnProperty(u)){var g=d[u];h.attributeName=g,!1}r.hasOwnProperty(u)&&(h.attributeNamespace=r[u]),p.hasOwnProperty(u)&&(h.propertyName=p[u]),l.hasOwnProperty(u)&&(h.mutationMethod=l[u]),s.properties[u]=h}}},d=':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD',s={ID_ATTRIBUTE_NAME:'data-reactid',ROOT_ATTRIBUTE_NAME:'data-reactroot',ATTRIBUTE_NAME_START_CHAR:d,ATTRIBUTE_NAME_CHAR:d+'\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040',properties:{},getPossibleStandardName:null,_isCustomAttributeFunctions:[],isCustomAttribute:function(e){for(var t=0,n;t]/,d=n(56),s=d(function(e,t){if(e.namespaceURI===a.svg&&!('innerHTML'in e)){p=p||document.createElement('div'),p.innerHTML=''+t+'';for(var n=p.firstChild;n.firstChild;)e.appendChild(n.firstChild)}else e.innerHTML=t}),p;if(o.canUseDOM){var l=document.createElement('div');l.innerHTML=' ',''===l.innerHTML&&(s=function(e,t){if(e.parentNode&&e.parentNode.replaceChild(e,e),r.test(t)||'<'===t[0]&&i.test(t)){e.innerHTML='\uFEFF'+t;var n=e.firstChild;1===n.data.length?e.removeChild(n):n.deleteData(0,1)}else e.innerHTML=t}),l=null}e.exports=s},function(e){'use strict';function t(e){var t=''+e,o=n.exec(t);if(!o)return t;var a='',r=0,i=0,d;for(r=o.index;r]/;e.exports=function(e){return'boolean'==typeof e||'number'==typeof e?''+e:t(e)}},function(e,t,n){'use strict';function o(e){return Object.prototype.hasOwnProperty.call(e,h)||(e[h]=c++,l[e[h]]={}),l[e[h]]}var a=n(3),r=n(48),i=n(133),d=n(74),s=n(134),p=n(52),l={},u=!1,c=0,m={topAbort:'abort',topAnimationEnd:s('animationend')||'animationend',topAnimationIteration:s('animationiteration')||'animationiteration',topAnimationStart:s('animationstart')||'animationstart',topBlur:'blur',topCanPlay:'canplay',topCanPlayThrough:'canplaythrough',topChange:'change',topClick:'click',topCompositionEnd:'compositionend',topCompositionStart:'compositionstart',topCompositionUpdate:'compositionupdate',topContextMenu:'contextmenu',topCopy:'copy',topCut:'cut',topDoubleClick:'dblclick',topDrag:'drag',topDragEnd:'dragend',topDragEnter:'dragenter',topDragExit:'dragexit',topDragLeave:'dragleave',topDragOver:'dragover',topDragStart:'dragstart',topDrop:'drop',topDurationChange:'durationchange',topEmptied:'emptied',topEncrypted:'encrypted',topEnded:'ended',topError:'error',topFocus:'focus',topInput:'input',topKeyDown:'keydown',topKeyPress:'keypress',topKeyUp:'keyup',topLoadedData:'loadeddata',topLoadedMetadata:'loadedmetadata',topLoadStart:'loadstart',topMouseDown:'mousedown',topMouseMove:'mousemove',topMouseOut:'mouseout',topMouseOver:'mouseover',topMouseUp:'mouseup',topPaste:'paste',topPause:'pause',topPlay:'play',topPlaying:'playing',topProgress:'progress',topRateChange:'ratechange',topScroll:'scroll',topSeeked:'seeked',topSeeking:'seeking',topSelectionChange:'selectionchange',topStalled:'stalled',topSuspend:'suspend',topTextInput:'textInput',topTimeUpdate:'timeupdate',topTouchCancel:'touchcancel',topTouchEnd:'touchend',topTouchMove:'touchmove',topTouchStart:'touchstart',topTransitionEnd:s('transitionend')||'transitionend',topVolumeChange:'volumechange',topWaiting:'waiting',topWheel:'wheel'},h='_reactListenersID'+(Math.random()+'').slice(2),g=a({},i,{ReactEventListener:null,injection:{injectReactEventListener:function(e){e.setHandleTopLevel(g.handleTopLevel),g.ReactEventListener=e}},setEnabled:function(e){g.ReactEventListener&&g.ReactEventListener.setEnabled(e)},isEnabled:function(){return!!(g.ReactEventListener&&g.ReactEventListener.isEnabled())},listenTo:function(e,t){for(var n=t,a=o(n),d=r.registrationNameDependencies[e],s=0,i;so.length?n+' (keys: '+o.join(', ')+')':n}function r(e){var t=s.get(e);if(!t){return null}return!1,t}var i=n(2),d=n(8),s=n(27),p=n(9),l=n(11),u=n(0),c=n(1),m={isMounted:function(e){var t=s.get(e);return!!t&&!!t._renderedComponent},enqueueCallback:function(e,t,n){m.validateCallback(t,n);var a=r(e);return a?void(a._pendingCallbacks?a._pendingCallbacks.push(t):a._pendingCallbacks=[t],o(a)):null},enqueueCallbackInternal:function(e,t){e._pendingCallbacks?e._pendingCallbacks.push(t):e._pendingCallbacks=[t],o(e)},enqueueForceUpdate:function(e){var t=r(e,'forceUpdate');t&&(t._pendingForceUpdate=!0,o(t))},enqueueReplaceState:function(e,t,n){var a=r(e,'replaceState');a&&(a._pendingStateQueue=[t],a._pendingReplaceState=!0,n!==void 0&&null!==n&&(m.validateCallback(n,'replaceState'),a._pendingCallbacks?a._pendingCallbacks.push(n):a._pendingCallbacks=[n]),o(a))},enqueueSetState:function(e,t){var n=r(e,'setState');if(n){var a=n._pendingStateQueue||(n._pendingStateQueue=[]);a.push(t),o(n)}},enqueueElementInternal:function(e,t,n){e._pendingElement=t,e._context=n,o(e)},validateCallback:function(e,t){!e||'function'==typeof e?void 0:i('122',t,a(e))}};e.exports=m},function(e,t,n){'use strict';var o=n(3),a=n(5),r=n(1);e.exports=a},function(e){'use strict';e.exports=function(e){var t=e.keyCode,n;return'charCode'in e?(n=e.charCode,0===n&&13===t&&(n=13)):n=t,32<=n||13===n?n:0}},,function(e){'use strict';e.exports={hasCachedChildNodes:1}},function(e,t,n){'use strict';var o=n(2),a=n(0);e.exports=function(e,t){return null==t?o('30'):void 0,null==e?t:Array.isArray(e)?Array.isArray(t)?(e.push.apply(e,t),e):(e.push(t),e):Array.isArray(t)?[e].concat(t):[e,t]}},function(e){'use strict';e.exports=function(e,t,n){Array.isArray(e)?e.forEach(t,n):e&&t.call(n,e)}},function(e,t,n){'use strict';var o=n(6),a=null;e.exports=function(){return!a&&o.canUseDOM&&(a='textContent'in document.documentElement?'textContent':'innerText'),a}},function(e,t,n){'use strict';function o(e,t){if(!(e instanceof t))throw new TypeError('Cannot call a class as a function')}var a=n(2),r=n(15),i=n(0),d=function(){function e(t){o(this,e),this._callbacks=null,this._contexts=null,this._arg=t}return e.prototype.enqueue=function(e,t){this._callbacks=this._callbacks||[],this._callbacks.push(e),this._contexts=this._contexts||[],this._contexts.push(t)},e.prototype.notifyAll=function(){var e=this._callbacks,t=this._contexts,n=this._arg;if(e&&t){e.length===t.length?void 0:a('24'),this._callbacks=null,this._contexts=null;for(var o=0;ot||e.hasOverloadedBooleanValue&&!1===t}var r=n(16),i=n(4),d=n(9),s=n(132),p=n(1),l=new RegExp('^['+r.ATTRIBUTE_NAME_START_CHAR+']['+r.ATTRIBUTE_NAME_CHAR+']*$'),u={},c={},m={createMarkupForID:function(e){return r.ID_ATTRIBUTE_NAME+'='+s(e)},setAttributeForID:function(e,t){e.setAttribute(r.ID_ATTRIBUTE_NAME,t)},createMarkupForRoot:function(){return r.ROOT_ATTRIBUTE_NAME+'=""'},setAttributeForRoot:function(e){e.setAttribute(r.ROOT_ATTRIBUTE_NAME,'')},createMarkupForProperty:function(e,t){var n=r.properties.hasOwnProperty(e)?r.properties[e]:null;if(n){if(a(n,t))return'';var o=n.attributeName;return n.hasBooleanValue||n.hasOverloadedBooleanValue&&!0===t?o+'=""':o+'='+s(t)}return r.isCustomAttribute(e)?null==t?'':e+'='+s(t):null},createMarkupForCustomAttribute:function(e,t){return o(e)&&null!=t?e+'='+s(t):''},setValueForProperty:function(e,t,n){var o=r.properties.hasOwnProperty(t)?r.properties[t]:null;if(o){var i=o.mutationMethod;if(i)i(e,n);else{if(a(o,n))return void this.deleteValueForProperty(e,t);if(o.mustUseProperty)e[o.propertyName]=n;else{var d=o.attributeName,s=o.attributeNamespace;s?e.setAttributeNS(s,d,''+n):o.hasBooleanValue||o.hasOverloadedBooleanValue&&!0===n?e.setAttribute(d,''):e.setAttribute(d,''+n)}}}else if(r.isCustomAttribute(t))return void m.setValueForAttribute(e,t,n)},setValueForAttribute:function(e,t,n){if(o(t)){null==n?e.removeAttribute(t):e.setAttribute(t,''+n)}},deleteValueForAttribute:function(e,t){e.removeAttribute(t),!1},deleteValueForProperty:function(e,t){var n=r.properties.hasOwnProperty(t)?r.properties[t]:null;if(n){var o=n.mutationMethod;if(o)o(e,void 0);else if(n.mustUseProperty){var a=n.propertyName;e[a]=!n.hasBooleanValue&&''}else e.removeAttribute(n.attributeName)}else r.isCustomAttribute(t)&&e.removeAttribute(t)}};e.exports=m},function(e,t,n){'use strict';function o(){if(this._rootNodeID&&this._wrapperState.pendingUpdate){this._wrapperState.pendingUpdate=!1;var e=this._currentElement.props,t=d.getValue(e);null!=t&&a(this,!!e.multiple,t)}}function a(e,t,n){var o=s.getNodeFromInstance(e).options,a,r;if(t){for(a={},r=0;r.':'function'==typeof t?' Instead of passing a class like Foo, pass React.createElement(Foo) or .':null!=t&&void 0!==t.props?' This may be caused by unintentionally loading two independent copies of React.':''),void 0;var i=f.createElement(W,{child:t}),d;if(e){var s=x.get(e);d=s._processChildContext(s._context)}else d=M;var l=c(n);if(l){var u=l._currentElement,h=u.props.child;if(D(h,t)){var g=l._renderedComponent.getPublicInstance(),y=o&&function(){o.call(g)};return H._updateRootComponent(l,i,d,n,y),g}H.unmountComponentAtNode(n)}var _=a(n),C=_&&!!r(_),b=p(n),E=H._renderNewRootComponent(i,n,C&&!l&&!b,d)._renderedComponent.getPublicInstance();return o&&o.call(E),E},render:function(e,t,n){return H._renderSubtreeIntoContainer(null,e,t,n)},unmountComponentAtNode:function(e){void 0,l(e)?void 0:m('40'),!1;var t=c(e);if(!t){var n=p(e),o=1===e.nodeType&&e.hasAttribute(L);return!1,!1}return delete j[t._instance.rootID],I.batchedUpdates(s,t,e,!1),!0},_mountImageIntoNode:function(e,t,n,r,i){if(l(t)?void 0:m('41'),r){var d=a(t);if(T.canReuseMarkup(e,d))return void C.precacheNode(n,d);var s=d.getAttribute(T.CHECKSUM_ATTR_NAME);d.removeAttribute(T.CHECKSUM_ATTR_NAME);var p=d.outerHTML;d.setAttribute(T.CHECKSUM_ATTR_NAME,s);var u=e,c=o(u,p),g=' (client) '+u.substring(c-20,c+20)+'\n (server) '+p.substring(c-20,c+20);t.nodeType===F?m('42',g):void 0,!1}if(t.nodeType===F?m('43'):void 0,i.useCreateElement){for(;t.lastChild;)t.removeChild(t.lastChild);h.insertTreeBefore(t,e,null)}else R(t,e),C.precacheNode(n,t.firstChild)}};e.exports=H},function(e,t,n){'use strict';var o=n(82);e.exports=function(e){for(var t;(t=e._renderedNodeType)===o.COMPOSITE;)e=e._renderedComponent;if(t===o.HOST)return e._renderedComponent;return t===o.EMPTY?null:void 0}},,,,,,,,,,,,function(e,t,n){'use strict';e.exports=n(104)},function(e,t,n){'use strict';var o=n(4),a=n(105),r=n(90),i=n(17),d=n(11),s=n(177),p=n(178),l=n(91),u=n(179),c=n(1);a.inject();var m={findDOMNode:p,render:r.render,unmountComponentAtNode:r.unmountComponentAtNode,version:s,unstable_batchedUpdates:d.batchedUpdates,unstable_renderSubtreeIntoContainer:u};'undefined'!=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&'function'==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.inject&&__REACT_DEVTOOLS_GLOBAL_HOOK__.inject({ComponentTree:{getClosestInstanceFromNode:o.getClosestInstanceFromNode,getNodeFromInstance:function(e){return e._renderedComponent&&(e=l(e)),e?o.getNodeFromInstance(e):null}},Mount:r,Reconciler:i});e.exports=m},function(e,t,n){'use strict';var o=n(106),a=n(107),r=n(111),i=n(114),d=n(115),s=n(116),p=n(117),l=n(123),u=n(4),c=n(148),m=n(149),h=n(150),g=n(151),f=n(152),y=n(154),_=n(155),C=n(161),b=n(162),E=n(163),v=!1;e.exports={inject:function(){v||(v=!0,y.EventEmitter.injectReactEventListener(f),y.EventPluginHub.injectEventPluginOrder(i),y.EventPluginUtils.injectComponentTree(u),y.EventPluginUtils.injectTreeTraversal(m),y.EventPluginHub.injectEventPluginsByName({SimpleEventPlugin:E,EnterLeaveEventPlugin:d,ChangeEventPlugin:r,SelectEventPlugin:b,BeforeInputEventPlugin:a}),y.HostComponent.injectGenericComponentClass(l),y.HostComponent.injectTextComponentClass(h),y.DOMProperty.injectDOMPropertyConfig(o),y.DOMProperty.injectDOMPropertyConfig(s),y.DOMProperty.injectDOMPropertyConfig(C),y.EmptyComponent.injectEmptyComponentFactory(function(e){return new c(e)}),y.Updates.injectReconcileTransaction(_),y.Updates.injectBatchingStrategy(g),y.Component.injectEnvironment(p))}}},function(e){'use strict';e.exports={Properties:{"aria-current":0,"aria-details":0,"aria-disabled":0,"aria-hidden":0,"aria-invalid":0,"aria-keyshortcuts":0,"aria-label":0,"aria-roledescription":0,"aria-autocomplete":0,"aria-checked":0,"aria-expanded":0,"aria-haspopup":0,"aria-level":0,"aria-modal":0,"aria-multiline":0,"aria-multiselectable":0,"aria-orientation":0,"aria-placeholder":0,"aria-pressed":0,"aria-readonly":0,"aria-required":0,"aria-selected":0,"aria-sort":0,"aria-valuemax":0,"aria-valuemin":0,"aria-valuenow":0,"aria-valuetext":0,"aria-atomic":0,"aria-busy":0,"aria-live":0,"aria-relevant":0,"aria-dropeffect":0,"aria-grabbed":0,"aria-activedescendant":0,"aria-colcount":0,"aria-colindex":0,"aria-colspan":0,"aria-controls":0,"aria-describedby":0,"aria-errormessage":0,"aria-flowto":0,"aria-labelledby":0,"aria-owns":0,"aria-posinset":0,"aria-rowcount":0,"aria-rowindex":0,"aria-rowspan":0,"aria-setsize":0},DOMAttributeNames:{},DOMPropertyNames:{}}},function(e,t,n){'use strict';function o(e){return(e.ctrlKey||e.altKey||e.metaKey)&&!(e.ctrlKey&&e.altKey)}function a(e){return'topCompositionStart'===e?T.compositionStart:'topCompositionEnd'===e?T.compositionEnd:'topCompositionUpdate'===e?T.compositionUpdate:void 0}function r(e,t){return'topKeyDown'===e&&t.keyCode===_}function i(e,t){return'topKeyUp'===e?-1!==y.indexOf(t.keyCode):'topKeyDown'===e?t.keyCode!==_:'topKeyPress'==e||'topMouseDown'==e||'topBlur'==e}function d(e){var t=e.detail;return'object'==typeof t&&'data'in t?t.data:null}function s(e,t,n,o){var s,p;if(C?s=a(e):P?i(e,n)&&(s=T.compositionEnd):r(e,n)&&(s=T.compositionStart),!s)return null;v&&(P||s!==T.compositionStart?s===T.compositionEnd&&P&&(p=P.getData()):P=h.getPooled(o));var l=g.getPooled(s,t,n,o);if(p)l.data=p;else{var u=d(n);null!==u&&(l.data=u)}return c.accumulateTwoPhaseDispatches(l),l}function p(e,t){switch(e){case'topCompositionEnd':return d(t);case'topKeyPress':var n=t.which;return n===x?(k=!0,N):null;case'topTextInput':var o=t.data;return o===N&&k?null:o;default:return null;}}function l(e,t){if(P){if('topCompositionEnd'===e||!C&&i(e,t)){var n=P.getData();return h.release(P),P=null,n}return null}return'topPaste'===e?null:'topKeyPress'===e?t.which&&!o(t)?String.fromCharCode(t.which):null:'topCompositionEnd'===e?v?null:t.data:null}function u(e,t,n,o){var a;if(a=E?p(e,n):l(e,n),!a)return null;var r=f.getPooled(T.beforeInput,t,n,o);return r.data=a,c.accumulateTwoPhaseDispatches(r),r}var c=n(24),m=n(6),h=n(108),g=n(109),f=n(110),y=[9,13,27,32],_=229,C=m.canUseDOM&&'CompositionEvent'in window,b=null;m.canUseDOM&&'documentMode'in document&&(b=document.documentMode);var E=m.canUseDOM&&'TextEvent'in window&&!b&&!function(){var e=window.opera;return'object'==typeof e&&'function'==typeof e.version&&12>=parseInt(e.version(),10)}(),v=m.canUseDOM&&(!C||b&&8=b),x=32,N=' ',T={beforeInput:{phasedRegistrationNames:{bubbled:'onBeforeInput',captured:'onBeforeInputCapture'},dependencies:['topCompositionEnd','topKeyPress','topTextInput','topPaste']},compositionEnd:{phasedRegistrationNames:{bubbled:'onCompositionEnd',captured:'onCompositionEndCapture'},dependencies:['topBlur','topCompositionEnd','topKeyDown','topKeyPress','topKeyUp','topMouseDown']},compositionStart:{phasedRegistrationNames:{bubbled:'onCompositionStart',captured:'onCompositionStartCapture'},dependencies:['topBlur','topCompositionStart','topKeyDown','topKeyPress','topKeyUp','topMouseDown']},compositionUpdate:{phasedRegistrationNames:{bubbled:'onCompositionUpdate',captured:'onCompositionUpdateCapture'},dependencies:['topBlur','topCompositionUpdate','topKeyDown','topKeyPress','topKeyUp','topMouseDown']}},k=!1,P=null;e.exports={eventTypes:T,extractEvents:function(e,t,n,o){return[s(e,t,n,o),u(e,t,n,o)]}}},function(e,t,n){'use strict';function o(e){this._root=e,this._startText=this.getText(),this._fallbackText=null}var a=n(3),r=n(15),i=n(69);a(o.prototype,{destructor:function(){this._root=null,this._startText=null,this._fallbackText=null},getText:function(){return'value'in this._root?this._root.value:this._root[i()]},getData:function(){if(this._fallbackText)return this._fallbackText;var e=this._startText,t=e.length,n=this.getText(),o=n.length,a,r;for(a=0;a',''],s=[1,'','
'],p=[3,'','
'],l=[1,'',''],u={"*":[1,'?
','
'],area:[1,'',''],col:[2,'','
'],legend:[1,'
','
'],param:[1,'',''],tr:[2,'','
'],optgroup:d,option:d,caption:s,colgroup:s,tbody:s,tfoot:s,thead:s,td:p,th:p};['circle','clipPath','defs','ellipse','g','image','line','linearGradient','mask','path','pattern','polygon','polyline','radialGradient','rect','stop','text','tspan'].forEach(function(e){u[e]=l,i[e]=!0}),e.exports=function(e){return r?void 0:a(!1),u.hasOwnProperty(e)||(e='*'),i.hasOwnProperty(e)||(r.innerHTML='*'===e?'':'<'+e+'>',i[e]=!r.firstChild),i[e]?u[e]:null}},function(e,t,n){'use strict';var o=n(54),a=n(4);e.exports={dangerouslyProcessChildrenUpdates:function(e,t){var n=a.getNodeFromInstance(e);o.processUpdates(n,t)}}},function(e,t,n){'use strict';function o(e){if(e){var t=e._currentElement._owner||null;if(t){var n=t.getName();if(n)return' This DOM node was rendered by `'+n+'`.'}}return''}function a(e){if('object'==typeof e){if(Array.isArray(e))return'['+e.map(a).join(', ')+']';var t=[];for(var n in e)if(Object.prototype.hasOwnProperty.call(e,n)){var o=/^[a-z$_][\w$_]*$/i.test(n)?n:JSON.stringify(n);t.push(o+': '+a(e[n]))}return'{'+t.join(', ')+'}'}return'string'==typeof e?JSON.stringify(e):'function'==typeof e?'[function object]':e+''}function r(e,t){t&&(ae[e._tag]&&(null==t.children&&null==t.dangerouslySetInnerHTML?void 0:y('137',e._tag,e._currentElement._owner?' Check the render method of '+e._currentElement._owner.getName()+'.':'')),null!=t.dangerouslySetInnerHTML&&(null==t.children?void 0:y('60'),'object'==typeof t.dangerouslySetInnerHTML&&$ in t.dangerouslySetInnerHTML?void 0:y('61')),!1,null==t.style||'object'==typeof t.style?void 0:y('62',o(e)))}function i(e,t,n,o){if(!(o instanceof L)){var a=e._hostContainerInfo,r=a._node&&a._node.nodeType===J,i=r?a._node:a._ownerDocument;z(t,i),o.getReactMountReady().enqueue(d,{inst:e,registrationName:t,listener:n})}}function d(){var e=this;T.putListener(e.inst,e.registrationName,e.listener)}function s(){var e=this;S.postMountWrapper(e)}function p(){var e=this;D.postMountWrapper(e)}function l(){var e=this;w.postMountWrapper(e)}function u(){W.track(this)}function c(){var e=this;e._rootNodeID?void 0:y('63');var t=Y(e);switch(t?void 0:y('64'),e._tag){case'iframe':case'object':e._wrapperState.listeners=[P.trapBubbledEvent('topLoad','load',t)];break;case'video':case'audio':for(var n in e._wrapperState.listeners=[],te)te.hasOwnProperty(n)&&e._wrapperState.listeners.push(P.trapBubbledEvent(n,te[n],t));break;case'source':e._wrapperState.listeners=[P.trapBubbledEvent('topError','error',t)];break;case'img':e._wrapperState.listeners=[P.trapBubbledEvent('topError','error',t),P.trapBubbledEvent('topLoad','load',t)];break;case'form':e._wrapperState.listeners=[P.trapBubbledEvent('topReset','reset',t),P.trapBubbledEvent('topSubmit','submit',t)];break;case'input':case'select':case'textarea':e._wrapperState.listeners=[P.trapBubbledEvent('topInvalid','invalid',t)];}}function m(){R.postUpdateWrapper(this)}function h(e){de.call(ie,e)||(re.test(e)?void 0:y('65',e),ie[e]=!0)}function g(e,t){return 0<=e.indexOf('-')||null!=t.is}function f(e){var t=e.type;h(t),this._currentElement=e,this._tag=t.toLowerCase(),this._namespaceURI=null,this._renderedChildren=null,this._previousStyle=null,this._previousStyleCopy=null,this._hostNode=null,this._hostParent=null,this._rootNodeID=0,this._domID=0,this._hostContainerInfo=null,this._wrapperState=null,this._topLevelWrapper=null,this._flags=0,!1}var y=n(2),_=n(3),C=n(124),b=n(125),E=n(18),v=n(55),x=n(16),N=n(78),T=n(25),k=n(48),P=n(33),I=n(66),M=n(4),S=n(135),w=n(137),R=n(79),D=n(138),A=n(9),O=n(139),L=n(146),U=n(5),F=n(32),V=n(0),j=n(52),B=n(59),W=n(72),H=n(63),q=n(1),K=T.deleteListener,Y=M.getNodeFromInstance,z=P.listenTo,X=k.registrationNameModules,G={string:!0,number:!0},Q='style',$='__html',Z={children:null,dangerouslySetInnerHTML:null,suppressContentEditableWarning:null},J=11,ee={};var te={topAbort:'abort',topCanPlay:'canplay',topCanPlayThrough:'canplaythrough',topDurationChange:'durationchange',topEmptied:'emptied',topEncrypted:'encrypted',topEnded:'ended',topError:'error',topLoadedData:'loadeddata',topLoadedMetadata:'loadedmetadata',topLoadStart:'loadstart',topPause:'pause',topPlay:'play',topPlaying:'playing',topProgress:'progress',topRateChange:'ratechange',topSeeked:'seeked',topSeeking:'seeking',topStalled:'stalled',topSuspend:'suspend',topTimeUpdate:'timeupdate',topVolumeChange:'volumechange',topWaiting:'waiting'},ne={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0},oe={listing:!0,pre:!0,textarea:!0},ae=_({menuitem:!0},ne),re=/^[a-zA-Z][a-zA-Z:_\.\-\d]*$/,ie={},de={}.hasOwnProperty,se=1;f.displayName='ReactDOMComponent',f.Mixin={mountComponent:function(e,t,n,o){this._rootNodeID=se++,this._domID=n._idCounter++,this._hostParent=t,this._hostContainerInfo=n;var a=this._currentElement.props;switch(this._tag){case'audio':case'form':case'iframe':case'img':case'link':case'object':case'source':case'video':this._wrapperState={listeners:null},e.getReactMountReady().enqueue(c,this);break;case'input':S.mountWrapper(this,a,t),a=S.getHostProps(this,a),e.getReactMountReady().enqueue(u,this),e.getReactMountReady().enqueue(c,this);break;case'option':w.mountWrapper(this,a,t),a=w.getHostProps(this,a);break;case'select':R.mountWrapper(this,a,t),a=R.getHostProps(this,a),e.getReactMountReady().enqueue(c,this);break;case'textarea':D.mountWrapper(this,a,t),a=D.getHostProps(this,a),e.getReactMountReady().enqueue(u,this),e.getReactMountReady().enqueue(c,this);}r(this,a);var i,d;null==t?n._tag&&(i=n._namespaceURI,d=n._tag):(i=t._namespaceURI,d=t._tag),(null==i||i===v.svg&&'foreignobject'===d)&&(i=v.html),i===v.html&&('svg'===this._tag?i=v.svg:'math'===this._tag&&(i=v.mathml)),this._namespaceURI=i;var m;if(e.useCreateElement){var h=n._ownerDocument,g;if(!(i===v.html))g=h.createElementNS(i,this._currentElement.type);else if('script'===this._tag){var f=h.createElement('div'),y=this._currentElement.type;f.innerHTML='<'+y+'>',g=f.removeChild(f.firstChild)}else g=a.is?h.createElement(this._currentElement.type,a.is):h.createElement(this._currentElement.type);M.precacheNode(this,g),this._flags|=I.hasCachedChildNodes,this._hostParent||N.setAttributeForRoot(g),this._updateDOMProperties(null,a,e);var _=E(g);this._createInitialChildren(e,a,o,_),m=_}else{var b=this._createOpenTagMarkupAndPutListeners(e,a),x=this._createContentMarkup(e,a,o);m=!x&&ne[this._tag]?b+'/>':b+'>'+x+''}switch(this._tag){case'input':e.getReactMountReady().enqueue(s,this),a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'textarea':e.getReactMountReady().enqueue(p,this),a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'select':a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'button':a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'option':e.getReactMountReady().enqueue(l,this);}return m},_createOpenTagMarkupAndPutListeners:function(e,t){var n='<'+this._currentElement.type;for(var o in t)if(t.hasOwnProperty(o)){var a=t[o];if(null!=a)if(X.hasOwnProperty(o))a&&i(this,o,a,e);else{o==Q&&(a&&(!1,a=this._previousStyleCopy=_({},t.style)),a=b.createMarkupForStyles(a,this));var r=null;null!=this._tag&&g(this._tag,t)?!Z.hasOwnProperty(o)&&(r=N.createMarkupForCustomAttribute(o,a)):r=N.createMarkupForProperty(o,a),r&&(n+=' '+r)}}return e.renderToStaticMarkup?n:(this._hostParent||(n+=' '+N.createMarkupForRoot()),n+=' '+N.createMarkupForID(this._domID),n)},_createContentMarkup:function(e,t,n){var o='',a=t.dangerouslySetInnerHTML;if(null!=a)null!=a.__html&&(o=a.__html);else{var r=G[typeof t.children]?t.children:null,i=null==r?t.children:null;if(null!=r)o=F(r),!1;else if(null!=i){var d=this.mountChildren(i,e,n);o=d.join('')}}return oe[this._tag]&&'\n'===o.charAt(0)?'\n'+o:o},_createInitialChildren:function(e,t,n,o){var a=t.dangerouslySetInnerHTML;if(null!=a)null!=a.__html&&E.queueHTML(o,a.__html);else{var r=G[typeof t.children]?t.children:null,d=null==r?t.children:null;if(null!=r)''!==r&&(!1,E.queueText(o,r));else if(null!=d)for(var s=this.mountChildren(d,e,n),p=0;p=s.length?void 0:r('93'),s=s[0]),i=''+s),null==i&&(i=''),o=i}e._wrapperState={initialValue:''+o,listeners:null,onChange:a.bind(e)}},updateWrapper:function(e){var t=e._currentElement.props,n=s.getNodeFromInstance(e),o=d.getValue(t);if(null!=o){var a=''+o;a!==n.value&&(n.value=a),null==t.defaultValue&&(n.defaultValue=a)}null!=t.defaultValue&&(n.defaultValue=t.defaultValue)},postMountWrapper:function(e){var t=s.getNodeFromInstance(e),n=t.textContent;n===e._wrapperState.initialValue&&(t.value=n)}};e.exports=c},function(e,t,n){'use strict';function o(e,t,n){return{type:'INSERT_MARKUP',content:e,fromIndex:null,fromNode:null,toIndex:n,afterNode:t}}function a(e,t,n){return{type:'MOVE_EXISTING',content:null,fromIndex:e._mountIndex,fromNode:g.getHostNode(e),toIndex:n,afterNode:t}}function r(e,t){return{type:'REMOVE_NODE',content:null,fromIndex:e._mountIndex,fromNode:t,toIndex:null,afterNode:null}}function i(e){return{type:'SET_MARKUP',content:e,fromIndex:null,fromNode:null,toIndex:null,afterNode:null}}function d(e){return{type:'TEXT_CONTENT',content:e,fromIndex:null,fromNode:null,toIndex:null,afterNode:null}}function s(e,t){return t&&(e=e||[],e.push(t)),e}function p(e,t){u.processChildrenUpdates(e,t)}var l=n(2),u=n(58),c=n(27),m=n(9),h=n(8),g=n(17),f=n(140),y=n(5),_=n(145),C=n(0);e.exports={Mixin:{_reconcilerInstantiateChildren:function(e,t,n){return f.instantiateChildren(e,t,n)},_reconcilerUpdateChildren:function(e,t,n,o,a,r){var i=0,d;return d=_(t,i),f.updateChildren(e,d,n,o,a,this,this._hostContainerInfo,r,i),d},mountChildren:function(e,t,n){var o=this._reconcilerInstantiateChildren(e,t,n);this._renderedChildren=o;var a=[],r=0;for(var i in o)if(o.hasOwnProperty(i)){var d=o[i];var s=g.mountComponent(d,t,this,this._hostContainerInfo,n,0);d._mountIndex=r++,a.push(s)}return!1,a},updateTextContent:function(e){var t=this._renderedChildren;for(var n in f.unmountChildren(t,!1),t)t.hasOwnProperty(n)&&l('118');var o=[d(e)];p(this,o)},updateMarkup:function(e){var t=this._renderedChildren;for(var n in f.unmountChildren(t,!1),t)t.hasOwnProperty(n)&&l('118');var o=[i(e)];p(this,o)},updateChildren:function(e,t,n){this._updateChildren(e,t,n)},_updateChildren:function(e,t,n){var o=Math.max,a=this._renderedChildren,r={},i=[],d=this._reconcilerUpdateChildren(a,e,i,r,t,n);if(d||a){var l=null,u=0,c=0,m=0,h=null,f;for(f in d)if(d.hasOwnProperty(f)){var y=a&&a[f],_=d[f];y===_?(l=s(l,this.moveChild(y,h,u,c)),c=o(y._mountIndex,c),y._mountIndex=u):(y&&(c=o(y._mountIndex,c)),l=s(l,this._mountChildAtIndex(_,i[m],h,u,t,n)),m++),u++,h=g.getHostNode(_)}for(f in r)r.hasOwnProperty(f)&&(l=s(l,this._unmountChild(a[f],r[f])));l&&p(this,l),this._renderedChildren=d,!1}},unmountChildren:function(e){var t=this._renderedChildren;f.unmountChildren(t,e),this._renderedChildren=null},moveChild:function(e,t,n,o){if(e._mountIndex'},receiveComponent:function(){},getHostNode:function(){return r.getNodeFromInstance(this)},unmountComponent:function(){r.uncacheNode(this)}}),e.exports=i},function(e,t,n){'use strict';function o(e,t){'_hostNode'in e?void 0:a('33'),'_hostNode'in t?void 0:a('33');for(var n=0,o=e;o;o=o._hostParent)n++;for(var r=0,i=t;i;i=i._hostParent)r++;for(;0'+m+''},receiveComponent:function(e){if(e!==this._currentElement){this._currentElement=e;var t=''+e;if(t!==this._stringText){this._stringText=t;var n=this.getHostNode();r.replaceDelimitedText(n[0],n[1],t)}}},getHostNode:function(){var e=this._commentNodes;if(e)return e;if(!this._closingComment)for(var t=d.getNodeFromInstance(this),n=t.nextSibling;;){if(null==n?o('67',this._domID):void 0,8===n.nodeType&&' /react-text '===n.nodeValue){this._closingComment=n;break}n=n.nextSibling}return e=[this._hostNode,this._closingComment],this._commentNodes=e,e},unmountComponent:function(){this._closingComment=null,this._commentNodes=null,d.uncacheNode(this)}}),e.exports=u},function(e,t,n){'use strict';function o(){this.reinitializeTransaction()}var a=n(3),r=n(11),i=n(29),d=n(5),s={initialize:d,close:r.flushBatchedUpdates.bind(r)},p=[s,{initialize:d,close:function(){u.isBatchingUpdates=!1}}];a(o.prototype,i,{getTransactionWrappers:function(){return p}});var l=new o,u={isBatchingUpdates:!1,batchedUpdates:function(t,n,o,a,r,i){var e=u.isBatchingUpdates;return u.isBatchingUpdates=!0,e?t(n,o,a,r,i):l.perform(t,null,n,o,a,r,i)}};e.exports=u},function(e,t,n){'use strict';function o(e){for(;e._hostParent;)e=e._hostParent;var t=u.getNodeFromInstance(e),n=t.parentNode;return u.getClosestInstanceFromNode(n)}function a(e,t){this.topLevelType=e,this.nativeEvent=t,this.ancestors=[]}function r(e){var t=m(e.nativeEvent),n=u.getClosestInstanceFromNode(t),a=n;do e.ancestors.push(a),a=a&&o(a);while(a);for(var r=0;rt.end?(o=t.end,a=t.start):(o=t.start,a=t.end),n.moveToElementText(e),n.moveStart('character',o),n.setEndPoint('EndToStart',n),n.moveEnd('character',a-o),n.select()}:function(e,t){if(window.getSelection){var n=window.getSelection(),o=e[d()].length,r=a(t.start,o),s=void 0===t.end?r:a(t.end,o);if(!n.extend&&r>s){var p=s;s=r,r=p}var l=i(e,r),u=i(e,s);if(l&&u){var c=document.createRange();c.setStart(l.node,l.offset),n.removeAllRanges(),r>s?(n.addRange(c),n.extend(u.node,u.offset)):(c.setEnd(u.node,u.offset),n.addRange(c))}}}};e.exports=p},function(e){'use strict';function t(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function n(e){for(;e;){if(e.nextSibling)return e.nextSibling;e=e.parentNode}}e.exports=function(e,o){for(var a=t(e),r=0,i=0;a;){if(3===a.nodeType){if(i=r+a.textContent.length,r<=o&&i>=o)return{node:a,offset:o-r};r=i}a=t(n(a))}}},function(e,t,n){'use strict';function o(e,t){return e&&t&&(e===t||!a(e)&&(a(t)?o(e,t.parentNode):'contains'in e?e.contains(t):!!e.compareDocumentPosition&&!!(16&e.compareDocumentPosition(t))))}var a=n(159);e.exports=o},function(e,t,n){'use strict';var o=n(160);e.exports=function(e){return o(e)&&3==e.nodeType}},function(e){'use strict';e.exports=function(e){var t=e?e.ownerDocument||e:document,n=t.defaultView||window;return!!(e&&('function'==typeof n.Node?e instanceof n.Node:'object'==typeof e&&'number'==typeof e.nodeType&&'string'==typeof e.nodeName))}},function(e){'use strict';var t={xlink:'http://www.w3.org/1999/xlink',xml:'http://www.w3.org/XML/1998/namespace'},n={accentHeight:'accent-height',accumulate:0,additive:0,alignmentBaseline:'alignment-baseline',allowReorder:'allowReorder',alphabetic:0,amplitude:0,arabicForm:'arabic-form',ascent:0,attributeName:'attributeName',attributeType:'attributeType',autoReverse:'autoReverse',azimuth:0,baseFrequency:'baseFrequency',baseProfile:'baseProfile',baselineShift:'baseline-shift',bbox:0,begin:0,bias:0,by:0,calcMode:'calcMode',capHeight:'cap-height',clip:0,clipPath:'clip-path',clipRule:'clip-rule',clipPathUnits:'clipPathUnits',colorInterpolation:'color-interpolation',colorInterpolationFilters:'color-interpolation-filters',colorProfile:'color-profile',colorRendering:'color-rendering',contentScriptType:'contentScriptType',contentStyleType:'contentStyleType',cursor:0,cx:0,cy:0,d:0,decelerate:0,descent:0,diffuseConstant:'diffuseConstant',direction:0,display:0,divisor:0,dominantBaseline:'dominant-baseline',dur:0,dx:0,dy:0,edgeMode:'edgeMode',elevation:0,enableBackground:'enable-background',end:0,exponent:0,externalResourcesRequired:'externalResourcesRequired',fill:0,fillOpacity:'fill-opacity',fillRule:'fill-rule',filter:0,filterRes:'filterRes',filterUnits:'filterUnits',floodColor:'flood-color',floodOpacity:'flood-opacity',focusable:0,fontFamily:'font-family',fontSize:'font-size',fontSizeAdjust:'font-size-adjust',fontStretch:'font-stretch',fontStyle:'font-style',fontVariant:'font-variant',fontWeight:'font-weight',format:0,from:0,fx:0,fy:0,g1:0,g2:0,glyphName:'glyph-name',glyphOrientationHorizontal:'glyph-orientation-horizontal',glyphOrientationVertical:'glyph-orientation-vertical',glyphRef:'glyphRef',gradientTransform:'gradientTransform',gradientUnits:'gradientUnits',hanging:0,horizAdvX:'horiz-adv-x',horizOriginX:'horiz-origin-x',ideographic:0,imageRendering:'image-rendering',in:0,in2:0,intercept:0,k:0,k1:0,k2:0,k3:0,k4:0,kernelMatrix:'kernelMatrix',kernelUnitLength:'kernelUnitLength',kerning:0,keyPoints:'keyPoints',keySplines:'keySplines',keyTimes:'keyTimes',lengthAdjust:'lengthAdjust',letterSpacing:'letter-spacing',lightingColor:'lighting-color',limitingConeAngle:'limitingConeAngle',local:0,markerEnd:'marker-end',markerMid:'marker-mid',markerStart:'marker-start',markerHeight:'markerHeight',markerUnits:'markerUnits',markerWidth:'markerWidth',mask:0,maskContentUnits:'maskContentUnits',maskUnits:'maskUnits',mathematical:0,mode:0,numOctaves:'numOctaves',offset:0,opacity:0,operator:0,order:0,orient:0,orientation:0,origin:0,overflow:0,overlinePosition:'overline-position',overlineThickness:'overline-thickness',paintOrder:'paint-order',panose1:'panose-1',pathLength:'pathLength',patternContentUnits:'patternContentUnits',patternTransform:'patternTransform',patternUnits:'patternUnits',pointerEvents:'pointer-events',points:0,pointsAtX:'pointsAtX',pointsAtY:'pointsAtY',pointsAtZ:'pointsAtZ',preserveAlpha:'preserveAlpha',preserveAspectRatio:'preserveAspectRatio',primitiveUnits:'primitiveUnits',r:0,radius:0,refX:'refX',refY:'refY',renderingIntent:'rendering-intent',repeatCount:'repeatCount',repeatDur:'repeatDur',requiredExtensions:'requiredExtensions',requiredFeatures:'requiredFeatures',restart:0,result:0,rotate:0,rx:0,ry:0,scale:0,seed:0,shapeRendering:'shape-rendering',slope:0,spacing:0,specularConstant:'specularConstant',specularExponent:'specularExponent',speed:0,spreadMethod:'spreadMethod',startOffset:'startOffset',stdDeviation:'stdDeviation',stemh:0,stemv:0,stitchTiles:'stitchTiles',stopColor:'stop-color',stopOpacity:'stop-opacity',strikethroughPosition:'strikethrough-position',strikethroughThickness:'strikethrough-thickness',string:0,stroke:0,strokeDasharray:'stroke-dasharray',strokeDashoffset:'stroke-dashoffset',strokeLinecap:'stroke-linecap',strokeLinejoin:'stroke-linejoin',strokeMiterlimit:'stroke-miterlimit',strokeOpacity:'stroke-opacity',strokeWidth:'stroke-width',surfaceScale:'surfaceScale',systemLanguage:'systemLanguage',tableValues:'tableValues',targetX:'targetX',targetY:'targetY',textAnchor:'text-anchor',textDecoration:'text-decoration',textRendering:'text-rendering',textLength:'textLength',to:0,transform:0,u1:0,u2:0,underlinePosition:'underline-position',underlineThickness:'underline-thickness',unicode:0,unicodeBidi:'unicode-bidi',unicodeRange:'unicode-range',unitsPerEm:'units-per-em',vAlphabetic:'v-alphabetic',vHanging:'v-hanging',vIdeographic:'v-ideographic',vMathematical:'v-mathematical',values:0,vectorEffect:'vector-effect',version:0,vertAdvY:'vert-adv-y',vertOriginX:'vert-origin-x',vertOriginY:'vert-origin-y',viewBox:'viewBox',viewTarget:'viewTarget',visibility:0,widths:0,wordSpacing:'word-spacing',writingMode:'writing-mode',x:0,xHeight:'x-height',x1:0,x2:0,xChannelSelector:'xChannelSelector',xlinkActuate:'xlink:actuate',xlinkArcrole:'xlink:arcrole',xlinkHref:'xlink:href',xlinkRole:'xlink:role',xlinkShow:'xlink:show',xlinkTitle:'xlink:title',xlinkType:'xlink:type',xmlBase:'xml:base',xmlns:0,xmlnsXlink:'xmlns:xlink',xmlLang:'xml:lang',xmlSpace:'xml:space',y:0,y1:0,y2:0,yChannelSelector:'yChannelSelector',z:0,zoomAndPan:'zoomAndPan'},o={Properties:{},DOMAttributeNamespaces:{xlinkActuate:t.xlink,xlinkArcrole:t.xlink,xlinkHref:t.xlink,xlinkRole:t.xlink,xlinkShow:t.xlink,xlinkTitle:t.xlink,xlinkType:t.xlink,xmlBase:t.xml,xmlLang:t.xml,xmlSpace:t.xml},DOMAttributeNames:{}};Object.keys(n).forEach(function(e){o.Properties[e]=0,n[e]&&(o.DOMAttributeNames[e]=n[e])}),e.exports=o},function(e,t,n){'use strict';function o(e){if('selectionStart'in e&&s.hasSelectionCapabilities(e))return{start:e.selectionStart,end:e.selectionEnd};if(window.getSelection){var t=window.getSelection();return{anchorNode:t.anchorNode,anchorOffset:t.anchorOffset,focusNode:t.focusNode,focusOffset:t.focusOffset}}if(document.selection){var n=document.selection.createRange();return{parentElement:n.parentElement(),text:n.text,top:n.boundingTop,left:n.boundingLeft}}}function a(e,t){if(_||null==g||g!==l())return null;var n=o(g);if(!y||!c(y,n)){y=n;var a=p.getPooled(h.select,f,e,t);return a.type='select',a.target=g,r.accumulateTwoPhaseDispatches(a),a}return null}var r=n(24),i=n(6),d=n(4),s=n(88),p=n(12),l=n(89),u=n(73),c=n(59),m=i.canUseDOM&&'documentMode'in document&&11>=document.documentMode,h={select:{phasedRegistrationNames:{bubbled:'onSelect',captured:'onSelectCapture'},dependencies:['topBlur','topContextMenu','topFocus','topKeyDown','topKeyUp','topMouseDown','topMouseUp','topSelectionChange']}},g=null,f=null,y=null,_=!1,C=!1;e.exports={eventTypes:h,extractEvents:function(e,t,n,o){if(!C)return null;var r=t?d.getNodeFromInstance(t):window;switch(e){case'topFocus':(u(r)||'true'===r.contentEditable)&&(g=r,f=t,y=null);break;case'topBlur':g=null,f=null,y=null;break;case'topMouseDown':_=!0;break;case'topContextMenu':case'topMouseUp':return _=!1,a(n,o);case'topSelectionChange':if(m)break;case'topKeyDown':case'topKeyUp':return a(n,o);}return null},didPutListener:function(e,t){'onSelect'===t&&(C=!0)}}},function(e,t,n){'use strict';function o(e){return'.'+e._rootNodeID}function a(e){return'button'===e||'input'===e||'select'===e||'textarea'===e}var r=n(2),i=n(87),d=n(24),s=n(4),p=n(164),l=n(165),u=n(12),c=n(166),m=n(167),h=n(30),g=n(169),f=n(170),y=n(171),_=n(26),C=n(172),b=n(5),E=n(64),v=n(0),x={},N={};['abort','animationEnd','animationIteration','animationStart','blur','canPlay','canPlayThrough','click','contextMenu','copy','cut','doubleClick','drag','dragEnd','dragEnter','dragExit','dragLeave','dragOver','dragStart','drop','durationChange','emptied','encrypted','ended','error','focus','input','invalid','keyDown','keyPress','keyUp','load','loadedData','loadedMetadata','loadStart','mouseDown','mouseMove','mouseOut','mouseOver','mouseUp','paste','pause','play','playing','progress','rateChange','reset','scroll','seeked','seeking','stalled','submit','suspend','timeUpdate','touchCancel','touchEnd','touchMove','touchStart','transitionEnd','volumeChange','waiting','wheel'].forEach(function(e){var t=e[0].toUpperCase()+e.slice(1),n='on'+t,o='top'+t,a={phasedRegistrationNames:{bubbled:n,captured:n+'Capture'},dependencies:[o]};x[e]=a,N[o]=a});var T={};e.exports={eventTypes:x,extractEvents:function(e,t,n,o){var a=N[e];if(!a)return null;var i;switch(e){case'topAbort':case'topCanPlay':case'topCanPlayThrough':case'topDurationChange':case'topEmptied':case'topEncrypted':case'topEnded':case'topError':case'topInput':case'topInvalid':case'topLoad':case'topLoadedData':case'topLoadedMetadata':case'topLoadStart':case'topPause':case'topPlay':case'topPlaying':case'topProgress':case'topRateChange':case'topReset':case'topSeeked':case'topSeeking':case'topStalled':case'topSubmit':case'topSuspend':case'topTimeUpdate':case'topVolumeChange':case'topWaiting':i=u;break;case'topKeyPress':if(0===E(n))return null;case'topKeyDown':case'topKeyUp':i=m;break;case'topBlur':case'topFocus':i=c;break;case'topClick':if(2===n.button)return null;case'topDoubleClick':case'topMouseDown':case'topMouseMove':case'topMouseUp':case'topMouseOut':case'topMouseOver':case'topContextMenu':i=h;break;case'topDrag':case'topDragEnd':case'topDragEnter':case'topDragExit':case'topDragLeave':case'topDragOver':case'topDragStart':case'topDrop':i=g;break;case'topTouchCancel':case'topTouchEnd':case'topTouchMove':case'topTouchStart':i=f;break;case'topAnimationEnd':case'topAnimationIteration':case'topAnimationStart':i=p;break;case'topTransitionEnd':i=y;break;case'topScroll':i=_;break;case'topWheel':i=C;break;case'topCopy':case'topCut':case'topPaste':i=l;}i?void 0:r('86',e);var s=i.getPooled(a,t,n,o);return d.accumulateTwoPhaseDispatches(s),s},didPutListener:function(e,t){if('onClick'===t&&!a(e._tag)){var n=o(e),r=s.getNodeFromInstance(e);T[n]||(T[n]=i.listen(r,'click',b))}},willDeleteListener:function(e,t){if('onClick'===t&&!a(e._tag)){var n=o(e);T[n].remove(),delete T[n]}}}},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{animationName:null,elapsedTime:null,pseudoElement:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{clipboardData:function(e){return'clipboardData'in e?e.clipboardData:window.clipboardData}}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26);a.augmentClass(o,{relatedTarget:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26),r=n(64),i=n(168),d=n(53);a.augmentClass(o,{key:i,location:null,ctrlKey:null,shiftKey:null,altKey:null,metaKey:null,repeat:null,locale:null,getModifierState:d,charCode:function(e){return'keypress'===e.type?r(e):0},keyCode:function(e){return'keydown'===e.type||'keyup'===e.type?e.keyCode:0},which:function(e){return'keypress'===e.type?r(e):'keydown'===e.type||'keyup'===e.type?e.keyCode:0}}),e.exports=o},function(e,t,n){'use strict';var o=n(64),a={Esc:'Escape',Spacebar:' ',Left:'ArrowLeft',Up:'ArrowUp',Right:'ArrowRight',Down:'ArrowDown',Del:'Delete',Win:'OS',Menu:'ContextMenu',Apps:'ContextMenu',Scroll:'ScrollLock',MozPrintableKey:'Unidentified'},r={8:'Backspace',9:'Tab',12:'Clear',13:'Enter',16:'Shift',17:'Control',18:'Alt',19:'Pause',20:'CapsLock',27:'Escape',32:' ',33:'PageUp',34:'PageDown',35:'End',36:'Home',37:'ArrowLeft',38:'ArrowUp',39:'ArrowRight',40:'ArrowDown',45:'Insert',46:'Delete',112:'F1',113:'F2',114:'F3',115:'F4',116:'F5',117:'F6',118:'F7',119:'F8',120:'F9',121:'F10',122:'F11',123:'F12',144:'NumLock',145:'ScrollLock',224:'Meta'};e.exports=function(e){if(e.key){var t=a[e.key]||e.key;if('Unidentified'!==t)return t}if('keypress'===e.type){var n=o(e);return 13===n?'Enter':String.fromCharCode(n)}return'keydown'===e.type||'keyup'===e.type?r[e.keyCode]||'Unidentified':''}},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(30);a.augmentClass(o,{dataTransfer:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26),r=n(53);a.augmentClass(o,{touches:null,targetTouches:null,changedTouches:null,altKey:null,metaKey:null,ctrlKey:null,shiftKey:null,getModifierState:r}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{propertyName:null,elapsedTime:null,pseudoElement:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(30);a.augmentClass(o,{deltaX:function(e){return'deltaX'in e?e.deltaX:'wheelDeltaX'in e?-e.wheelDeltaX:0},deltaY:function(e){return'deltaY'in e?e.deltaY:'wheelDeltaY'in e?-e.wheelDeltaY:'wheelDelta'in e?-e.wheelDelta:0},deltaZ:null,deltaMode:null}),e.exports=o},function(e,t,n){'use strict';var o=n(63);e.exports=function(e,t){var n={_topLevelWrapper:e,_idCounter:1,_ownerDocument:t?t.nodeType===9?t:t.ownerDocument:null,_node:t,_tag:t?t.nodeName.toLowerCase():null,_namespaceURI:t?t.namespaceURI:null};return!1,n}},function(e){'use strict';e.exports={useCreateElement:!0,useFiber:!1}},function(e,t,n){'use strict';var o=n(176),a=/\/?>/,r=/^<\!\-\-/,i={CHECKSUM_ATTR_NAME:'data-react-checksum',addChecksumToMarkup:function(e){var t=o(e);return r.test(e)?e:e.replace(a,' '+i.CHECKSUM_ATTR_NAME+'="'+t+'"$&')},canReuseMarkup:function(e,t){var n=t.getAttribute(i.CHECKSUM_ATTR_NAME);n=n&&parseInt(n,10);var a=o(e);return a===n}};e.exports=i},function(e){'use strict';var t=65521;e.exports=function(e){for(var o=1,a=0,r=0,i=e.length,d=-4&i;r