forked from mirrors/gecko-dev
		
	Bug 1440780 - Add Normandy action for add-on studies r=aswan
This ports the code from the Normandy server github repo to run as a local
action, instead of being fetched from the server.
The original code is here:
c0a8c53707/client/actions/opt-out-study
Differential Revision: https://phabricator.services.mozilla.com/D2973
--HG--
extra : moz-landing-system : lando
			
			
This commit is contained in:
		
							parent
							
								
									aa2faf59f3
								
							
						
					
					
						commit
						163557534c
					
				
					 16 changed files with 842 additions and 506 deletions
				
			
		
							
								
								
									
										269
									
								
								toolkit/components/normandy/actions/AddonStudyAction.jsm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								toolkit/components/normandy/actions/AddonStudyAction.jsm
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,269 @@ | |||
| /* 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, | ||||
|  * and uninstalling them when the recipe no longer applies. | ||||
|  */ | ||||
| 
 | ||||
| "use strict"; | ||||
| 
 | ||||
| ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); | ||||
| ChromeUtils.import("resource://normandy/actions/BaseAction.jsm"); | ||||
| 
 | ||||
| XPCOMUtils.defineLazyModuleGetters(this, { | ||||
|   Services: "resource://gre/modules/Services.jsm", | ||||
|   PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", | ||||
|   AddonManager: "resource://gre/modules/AddonManager.jsm", | ||||
|   ActionSchemas: "resource://normandy/actions/schemas/index.js", | ||||
|   AddonStudies: "resource://normandy/lib/AddonStudies.jsm", | ||||
|   TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm", | ||||
| }); | ||||
| 
 | ||||
| var EXPORTED_SYMBOLS = ["AddonStudyAction"]; | ||||
| 
 | ||||
| const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled"; | ||||
| 
 | ||||
| class AddonStudyEnrollError extends Error { | ||||
|   constructor(studyName, reason) { | ||||
|     let message; | ||||
|     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; | ||||
|       } | ||||
|       default: { | ||||
|         throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`); | ||||
|       } | ||||
|     } | ||||
|     super(new Error(`Cannot install study add-on for ${studyName}: ${message}.`)); | ||||
|     this.studyName = studyName; | ||||
|     this.reason = reason; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class AddonStudyAction extends BaseAction { | ||||
|   get schema() { | ||||
|     return ActionSchemas["addon-study"]; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * This hook is executed once before any recipes have been processed, it is | ||||
|    * responsible for: | ||||
|    * | ||||
|    *   - Checking if the user has opted out of studies, and if so, it disables the action. | ||||
|    *   - Setting up tracking of seen recipes, for use in _finalize. | ||||
|    */ | ||||
|   _preExecution() { | ||||
|     // Check opt-out preference
 | ||||
|     if (!Services.prefs.getBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, true)) { | ||||
|       this.log.info("User has opted-out of opt-out experiments, disabling action."); | ||||
|       this.disable(); | ||||
|     } | ||||
| 
 | ||||
|     this.seenRecipeIds = new Set(); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * This hook is executed once for each recipe that currently applies to this | ||||
|    * client. It is responsible for: | ||||
|    * | ||||
|    *   - Enrolling studies the first time they are seen. | ||||
|    *   - Marking studies as having been seen in this session. | ||||
|    * | ||||
|    * If the recipe fails to enroll, it should throw to properly report its status. | ||||
|    */ | ||||
|   async _run(recipe) { | ||||
|     this.seenRecipeIds.add(recipe.id); | ||||
| 
 | ||||
|     const hasStudy = await AddonStudies.has(recipe.id); | ||||
|     if (recipe.arguments.isEnrollmentPaused || hasStudy) { | ||||
|       // Recipe does not need anything done
 | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     await this.enroll(recipe); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * 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() { | ||||
|     const activeStudies = (await AddonStudies.getAll()).filter(study => study.active); | ||||
| 
 | ||||
|     for (const study of activeStudies) { | ||||
|       if (!this.seenRecipeIds.has(study.recipeId)) { | ||||
|         this.log.debug(`Stopping study for recipe ${study.recipeId}`); | ||||
|         try { | ||||
|           await this.unenroll(study.recipeId, "recipe-not-seen"); | ||||
|         } catch (err) { | ||||
|           Cu.reportError(err); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Enroll in the study represented by the given recipe. | ||||
|    * @param recipe Object describing the study to enroll in. | ||||
|    */ | ||||
|   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, assuming 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 we 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.
 | ||||
| 
 | ||||
|     const { addonUrl, name, description } = recipe.arguments; | ||||
| 
 | ||||
|     const downloadDeferred = PromiseUtils.defer(); | ||||
|     const installDeferred = PromiseUtils.defer(); | ||||
| 
 | ||||
|     const install = await AddonManager.getInstallForURL(addonUrl, "application/x-xpinstall"); | ||||
| 
 | ||||
|     const listener = { | ||||
|       onDownloadFailed() { | ||||
|         downloadDeferred.reject(new AddonStudyEnrollError(name, "download-failure")); | ||||
|       }, | ||||
| 
 | ||||
|       onDownloadEnded() { | ||||
|         downloadDeferred.resolve(); | ||||
|         return false; // temporarily pause installation for Normandy bookkeeping
 | ||||
|       }, | ||||
| 
 | ||||
|       onInstallStarted(cbInstall) { | ||||
|         if (cbInstall.existingAddon) { | ||||
|           installDeferred.reject(new AddonStudyEnrollError(name, "conflicting-addon-id")); | ||||
|           return false; // cancel the installation, no upgrades allowed
 | ||||
|         } | ||||
|         return true; | ||||
|       }, | ||||
| 
 | ||||
|       onInstallFailed() { | ||||
|         installDeferred.reject(new AddonStudyEnrollError(name, "failed-to-install")); | ||||
|       }, | ||||
| 
 | ||||
|       onInstallEnded() { | ||||
|         installDeferred.resolve(); | ||||
|       }, | ||||
|     }; | ||||
| 
 | ||||
|     install.addListener(listener); | ||||
| 
 | ||||
|     // Download the add-on
 | ||||
|     try { | ||||
|       install.install(); | ||||
|       await downloadDeferred.promise; | ||||
|     } catch (err) { | ||||
|       this.reportEnrollError(err); | ||||
|       install.removeListener(listener); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const addonId = install.addon.id; | ||||
| 
 | ||||
|     const study = { | ||||
|       recipeId: recipe.id, | ||||
|       name, | ||||
|       description, | ||||
|       addonId, | ||||
|       addonVersion: install.addon.version, | ||||
|       addonUrl, | ||||
|       active: true, | ||||
|       studyStartDate: new Date(), | ||||
|     }; | ||||
| 
 | ||||
|     try { | ||||
|       await AddonStudies.add(study); | ||||
|     } catch (err) { | ||||
|       this.reportEnrollError(err); | ||||
|       install.removeListener(listener); | ||||
|       install.cancel(); | ||||
|       throw err; | ||||
|     } | ||||
| 
 | ||||
|     // finish paused installation
 | ||||
|     try { | ||||
|       install.install(); | ||||
|       await installDeferred.promise; | ||||
|     } catch (err) { | ||||
|       this.reportEnrollError(err); | ||||
|       install.removeListener(listener); | ||||
|       await AddonStudies.delete(recipe.id); | ||||
|       throw err; | ||||
|     } | ||||
| 
 | ||||
|     // All done, report success to Telemetry and cleanup
 | ||||
|     TelemetryEvents.sendEvent("enroll", "addon_study", name, { | ||||
|       addonId: install.addon.id, | ||||
|       addonVersion: install.addon.version, | ||||
|     }); | ||||
| 
 | ||||
|     install.removeListener(listener); | ||||
|   } | ||||
| 
 | ||||
|   reportEnrollError(error) { | ||||
|     if (error instanceof AddonStudyEnrollError) { | ||||
|       // One of our known errors. Report it nicely to telemetry
 | ||||
|       TelemetryEvents.sendEvent("enrollFailed", "addon_study", error.studyName, { reason: error.reason }); | ||||
|     } 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}`; | ||||
|       TelemetryEvents.sendEvent("enrollFailed", "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 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 AddonStudies.markAsEnded(study, reason); | ||||
| 
 | ||||
|     const addon = await 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.`); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -20,17 +20,19 @@ var EXPORTED_SYMBOLS = ["BaseAction"]; | |||
|  */ | ||||
| class BaseAction { | ||||
|   constructor() { | ||||
|     this.finalized = false; | ||||
|     this.failed = false; | ||||
|     this.state = BaseAction.STATE_PREPARING; | ||||
|     this.log = LogManager.getLogger(`action.${this.name}`); | ||||
| 
 | ||||
|     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) { | ||||
|       this.failed = true; | ||||
|       err.message = `Could not initialize action ${this.name}: ${err.message}`; | ||||
|       Cu.reportError(err); | ||||
|       Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR); | ||||
|       this.fail(Uptake.ACTION_PRE_EXECUTION_ERROR); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -41,6 +43,27 @@ class BaseAction { | |||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * 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() { | ||||
|     switch (this.state) { | ||||
|       case BaseAction.STATE_PREPARING: { | ||||
|         Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR); | ||||
|         break; | ||||
|       } | ||||
|       default: { | ||||
|         Cu.reportError(new Error("BaseAction.fail() called at unexpected time")); | ||||
|       } | ||||
|     } | ||||
|     this.state = BaseAction.STATE_FAILED; | ||||
|   } | ||||
| 
 | ||||
|   // Gets the name of the action. Does not necessarily match the
 | ||||
|   // server slug for the action.
 | ||||
|   get name() { | ||||
|  | @ -63,13 +86,13 @@ class BaseAction { | |||
|    * @throws If this action has already been finalized. | ||||
|    */ | ||||
|   async runRecipe(recipe) { | ||||
|     if (this.finalized) { | ||||
|     if (this.state === BaseAction.STATE_FINALIZED) { | ||||
|       throw new Error("Action has already been finalized"); | ||||
|     } | ||||
| 
 | ||||
|     if (this.failed) { | ||||
|     if (this.state !== BaseAction.STATE_READY) { | ||||
|       Uptake.reportRecipe(recipe.id, Uptake.RECIPE_ACTION_DISABLED); | ||||
|       this.log.warn(`Skipping recipe ${recipe.name} because ${this.name} failed during preExecution.`); | ||||
|       this.log.warn(`Skipping recipe ${recipe.name} because ${this.name} was disabled during preExecution.`); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|  | @ -107,24 +130,46 @@ class BaseAction { | |||
|    * recipes will be assumed to have been seen. | ||||
|    */ | ||||
|   async finalize() { | ||||
|     if (this.finalized) { | ||||
|       throw new Error("Action has already been finalized"); | ||||
|     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(); | ||||
|           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}`); | ||||
|           } | ||||
| 
 | ||||
|           Cu.reportError(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}`); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (this.failed) { | ||||
|       this.log.info(`Skipping post-execution hook for ${this.name} due to earlier failure.`); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     let status = Uptake.ACTION_SUCCESS; | ||||
|     try { | ||||
|       await this._finalize(); | ||||
|     } catch (err) { | ||||
|       status = Uptake.ACTION_POST_EXECUTION_ERROR; | ||||
|       err.message = `Could not run postExecution hook for ${this.name}: ${err.message}`; | ||||
|       Cu.reportError(err); | ||||
|     } finally { | ||||
|       this.finalized = true; | ||||
|     this.state = BaseAction.STATE_FINALIZED; | ||||
|     if (status) { | ||||
|       Uptake.reportAction(this.name, status); | ||||
|     } | ||||
|   } | ||||
|  | @ -138,3 +183,9 @@ class BaseAction { | |||
|     // 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"; | ||||
|  |  | |||
|  | @ -61,8 +61,45 @@ const ActionSchemas = { | |||
|       }, | ||||
|     }, | ||||
|   }, | ||||
| 
 | ||||
|   "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" | ||||
|     ], | ||||
|     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 | ||||
|       }, | ||||
|       isEnrollmentPaused: { | ||||
|         description: "If true, new users will not be enrolled in the study.", | ||||
|         type: "boolean", | ||||
|         default: false | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| // 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 */ | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| { | ||||
|   "name": "@mozilla/normandy-action-argument-schemas", | ||||
|   "version": "0.3.0", | ||||
|   "version": "0.4.0", | ||||
|   "description": "Schemas for Normandy action arguments", | ||||
|   "main": "index.js", | ||||
|   "author": "Michael Cooper <mcooper@mozilla.com>", | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { | |||
|   ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm", | ||||
|   NormandyApi: "resource://normandy/lib/NormandyApi.jsm", | ||||
|   Uptake: "resource://normandy/lib/Uptake.jsm", | ||||
|   AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm", | ||||
|   ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm", | ||||
|   PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm", | ||||
|   PreferenceRollbackAction: "resource://normandy/actions/PreferenceRollbackAction.jsm", | ||||
|  | @ -28,10 +29,14 @@ class ActionsManager { | |||
|     this.finalized = false; | ||||
|     this.remoteActionSandboxes = {}; | ||||
| 
 | ||||
|     const addonStudyAction = new AddonStudyAction(); | ||||
| 
 | ||||
|     this.localActions = { | ||||
|       "addon-study": addonStudyAction, | ||||
|       "console-log": new ConsoleLogAction(), | ||||
|       "preference-rollout": new PreferenceRolloutAction(), | ||||
|       "preference-rollback": new PreferenceRollbackAction(), | ||||
|       "opt-out-study": addonStudyAction, // Legacy name used on Normandy server
 | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -28,20 +28,15 @@ | |||
| 
 | ||||
| ChromeUtils.import("resource://gre/modules/osfile.jsm"); | ||||
| ChromeUtils.import("resource://gre/modules/Services.jsm"); | ||||
| ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); | ||||
| 
 | ||||
| ChromeUtils.defineModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm"); | ||||
| ChromeUtils.defineModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm"); | ||||
| ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); | ||||
| ChromeUtils.defineModuleGetter(this, "Addons", "resource://normandy/lib/Addons.jsm"); | ||||
| ChromeUtils.defineModuleGetter( | ||||
|   this, "CleanupManager", "resource://normandy/lib/CleanupManager.jsm" | ||||
| ); | ||||
| ChromeUtils.defineModuleGetter(this, "LogManager", "resource://normandy/lib/LogManager.jsm"); | ||||
| ChromeUtils.defineModuleGetter(this, "TelemetryEvents", "resource://normandy/lib/TelemetryEvents.jsm"); | ||||
| 
 | ||||
| XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]); /* globals fetch */ | ||||
| 
 | ||||
| var EXPORTED_SYMBOLS = ["AddonStudies"]; | ||||
| 
 | ||||
| const DB_NAME = "shield"; | ||||
|  | @ -87,29 +82,6 @@ function getStore(db) { | |||
|   return db.objectStore(STORE_NAME, "readwrite"); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 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 function markAsEnded(db, study, reason) { | ||||
|   if (reason === "unknown") { | ||||
|     log.warn(`Study ${study.name} ending for unknown reason.`); | ||||
|   } | ||||
| 
 | ||||
|   study.active = false; | ||||
|   study.studyEndDate = new Date(); | ||||
|   await getStore(db).put(study); | ||||
| 
 | ||||
|   Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`); | ||||
|   TelemetryEvents.sendEvent("unenroll", "addon_study", study.name, { | ||||
|     addonId: study.addonId, | ||||
|     addonVersion: study.addonVersion, | ||||
|     reason, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| var AddonStudies = { | ||||
|   /** | ||||
|    * Test wrapper that temporarily replaces the stored studies with the given | ||||
|  | @ -151,11 +123,10 @@ var AddonStudies = { | |||
|     // If an active study's add-on has been removed since we last ran, stop the
 | ||||
|     // study.
 | ||||
|     const activeStudies = (await this.getAll()).filter(study => study.active); | ||||
|     const db = await getDatabase(); | ||||
|     for (const study of activeStudies) { | ||||
|       const addon = await AddonManager.getAddonByID(study.addonId); | ||||
|       if (!addon) { | ||||
|         await markAsEnded(db, study, "uninstalled-sideload"); | ||||
|         await this.markAsEnded(study, "uninstalled-sideload"); | ||||
|       } | ||||
|     } | ||||
|     await this.close(); | ||||
|  | @ -178,7 +149,7 @@ var AddonStudies = { | |||
|       // Use a dedicated DB connection instead of the shared one so that we can
 | ||||
|       // close it without fear of affecting other users of the shared connection.
 | ||||
|       const db = await openDatabase(); | ||||
|       await markAsEnded(db, matchingStudy, "uninstalled"); | ||||
|       await this.markAsEnded(matchingStudy, "uninstalled"); | ||||
|       await db.close(); | ||||
|     } | ||||
|   }, | ||||
|  | @ -234,122 +205,45 @@ var AddonStudies = { | |||
|   }, | ||||
| 
 | ||||
|   /** | ||||
|    * Start a new study. Installs an add-on and stores the study info. | ||||
|    * @param {Object} options | ||||
|    * @param {Number} options.recipeId | ||||
|    * @param {String} options.name | ||||
|    * @param {String} options.description | ||||
|    * @param {String} options.addonUrl | ||||
|    * @throws | ||||
|    *   If any of the required options aren't given. | ||||
|    *   If a study for the given recipeID already exists in storage. | ||||
|    *   If add-on installation fails. | ||||
|    * Add a study to storage. | ||||
|    * @return {Promise<void, Error>} Resolves when the study is stored, or rejects with an error. | ||||
|    */ | ||||
|   async start({recipeId, name, description, addonUrl}) { | ||||
|     if (!recipeId || !name || !description || !addonUrl) { | ||||
|       throw new Error("Required arguments (recipeId, name, description, addonUrl) missing."); | ||||
|     } | ||||
| 
 | ||||
|   async add(study) { | ||||
|     const db = await getDatabase(); | ||||
|     if (await getStore(db).get(recipeId)) { | ||||
|       throw new Error(`A study for recipe ${recipeId} already exists.`); | ||||
|     } | ||||
| 
 | ||||
|     let addonFile; | ||||
|     try { | ||||
|       addonFile = await this.downloadAddonToTemporaryFile(addonUrl); | ||||
|       const install = await AddonManager.getInstallForFile(addonFile); | ||||
|       const study = { | ||||
|         recipeId, | ||||
|         name, | ||||
|         description, | ||||
|         addonId: install.addon.id, | ||||
|         addonVersion: install.addon.version, | ||||
|         addonUrl, | ||||
|         active: true, | ||||
|         studyStartDate: new Date(), | ||||
|       }; | ||||
| 
 | ||||
|       await getStore(db).add(study); | ||||
|       await Addons.applyInstall(install, false); | ||||
| 
 | ||||
|       TelemetryEvents.sendEvent("enroll", "addon_study", name, { | ||||
|         addonId: install.addon.id, | ||||
|         addonVersion: install.addon.version, | ||||
|       }); | ||||
| 
 | ||||
|       return study; | ||||
|     } catch (err) { | ||||
|       await getStore(db).delete(recipeId); | ||||
| 
 | ||||
|       // 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 = `${err.fileName}:${err.lineNumber}:${err.columnNumber} ${err.name}`; | ||||
|       TelemetryEvents.sendEvent("enrollFailed", "addon_study", name, { | ||||
|         reason: safeErrorMessage.slice(0, 80),  // max length is 80 chars
 | ||||
|       }); | ||||
| 
 | ||||
|       throw err; | ||||
|     } finally { | ||||
|       if (addonFile) { | ||||
|         Services.obs.notifyObservers(addonFile, "flush-cache-entry"); | ||||
|         await OS.File.remove(addonFile.path); | ||||
|       } | ||||
|     } | ||||
|     return getStore(db).add(study); | ||||
|   }, | ||||
| 
 | ||||
|   /** | ||||
|    * Download a remote add-on and store it in a temporary nsIFile. | ||||
|    * @param {String} addonUrl | ||||
|    * @returns {nsIFile} | ||||
|    * Remove a study from storage | ||||
|    * @param recipeId The recipeId of the study to delete | ||||
|    * @return {Promise<void, Error>} Resolves when the study is deleted, or rejects with an error. | ||||
|    */ | ||||
|   async downloadAddonToTemporaryFile(addonUrl) { | ||||
|     const response = await fetch(addonUrl); | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`Download for ${addonUrl} failed: ${response.status} ${response.statusText}`); | ||||
|     } | ||||
| 
 | ||||
|     // Create temporary file to store add-on.
 | ||||
|     const path = OS.Path.join(OS.Constants.Path.tmpDir, "study.xpi"); | ||||
|     const {file, path: uniquePath} = await OS.File.openUnique(path); | ||||
| 
 | ||||
|     // Write the add-on to the file
 | ||||
|     try { | ||||
|       const xpiArrayBufferView = new Uint8Array(await response.arrayBuffer()); | ||||
|       await file.write(xpiArrayBufferView); | ||||
|     } finally { | ||||
|       await file.close(); | ||||
|     } | ||||
| 
 | ||||
|     return new FileUtils.File(uniquePath); | ||||
|   async delete(recipeId) { | ||||
|     const db = await getDatabase(); | ||||
|     return getStore(db).delete(recipeId); | ||||
|   }, | ||||
| 
 | ||||
|   /** | ||||
|    * Stop an active study, uninstalling the associated add-on. | ||||
|    * @param {Number} recipeId | ||||
|    * @param {String} reason Why the study is ending. Optional, defaults to "unknown". | ||||
|    * @throws | ||||
|    *   If no study is found with the given recipeId. | ||||
|    *   If the study is already inactive. | ||||
|    * 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 stop(recipeId, reason = "unknown") { | ||||
|   async markAsEnded(study, reason) { | ||||
|     if (reason === "unknown") { | ||||
|       log.warn(`Study ${study.name} ending for unknown reason.`); | ||||
|     } | ||||
| 
 | ||||
|     study.active = false; | ||||
|     study.studyEndDate = new Date(); | ||||
|     const db = await getDatabase(); | ||||
|     const study = await getStore(db).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 getStore(db).put(study); | ||||
| 
 | ||||
|     await markAsEnded(db, study, reason); | ||||
| 
 | ||||
|     try { | ||||
|       await Addons.uninstall(study.addonId); | ||||
|     } catch (err) { | ||||
|       log.warn(`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}:`, err); | ||||
|     } | ||||
|     Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`); | ||||
|     TelemetryEvents.sendEvent("unenroll", "addon_study", study.name, { | ||||
|       addonId: study.addonId, | ||||
|       addonVersion: study.addonVersion, | ||||
|       reason, | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
|  |  | |||
|  | @ -1,118 +0,0 @@ | |||
| /* 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"; | ||||
| 
 | ||||
| ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); | ||||
| 
 | ||||
| var EXPORTED_SYMBOLS = ["Addons"]; | ||||
| 
 | ||||
| /** | ||||
|  * SafeAddons store info about an add-on. They are single-depth | ||||
|  * objects to simplify cloning, and have no methods so they are safe | ||||
|  * to pass to sandboxes and filter expressions. | ||||
|  * | ||||
|  * @typedef {Object} SafeAddon | ||||
|  * @property {string} id | ||||
|  *   Add-on id, such as "shield-recipe-client@mozilla.com" or "{4ea51ac2-adf2-4af8-a69d-17b48c558a12}" | ||||
|  * @property {Date} installDate | ||||
|  * @property {boolean} isActive | ||||
|  * @property {string} name | ||||
|  * @property {string} type | ||||
|  *   "extension", "theme", etc. | ||||
|  * @property {string} version | ||||
|  */ | ||||
| 
 | ||||
| var Addons = { | ||||
|   /** | ||||
|    * Get information about an installed add-on by ID. | ||||
|    * | ||||
|    * @param {string} addonId | ||||
|    * @returns {SafeAddon?} Add-on with given ID, or null if not found. | ||||
|    * @throws If addonId is not specified or not a string. | ||||
|    */ | ||||
|   async get(addonId) { | ||||
|     const addon = await AddonManager.getAddonByID(addonId); | ||||
|     if (!addon) { | ||||
|       return null; | ||||
|     } | ||||
|     return this.serializeForSandbox(addon); | ||||
|   }, | ||||
| 
 | ||||
|   /** | ||||
|    * Installs an add-on | ||||
|    * | ||||
|    * @param {string} addonUrl | ||||
|    *   Url to download the .xpi for the add-on from. | ||||
|    * @param {object} options | ||||
|    * @param {boolean} options.update=false | ||||
|    *   If true, will update an existing installed add-on with the same ID. | ||||
|    * @async | ||||
|    * @returns {string} | ||||
|    *   Add-on ID that was installed | ||||
|    * @throws {string} | ||||
|    *   If the add-on can not be installed, or overwriting is disabled and an | ||||
|    *   add-on with a matching ID is already installed. | ||||
|    */ | ||||
|   async install(addonUrl, options) { | ||||
|     const installObj = await AddonManager.getInstallForURL(addonUrl, "application/x-xpinstall"); | ||||
|     return this.applyInstall(installObj, options); | ||||
|   }, | ||||
| 
 | ||||
|   async applyInstall(addonInstall, {update = false} = {}) { | ||||
|     const result = new Promise((resolve, reject) => addonInstall.addListener({ | ||||
|       onInstallStarted(cbInstall) { | ||||
|         if (cbInstall.existingAddon && !update) { | ||||
|           reject(new Error(` | ||||
|             Cannot install add-on ${cbInstall.addon.id}; an existing add-on | ||||
|             with the same ID exists and updating is disabled. | ||||
|           `));
 | ||||
|           return false; | ||||
|         } | ||||
|         return true; | ||||
|       }, | ||||
|       onInstallEnded(cbInstall, addon) { | ||||
|         resolve(addon.id); | ||||
|       }, | ||||
|       onInstallFailed(cbInstall) { | ||||
|         reject(new Error(`AddonInstall error code: [${cbInstall.error}]`)); | ||||
|       }, | ||||
|       onDownloadFailed(cbInstall) { | ||||
|         reject(new Error(`Download failed: [${cbInstall.sourceURI.spec}]`)); | ||||
|       }, | ||||
|     })); | ||||
|     addonInstall.install(); | ||||
|     return result; | ||||
|   }, | ||||
| 
 | ||||
|   /** | ||||
|    * Uninstalls an add-on by ID. | ||||
|    * @param addonId {string} Add-on ID to uninstall. | ||||
|    * @async | ||||
|    * @throws If no add-on with `addonId` is installed. | ||||
|    */ | ||||
|   async uninstall(addonId) { | ||||
|     const addon = await AddonManager.getAddonByID(addonId); | ||||
|     if (addon === null) { | ||||
|       throw new Error(`No addon with ID [${addonId}] found.`); | ||||
|     } | ||||
|     await addon.uninstall(); | ||||
|     return null; | ||||
|   }, | ||||
| 
 | ||||
|   /** | ||||
|    * Make a safe serialization of an add-on | ||||
|    * @param addon {Object} An add-on object as returned from AddonManager. | ||||
|    */ | ||||
|   serializeForSandbox(addon) { | ||||
|     return { | ||||
|       id: addon.id, | ||||
|       installDate: new Date(addon.installDate), | ||||
|       isActive: addon.isActive, | ||||
|       name: addon.name, | ||||
|       type: addon.type, | ||||
|       version: addon.version, | ||||
|     }; | ||||
|   }, | ||||
| }; | ||||
|  | @ -9,7 +9,6 @@ ChromeUtils.import("resource://gre/modules/Preferences.jsm"); | |||
| ChromeUtils.import("resource:///modules/ShellService.jsm"); | ||||
| ChromeUtils.import("resource://gre/modules/AddonManager.jsm"); | ||||
| ChromeUtils.import("resource://gre/modules/Timer.jsm"); | ||||
| ChromeUtils.import("resource://normandy/lib/Addons.jsm"); | ||||
| ChromeUtils.import("resource://normandy/lib/LogManager.jsm"); | ||||
| ChromeUtils.import("resource://normandy/lib/Storage.jsm"); | ||||
| ChromeUtils.import("resource://normandy/lib/Heartbeat.jsm"); | ||||
|  | @ -19,8 +18,6 @@ ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm"); | |||
| ChromeUtils.defineModuleGetter( | ||||
|   this, "Sampling", "resource://gre/modules/components-utils/Sampling.jsm"); | ||||
| ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm"); | ||||
| ChromeUtils.defineModuleGetter( | ||||
|   this, "AddonStudies", "resource://normandy/lib/AddonStudies.jsm"); | ||||
| 
 | ||||
| const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); | ||||
| 
 | ||||
|  | @ -157,12 +154,6 @@ var NormandyDriver = function(sandboxManager) { | |||
|       sandboxManager.removeHold(`setTimeout-${token}`); | ||||
|     }, | ||||
| 
 | ||||
|     addons: { | ||||
|       get: sandboxManager.wrapAsync(Addons.get.bind(Addons), {cloneInto: true}), | ||||
|       install: sandboxManager.wrapAsync(Addons.install.bind(Addons)), | ||||
|       uninstall: sandboxManager.wrapAsync(Addons.uninstall.bind(Addons)), | ||||
|     }, | ||||
| 
 | ||||
|     // Sampling
 | ||||
|     ratioSample: sandboxManager.wrapAsync(Sampling.ratioSample), | ||||
| 
 | ||||
|  | @ -187,18 +178,6 @@ var NormandyDriver = function(sandboxManager) { | |||
|       has: sandboxManager.wrapAsync(PreferenceExperiments.has.bind(PreferenceExperiments)), | ||||
|     }, | ||||
| 
 | ||||
|     // Study storage API
 | ||||
|     studies: { | ||||
|       start: sandboxManager.wrapAsync( | ||||
|         AddonStudies.start.bind(AddonStudies), | ||||
|         {cloneArguments: true, cloneInto: true} | ||||
|       ), | ||||
|       stop: sandboxManager.wrapAsync(AddonStudies.stop.bind(AddonStudies)), | ||||
|       get: sandboxManager.wrapAsync(AddonStudies.get.bind(AddonStudies), {cloneInto: true}), | ||||
|       getAll: sandboxManager.wrapAsync(AddonStudies.getAll.bind(AddonStudies), {cloneInto: true}), | ||||
|       has: sandboxManager.wrapAsync(AddonStudies.has.bind(AddonStudies)), | ||||
|     }, | ||||
| 
 | ||||
|     // Preference read-only API
 | ||||
|     preferences: { | ||||
|       getBool: wrapPrefGetter(Services.prefs.getBoolPref), | ||||
|  |  | |||
|  | @ -3,14 +3,14 @@ | |||
|  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | ||||
| "use strict"; | ||||
| 
 | ||||
| ChromeUtils.import("resource://gre/modules/Services.jsm"); | ||||
| ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); | ||||
| 
 | ||||
| ChromeUtils.defineModuleGetter( | ||||
|   this, "AddonStudies", "resource://normandy/lib/AddonStudies.jsm" | ||||
| ); | ||||
| ChromeUtils.defineModuleGetter( | ||||
|   this, "CleanupManager", "resource://normandy/lib/CleanupManager.jsm" | ||||
| ); | ||||
| XPCOMUtils.defineLazyModuleGetters(this, { | ||||
|   Services: "resource://gre/modules/Services.jsm", | ||||
|   AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm", | ||||
|   AddonStudies: "resource://normandy/lib/AddonStudies.jsm", | ||||
|   CleanupManager: "resource://normandy/lib/CleanupManager.jsm", | ||||
| }); | ||||
| 
 | ||||
| var EXPORTED_SYMBOLS = ["ShieldPreferences"]; | ||||
| 
 | ||||
|  | @ -24,6 +24,7 @@ var ShieldPreferences = { | |||
|   init() { | ||||
|     // Watch for changes to the Opt-out pref
 | ||||
|     Services.prefs.addObserver(PREF_OPT_OUT_STUDIES_ENABLED, this); | ||||
| 
 | ||||
|     CleanupManager.addCleanupHandler(() => { | ||||
|       Services.prefs.removeObserver(PREF_OPT_OUT_STUDIES_ENABLED, this); | ||||
|     }); | ||||
|  | @ -44,9 +45,14 @@ var ShieldPreferences = { | |||
|       case PREF_OPT_OUT_STUDIES_ENABLED: { | ||||
|         prefValue = Services.prefs.getBoolPref(PREF_OPT_OUT_STUDIES_ENABLED); | ||||
|         if (!prefValue) { | ||||
|           const action = new AddonStudyAction(); | ||||
|           for (const study of await AddonStudies.getAll()) { | ||||
|             if (study.active) { | ||||
|               await AddonStudies.stop(study.recipeId, "general-opt-out"); | ||||
|               try { | ||||
|                 await action.unenroll(study.recipeId, "general-opt-out"); | ||||
|               } catch (err) { | ||||
|                 Cu.reportError(err); | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|  |  | |||
|  | @ -8,12 +8,12 @@ head = head.js | |||
| skip-if = !healthreport || !telemetry | ||||
| [browser_about_studies.js] | ||||
| skip-if = true # bug 1442712 | ||||
| [browser_actions_AddonStudyAction.js] | ||||
| [browser_actions_ConsoleLogAction.js] | ||||
| [browser_actions_PreferenceRolloutAction.js] | ||||
| [browser_actions_PreferenceRollbackAction.js] | ||||
| [browser_ActionSandboxManager.js] | ||||
| [browser_ActionsManager.js] | ||||
| [browser_Addons.js] | ||||
| [browser_AddonStudies.js] | ||||
| skip-if = (verify && (os == 'linux')) | ||||
| [browser_BaseAction.js] | ||||
|  |  | |||
|  | @ -4,23 +4,21 @@ ChromeUtils.import("resource://normandy/actions/BaseAction.jsm", this); | |||
| ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this); | ||||
| 
 | ||||
| class NoopAction extends BaseAction { | ||||
|   _run(recipe) { | ||||
|     // does nothing
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FailPreExecutionAction extends BaseAction { | ||||
|   constructor() { | ||||
|     super(); | ||||
|     // this._testPreExecutionFlag is set by _preExecution, called in the constructor
 | ||||
|     if (this._testPreExecutionFlag === undefined) { | ||||
|       this._testPreExecutionFlag = false; | ||||
|     } | ||||
|     this._testRunFlag = false; | ||||
|     this._testFinalizeFlag = false; | ||||
|   } | ||||
| 
 | ||||
|   _preExecution() { | ||||
|     throw new Error("Test error"); | ||||
|     this._testPreExecutionFlag = true; | ||||
|   } | ||||
| 
 | ||||
|   _run() { | ||||
|   _run(recipe) { | ||||
|     this._testRunFlag = true; | ||||
|   } | ||||
| 
 | ||||
|  | @ -29,41 +27,43 @@ class FailPreExecutionAction extends BaseAction { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| class FailRunAction extends BaseAction { | ||||
|   constructor() { | ||||
|     super(); | ||||
|     this._testRunFlag = false; | ||||
|     this._testFinalizeFlag = false; | ||||
|   } | ||||
| 
 | ||||
|   _run(recipe) { | ||||
| class FailPreExecutionAction extends NoopAction { | ||||
|   _preExecution() { | ||||
|     throw new Error("Test error"); | ||||
|   } | ||||
| 
 | ||||
|   _finalize() { | ||||
|     this._testFinalizeFlag = true; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FailFinalizeAction extends BaseAction { | ||||
| class FailRunAction extends NoopAction { | ||||
|   _run(recipe) { | ||||
|     // does nothing
 | ||||
|     throw new Error("Test error"); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FailFinalizeAction extends NoopAction { | ||||
|   _finalize() { | ||||
|     throw new Error("Test error"); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| let _recipeId = 1; | ||||
| function recipeFactory(overrides) { | ||||
|   let defaults = { | ||||
|     id: _recipeId++, | ||||
|     arguments: {}, | ||||
|   }; | ||||
|   Object.assign(defaults, overrides); | ||||
|   return defaults; | ||||
| } | ||||
| // Test that constructor and override methods are run
 | ||||
| decorate_task( | ||||
|   withStub(Uptake, "reportRecipe"), | ||||
|   withStub(Uptake, "reportAction"), | ||||
|   async () => { | ||||
|     const action = new NoopAction(); | ||||
|     is(action._testPreExecutionFlag, true, "_preExecution should be 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.runRecipe(recipe); | ||||
|     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"); | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| // Test that per-recipe uptake telemetry is recorded
 | ||||
| decorate_task( | ||||
|  | @ -86,7 +86,7 @@ decorate_task( | |||
|   async function(reportActionStub) { | ||||
|     const action = new NoopAction(); | ||||
|     await action.finalize(); | ||||
|     ok(action.finalized, "Action should be marked as finalized"); | ||||
|     ok(action.state == NoopAction.STATE_FINALIZED, "Action should be marked as finalized"); | ||||
|     Assert.deepEqual( | ||||
|       reportActionStub.args, | ||||
|       [[action.name, Uptake.ACTION_SUCCESS]], | ||||
|  | @ -127,16 +127,18 @@ decorate_task( | |||
|   async function(reportRecipeStub, reportActionStub) { | ||||
|     const recipe = recipeFactory(); | ||||
|     const action = new FailPreExecutionAction(); | ||||
|     ok(action.failed, "Action should fail during pre-execution fail"); | ||||
|     is(action.state, FailPreExecutionAction.STATE_FAILED, "Action should fail during pre-execution fail"); | ||||
| 
 | ||||
|     // Should not throw, even though the action is in a failed state.
 | ||||
|     // Should not throw, even though the action is in a disabled state.
 | ||||
|     await action.runRecipe(recipe); | ||||
|     is(action.state, FailPreExecutionAction.STATE_FAILED, "Action should remain failed"); | ||||
| 
 | ||||
|     // Should not throw, even though the action is in a failed state.
 | ||||
|     // 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._testRunFlag, false, "_run should not have been caled"); | ||||
|     is(action._testFinalizeFlag, false, "_finalize should not have been caled"); | ||||
|     is(action._testRunFlag, false, "_run should not have been called"); | ||||
|     is(action._testFinalizeFlag, false, "_finalize should not have been called"); | ||||
| 
 | ||||
|     Assert.deepEqual( | ||||
|       reportRecipeStub.args, | ||||
|  | @ -160,8 +162,9 @@ decorate_task( | |||
|     const recipe = recipeFactory(); | ||||
|     const action = new FailRunAction(); | ||||
|     await action.runRecipe(recipe); | ||||
|     is(action.state, FailRunAction.STATE_READY, "Action should not be marked as failed due to a recipe failure"); | ||||
|     await action.finalize(); | ||||
|     ok(!action.failed, "Action should not be marked as failed due to a recipe failure"); | ||||
|     is(action.state, FailRunAction.STATE_FINALIZED, "Action should be marked as finalized after finalize is called"); | ||||
| 
 | ||||
|     ok(action._testFinalizeFlag, "_finalize should have been called"); | ||||
| 
 | ||||
|  | @ -202,3 +205,37 @@ decorate_task( | |||
|     ); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // 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.runRecipe(recipe); | ||||
| 
 | ||||
|     // 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.id, Uptake.RECIPE_ACTION_DISABLED]], | ||||
|       "Recipe should report recipe status as action disabled", | ||||
|     ); | ||||
|   }, | ||||
| ); | ||||
|  |  | |||
|  | @ -105,7 +105,9 @@ add_task(async function testExperiments() { | |||
| add_task(withDriver(Assert, async function testAddonsInContext(driver) { | ||||
|   // Create before install so that the listener is added before startup completes.
 | ||||
|   const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com"); | ||||
|   const addonId = await driver.addons.install(TEST_XPI_URL); | ||||
|   const addonInstall = await AddonManager.getInstallForURL(TEST_XPI_URL, "application/x-xpinstall"); | ||||
|   await addonInstall.install(); | ||||
|   const addonId = addonInstall.addon.id; | ||||
|   await startupPromise; | ||||
| 
 | ||||
|   const addons = await ClientEnvironment.addons; | ||||
|  | @ -118,7 +120,8 @@ add_task(withDriver(Assert, async function testAddonsInContext(driver) { | |||
|     type: "extension", | ||||
|   }, "addons should be available in context"); | ||||
| 
 | ||||
|   await driver.addons.uninstall(addonId); | ||||
|   const addon = await AddonManager.getAddonByID(addonId); | ||||
|   await addon.uninstall(); | ||||
| })); | ||||
| 
 | ||||
| add_task(async function isFirstRun() { | ||||
|  |  | |||
|  | @ -1,8 +1,6 @@ | |||
| "use strict"; | ||||
| 
 | ||||
| ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); | ||||
| ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this); | ||||
| 
 | ||||
|  | @ -16,49 +14,6 @@ add_task(withDriver(Assert, async function uuids(driver) { | |||
|   isnot(uuid1, uuid2, "uuids are unique"); | ||||
| })); | ||||
| 
 | ||||
| add_task(withDriver(Assert, async function installXpi(driver) { | ||||
|   // Test that we can install an XPI from any URL
 | ||||
|   // Create before install so that the listener is added before startup completes.
 | ||||
|   const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com"); | ||||
| 
 | ||||
|   var addonId = await driver.addons.install(TEST_XPI_URL); | ||||
|   is(addonId, "normandydriver@example.com", "Expected test addon was installed"); | ||||
|   isnot(addonId, null, "Addon install was successful"); | ||||
| 
 | ||||
|   // Wait until the add-on is fully started up to uninstall it.
 | ||||
|   await startupPromise; | ||||
| 
 | ||||
|   const uninstallMsg = await driver.addons.uninstall(addonId); | ||||
|   is(uninstallMsg, null, `Uninstall returned an unexpected message [${uninstallMsg}]`); | ||||
| })); | ||||
| 
 | ||||
| add_task(withDriver(Assert, async function uninstallInvalidAddonId(driver) { | ||||
|   const invalidAddonId = "not_a_valid_xpi_id@foo.bar"; | ||||
|   try { | ||||
|     await driver.addons.uninstall(invalidAddonId); | ||||
|     ok(false, `Uninstalling an invalid XPI should fail. addons.uninstall resolved successfully though.`); | ||||
|   } catch (e) { | ||||
|     ok(true, `This is the expected failure`); | ||||
|   } | ||||
| })); | ||||
| 
 | ||||
| 
 | ||||
| add_task(withDriver(Assert, async function installXpiBadURL(driver) { | ||||
|   let xpiUrl; | ||||
|   if (AppConstants.platform === "win") { | ||||
|     xpiUrl = "file:///C:/invalid_xpi.xpi"; | ||||
|   } else { | ||||
|     xpiUrl = "file:///tmp/invalid_xpi.xpi"; | ||||
|   } | ||||
| 
 | ||||
|   try { | ||||
|     await driver.addons.install(xpiUrl); | ||||
|     ok(false, "Installation succeeded on an XPI that doesn't exist"); | ||||
|   } catch (reason) { | ||||
|     ok(true, `Installation was rejected: [${reason}]`); | ||||
|   } | ||||
| })); | ||||
| 
 | ||||
| add_task(withDriver(Assert, async function userId(driver) { | ||||
|   // Test that userId is a UUID
 | ||||
|   ok(UUID_REGEX.test(driver.userId), "userId is a uuid"); | ||||
|  | @ -131,109 +86,6 @@ decorate_task( | |||
|   } | ||||
| ); | ||||
| 
 | ||||
| add_task(withDriver(Assert, async function getAddon(driver, sandboxManager) { | ||||
|   const ADDON_ID = "normandydriver@example.com"; | ||||
|   let addon = await driver.addons.get(ADDON_ID); | ||||
|   Assert.equal(addon, null, "Add-on is not yet installed"); | ||||
| 
 | ||||
|   await driver.addons.install(TEST_XPI_URL); | ||||
|   addon = await driver.addons.get(ADDON_ID); | ||||
| 
 | ||||
|   Assert.notEqual(addon, null, "Add-on object was returned"); | ||||
|   ok(addon.installDate instanceof sandboxManager.sandbox.Date, "installDate should be a Date object"); | ||||
| 
 | ||||
|   Assert.deepEqual(addon, { | ||||
|     id: "normandydriver@example.com", | ||||
|     name: "normandy_fixture", | ||||
|     version: "1.0", | ||||
|     installDate: addon.installDate, | ||||
|     isActive: true, | ||||
|     type: "extension", | ||||
|   }, "Add-on is installed"); | ||||
| 
 | ||||
|   await driver.addons.uninstall(ADDON_ID); | ||||
|   addon = await driver.addons.get(ADDON_ID); | ||||
| 
 | ||||
|   Assert.equal(addon, null, "Add-on has been uninstalled"); | ||||
| })); | ||||
| 
 | ||||
| decorate_task( | ||||
|   withSandboxManager(Assert), | ||||
|   async function testAddonsGetWorksInSandbox(sandboxManager) { | ||||
|     const driver = new NormandyDriver(sandboxManager); | ||||
|     sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true}); | ||||
| 
 | ||||
|     // Assertion helpers
 | ||||
|     sandboxManager.addGlobal("is", is); | ||||
|     sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args)); | ||||
| 
 | ||||
|     const ADDON_ID = "normandydriver@example.com"; | ||||
| 
 | ||||
|     await driver.addons.install(TEST_XPI_URL); | ||||
| 
 | ||||
|     await sandboxManager.evalInSandbox(` | ||||
|       (async function sandboxTest() { | ||||
|         const addon = await driver.addons.get("${ADDON_ID}"); | ||||
| 
 | ||||
|         deepEqual(addon, { | ||||
|           id: "${ADDON_ID}", | ||||
|           name: "normandy_fixture", | ||||
|           version: "1.0", | ||||
|           installDate: addon.installDate, | ||||
|           isActive: true, | ||||
|           type: "extension", | ||||
|         }, "Add-on is accesible in the driver"); | ||||
|       })(); | ||||
|     `);
 | ||||
| 
 | ||||
|     await driver.addons.uninstall(ADDON_ID); | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| decorate_task( | ||||
|   withSandboxManager(Assert), | ||||
|   withWebExtension({id: "driver-addon-studies@example.com"}), | ||||
|   AddonStudies.withStudies(), | ||||
|   async function testAddonStudies(sandboxManager, [addonId, addonFile]) { | ||||
|     const addonUrl = Services.io.newFileURI(addonFile).spec; | ||||
|     const driver = new NormandyDriver(sandboxManager); | ||||
|     sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true}); | ||||
| 
 | ||||
|     // Assertion helpers
 | ||||
|     sandboxManager.addGlobal("is", is); | ||||
|     sandboxManager.addGlobal("ok", ok); | ||||
| 
 | ||||
|     await sandboxManager.evalInSandbox(` | ||||
|       (async function sandboxTest() { | ||||
|         const recipeId = 5; | ||||
|         let hasStudy = await driver.studies.has(recipeId); | ||||
|         ok(!hasStudy, "studies.has returns false if the study hasn't been started yet."); | ||||
| 
 | ||||
|         await driver.studies.start({ | ||||
|           recipeId, | ||||
|           name: "fake", | ||||
|           description: "fake", | ||||
|           addonUrl: "${addonUrl}", | ||||
|         }); | ||||
|         hasStudy = await driver.studies.has(recipeId); | ||||
|         ok(hasStudy, "studies.has returns true after the study has been started."); | ||||
| 
 | ||||
|         let study = await driver.studies.get(recipeId); | ||||
|         is( | ||||
|           study.addonId, | ||||
|           "driver-addon-studies@example.com", | ||||
|           "studies.get fetches studies from within a sandbox." | ||||
|         ); | ||||
|         ok(study.active, "Studies are marked as active after being started by the driver."); | ||||
| 
 | ||||
|         await driver.studies.stop(recipeId); | ||||
|         study = await driver.studies.get(recipeId); | ||||
|         ok(!study.active, "Studies are marked as inactive after being stopped by the driver."); | ||||
|       })(); | ||||
|     `);
 | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| decorate_task( | ||||
|   withPrefEnv({ | ||||
|     set: [ | ||||
|  |  | |||
|  | @ -2,8 +2,11 @@ | |||
| 
 | ||||
| ChromeUtils.import("resource://gre/modules/Services.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/ShieldPreferences.jsm", this); | ||||
| 
 | ||||
| const OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; | ||||
| const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled"; | ||||
| 
 | ||||
| ShieldPreferences.init(); | ||||
| 
 | ||||
| decorate_task( | ||||
|   withMockPreferences, | ||||
|  | @ -12,12 +15,13 @@ decorate_task( | |||
|     studyFactory({active: true}), | ||||
|   ]), | ||||
|   async function testDisableStudiesWhenOptOutDisabled(mockPreferences, [study1, study2]) { | ||||
|     mockPreferences.set(OPT_OUT_PREF, true); | ||||
| 
 | ||||
|     mockPreferences.set(OPT_OUT_STUDIES_ENABLED_PREF, true); | ||||
|     const observers = [ | ||||
|       studyEndObserved(study1.recipeId), | ||||
|       studyEndObserved(study2.recipeId), | ||||
|     ]; | ||||
|     Services.prefs.setBoolPref(OPT_OUT_PREF, false); | ||||
|     Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, false); | ||||
|     await Promise.all(observers); | ||||
| 
 | ||||
|     const newStudy1 = await AddonStudies.get(study1.recipeId); | ||||
|  |  | |||
|  | @ -0,0 +1,301 @@ | |||
| "use strict"; | ||||
| 
 | ||||
| ChromeUtils.import("resource://gre/modules/Services.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/actions/AddonStudyAction.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this); | ||||
| 
 | ||||
| const FIXTURE_ADDON_ID = "normandydriver@example.com"; | ||||
| const FIXTURE_ADDON_URL = "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi"; | ||||
| 
 | ||||
| function addonStudyRecipeFactory(overrides = {}) { | ||||
|   let args = { | ||||
|     name: "Fake name", | ||||
|     description: "fake description", | ||||
|     addonUrl: "https://example.com/study.xpi", | ||||
|   }; | ||||
|   if (Object.hasOwnProperty.call(overrides, "arguments")) { | ||||
|     args = Object.assign(args, overrides.arguments); | ||||
|     delete overrides.arguments; | ||||
|   } | ||||
|   return recipeFactory(Object.assign({ action: "addon-study", arguments: args }, overrides)); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Test decorator that checks that the test cleans up all add-ons installed | ||||
|  * during the test. Likely needs to be the first decorator used. | ||||
|  */ | ||||
| function ensureAddonCleanup(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"); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| // Test that enroll is not called if recipe is already enrolled
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   AddonStudies.withStudies([studyFactory()]), | ||||
|   withSendEventStub, | ||||
|   async function enrollTwiceFail([study], sendEventStub) { | ||||
|     const recipe = recipeFactory({ | ||||
|       id: study.recipeId, | ||||
|       type: "addon-study", | ||||
|       arguments: { | ||||
|         name: study.name, | ||||
|         description: study.description, | ||||
|         addonUrl: study.addonUrl, | ||||
|       }, | ||||
|     }); | ||||
|     const action = new AddonStudyAction(); | ||||
|     const enrollSpy = sinon.spy(action, "enroll"); | ||||
|     await action.runRecipe(recipe); | ||||
|     Assert.deepEqual(enrollSpy.args, [], "enroll should not be called"); | ||||
|     Assert.deepEqual(sendEventStub.args, [], "no events should be sent"); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // Test that if the add-on fails to install, the database is cleaned up and the
 | ||||
| // error is correctly reported.
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   withSendEventStub, | ||||
|   AddonStudies.withStudies([]), | ||||
|   async function enrollFailInstall(sendEventStub) { | ||||
|     const recipe = addonStudyRecipeFactory({ arguments: { addonUrl: "https://example.com/404.xpi" }}); | ||||
|     const action = new AddonStudyAction(); | ||||
|     await action.enroll(recipe); | ||||
| 
 | ||||
|     const studies = await AddonStudies.getAll(); | ||||
|     Assert.deepEqual(studies, [], "the study should not be in the database"); | ||||
| 
 | ||||
|     Assert.deepEqual( | ||||
|       sendEventStub.args, | ||||
|       [["enrollFailed", "addon_study", recipe.arguments.name, {reason: "download-failure"}]], | ||||
|       "An enrollFailed event should be sent", | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| // Test that in the case of a study add-on conflicting with a non-study add-on, the study does not enroll
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   AddonStudies.withStudies([]), | ||||
|   withSendEventStub, | ||||
|   withInstalledWebExtension({ version: "0.1", id: FIXTURE_ADDON_ID }), | ||||
|   async function conflictingEnrollment(studies, sendEventStub, [installedAddonId, installedAddonFile]) { | ||||
|     is(installedAddonId, FIXTURE_ADDON_ID, "Generated, installed add-on should have the same ID as the fixture"); | ||||
|     const addonUrl = FIXTURE_ADDON_URL; | ||||
|     const recipe = addonStudyRecipeFactory({ arguments: { name: "conflicting", addonUrl } }); | ||||
|     const action = new AddonStudyAction(); | ||||
|     await action.runRecipe(recipe); | ||||
| 
 | ||||
|     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"); | ||||
| 
 | ||||
|     Assert.deepEqual( | ||||
|       sendEventStub.args, | ||||
|       [["enrollFailed", "addon_study", recipe.arguments.name, { reason: "conflicting-addon-id" }]], | ||||
|       "A enrollFailed event should be sent", | ||||
|     ); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // Test a successful enrollment
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   withSendEventStub, | ||||
|   AddonStudies.withStudies(), | ||||
|   async function successfulEnroll(sendEventStub, studies) { | ||||
|     const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID); | ||||
|     const addonUrl = FIXTURE_ADDON_URL; | ||||
| 
 | ||||
|     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); | ||||
|     is(addon, null, "Before enroll, the add-on is not installed"); | ||||
| 
 | ||||
|     const recipe = addonStudyRecipeFactory({ arguments: { name: "success", addonUrl } }); | ||||
|     const action = new AddonStudyAction(); | ||||
|     await action.runRecipe(recipe); | ||||
| 
 | ||||
|     await webExtStartupPromise; | ||||
|     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID); | ||||
|     ok(addon, "After start is called, the add-on is installed"); | ||||
| 
 | ||||
|     const study = await AddonStudies.get(recipe.id); | ||||
|     Assert.deepEqual( | ||||
|       study, | ||||
|       { | ||||
|         recipeId: recipe.id, | ||||
|         name: recipe.arguments.name, | ||||
|         description: recipe.arguments.description, | ||||
|         addonId: FIXTURE_ADDON_ID, | ||||
|         addonVersion: "1.0", | ||||
|         addonUrl, | ||||
|         active: true, | ||||
|         studyStartDate: study.studyStartDate, | ||||
|       }, | ||||
|       "study data should be stored", | ||||
|     ); | ||||
|     ok(study.studyStartDate, "a start date should be assigned"); | ||||
|     is(study.studyEndDate, null, "an end date should not be assigned"); | ||||
| 
 | ||||
|     Assert.deepEqual( | ||||
|       sendEventStub.args, | ||||
|       [["enroll", "addon_study", recipe.arguments.name, { addonId: FIXTURE_ADDON_ID, addonVersion: "1.0" }]], | ||||
|       "an enrollment event should be sent", | ||||
|     ); | ||||
| 
 | ||||
|     // cleanup
 | ||||
|     await addon.uninstall(); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // Test that unenrolling fails if the study doesn't exist
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   AddonStudies.withStudies(), | ||||
|   async function unenrollNonexistent(studies) { | ||||
|     const action = new AddonStudyAction(); | ||||
|     await Assert.rejects( | ||||
|       action.unenroll(42), | ||||
|       /no study found/i, | ||||
|       "unenroll should fail when no study exists" | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| // Test that unenrolling an inactive experiment fails
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   AddonStudies.withStudies([ | ||||
|     studyFactory({active: false}), | ||||
|   ]), | ||||
|   withSendEventStub, | ||||
|   async ([study], sendEventStub) => { | ||||
|     const action = new AddonStudyAction(); | ||||
|     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( | ||||
|   ensureAddonCleanup, | ||||
|   AddonStudies.withStudies([ | ||||
|     studyFactory({active: true, addonId: testStopId, studyEndDate: null}), | ||||
|   ]), | ||||
|   withInstalledWebExtension({id: testStopId}, /* expectUninstall: */ true), | ||||
|   withSendEventStub, | ||||
|   async function unenrollTest([study], [addonId, addonFile], sendEventStub) { | ||||
|     let addon = await AddonManager.getAddonByID(addonId); | ||||
|     ok(addon, "the add-on should be installed before unenrolling"); | ||||
| 
 | ||||
|     const action = new AddonStudyAction(); | ||||
|     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"); | ||||
| 
 | ||||
|     Assert.deepEqual( | ||||
|       sendEventStub.args, | ||||
|       [["unenroll", "addon_study", study.name, { | ||||
|         addonId, | ||||
|         addonVersion: study.addonVersion, | ||||
|         reason: "test-reason" | ||||
|       }]], | ||||
|       "an unenroll event should be sent", | ||||
|     ); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // If the add-on for a study isn't installed, a warning should be logged, but the action is still successful
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   AddonStudies.withStudies([ | ||||
|     studyFactory({active: true, addonId: "missingAddon@example.com", studyEndDate: null}), | ||||
|   ]), | ||||
|   withSendEventStub, | ||||
|   async function unenrollTest([study], sendEventStub) { | ||||
|     const action = new AddonStudyAction(); | ||||
| 
 | ||||
|     SimpleTest.waitForExplicitFinish(); | ||||
|     SimpleTest.monitorConsole(() => SimpleTest.finish(), [{message: /could not uninstall addon/i}]); | ||||
|     await action.unenroll(study.recipeId); | ||||
| 
 | ||||
|     Assert.deepEqual( | ||||
|       sendEventStub.args, | ||||
|       [["unenroll", "addon_study", study.name, { | ||||
|         addonId: study.addonId, | ||||
|         addonVersion: study.addonVersion, | ||||
|         reason: "unknown" | ||||
|       }]], | ||||
|       "an unenroll event should be sent", | ||||
|     ); | ||||
| 
 | ||||
|     SimpleTest.endMonitorConsole(); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // Test that the action respects the study opt-out
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   withSendEventStub, | ||||
|   withMockPreferences, | ||||
|   AddonStudies.withStudies([]), | ||||
|   async function testOptOut(sendEventStub, mockPreferences) { | ||||
|     mockPreferences.set("app.shield.optoutstudies.enabled", false); | ||||
|     const action = new AddonStudyAction(); | ||||
|     is(action.state, AddonStudyAction.STATE_DISABLED, "the action should be disabled"); | ||||
|     const enrollSpy = sinon.spy(action, "enroll"); | ||||
|     const recipe = addonStudyRecipeFactory(); | ||||
|     await action.runRecipe(recipe); | ||||
|     await action.finalize(); | ||||
|     is(action.state, AddonStudyAction.STATE_FINALIZED, "the action should be finalized"); | ||||
|     Assert.deepEqual(enrollSpy.args, [], "enroll should not be called"); | ||||
|     Assert.deepEqual(sendEventStub.args, [], "no events should be sent"); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // Test that the action does not execute paused recipes
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   withSendEventStub, | ||||
|   AddonStudies.withStudies([]), | ||||
|   async function testOptOut(sendEventStub) { | ||||
|     const action = new AddonStudyAction(); | ||||
|     const enrollSpy = sinon.spy(action, "enroll"); | ||||
|     const recipe = addonStudyRecipeFactory({arguments: {isEnrollmentPaused: true}}); | ||||
|     await action.runRecipe(recipe); | ||||
|     await action.finalize(); | ||||
|     Assert.deepEqual(enrollSpy.args, [], "enroll should not be called"); | ||||
|     Assert.deepEqual(sendEventStub.args, [], "no events should be sent"); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| // Test that enroll is not called if recipe is already enrolled
 | ||||
| decorate_task( | ||||
|   ensureAddonCleanup, | ||||
|   AddonStudies.withStudies([studyFactory()]), | ||||
|   async function enrollTwiceFail([study]) { | ||||
|     const action = new AddonStudyAction(); | ||||
|     const unenrollSpy = sinon.stub(action, "unenroll"); | ||||
|     await action.finalize(); | ||||
|     Assert.deepEqual(unenrollSpy.args, [[study.recipeId, "recipe-not-seen"]], "unenroll should be called"); | ||||
|   }, | ||||
| ); | ||||
|  | @ -2,7 +2,6 @@ ChromeUtils.import("resource://gre/modules/Preferences.jsm", this); | |||
| ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this); | ||||
| ChromeUtils.import("resource://testing-common/TestUtils.jsm", this); | ||||
| ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/Addons.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/SandboxManager.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm", this); | ||||
| ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this); | ||||
|  | @ -71,21 +70,30 @@ this.withWebExtension = function(manifestOverrides = {}) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| this.withInstalledWebExtension = function(manifestOverrides = {}) { | ||||
| this.withCorruptedWebExtension = function() { | ||||
|   // This should be an invalid manifest version, so that installing this add-on fails.
 | ||||
|   return this.withWebExtension({ manifest_version: -1 }); | ||||
| }; | ||||
| 
 | ||||
| this.withInstalledWebExtension = function(manifestOverrides = {}, expectUninstall = false) { | ||||
|   return function wrapper(testFunction) { | ||||
|     return decorate( | ||||
|       withWebExtension(manifestOverrides), | ||||
|       async function wrappedTestFunction(...args) { | ||||
|         const [id, file] = args[args.length - 1]; | ||||
|         const startupPromise = AddonTestUtils.promiseWebExtensionStartup(id); | ||||
|         const url = Services.io.newFileURI(file).spec; | ||||
|         await Addons.install(url); | ||||
|         const addonInstall = await AddonManager.getInstallForFile(file, "application/x-xpinstall"); | ||||
|         await addonInstall.install(); | ||||
|         await startupPromise; | ||||
| 
 | ||||
|         try { | ||||
|           await testFunction(...args); | ||||
|         } finally { | ||||
|           if (await Addons.get(id)) { | ||||
|             await Addons.uninstall(id); | ||||
|           const addonToUninstall = await AddonManager.getAddonByID(id); | ||||
|           if (addonToUninstall) { | ||||
|             await addonToUninstall.uninstall(); | ||||
|           } else { | ||||
|             ok(expectUninstall, "Add-on should not be unexpectedly uninstalled during test"); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | @ -231,7 +239,7 @@ this.withPrefEnv = function(inPrefs) { | |||
| 
 | ||||
| /** | ||||
|  * Combine a list of functions right to left. The rightmost function is passed | ||||
|  * to the preceeding function as the argument; the result of this is passed to | ||||
|  * 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); | ||||
|  | @ -354,3 +362,11 @@ this.withSendEventStub = function(testFunction) { | |||
|     } | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| let _recipeId = 1; | ||||
| this.recipeFactory = function(overrides = {}) { | ||||
|   return Object.assign({ | ||||
|     id: _recipeId++, | ||||
|     arguments: overrides.arguments || {}, | ||||
|   }, overrides); | ||||
| }; | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Mike Cooper
						Mike Cooper