Backed out changeset dc5cbee865e4 (bug 1440780) for ESLint failure on normandy/test/browser/head.js:78. CLOSED TREE
authorCsoregi Natalia <ncsoregi@mozilla.com>
Mon, 20 Aug 2018 23:48:46 +0300
changeset 487565 2d2241b48b73d5e9db851f566012992266fbfc69
parent 487564 19eadd36bbba46dd3cf3addf2fdcedf0885d7971
child 487566 eaab563115c8a15ad26fb20e43f703dd0687e9b8
push id9719
push userffxbld-merge
push dateFri, 24 Aug 2018 17:49:46 +0000
treeherdermozilla-beta@719ec98fba77 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1440780
milestone63.0a1
backs outdc5cbee865e4286a767dd69749314c909e2f7343
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Backed out changeset dc5cbee865e4 (bug 1440780) for ESLint failure on normandy/test/browser/head.js:78. CLOSED TREE
toolkit/components/normandy/actions/AddonStudyAction.jsm
toolkit/components/normandy/actions/BaseAction.jsm
toolkit/components/normandy/actions/schemas/index.js
toolkit/components/normandy/actions/schemas/package.json
toolkit/components/normandy/lib/ActionsManager.jsm
toolkit/components/normandy/lib/AddonStudies.jsm
toolkit/components/normandy/lib/Addons.jsm
toolkit/components/normandy/lib/NormandyDriver.jsm
toolkit/components/normandy/lib/ShieldPreferences.jsm
toolkit/components/normandy/test/browser/browser.ini
toolkit/components/normandy/test/browser/browser_BaseAction.js
toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
toolkit/components/normandy/test/browser/browser_NormandyDriver.js
toolkit/components/normandy/test/browser/browser_ShieldPreferences.js
toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
toolkit/components/normandy/test/browser/head.js
deleted file mode 100644
--- a/toolkit/components/normandy/actions/AddonStudyAction.jsm
+++ /dev/null
@@ -1,269 +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/. */
-
-/*
- * 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.`);
-    }
-  }
-}
--- a/toolkit/components/normandy/actions/BaseAction.jsm
+++ b/toolkit/components/normandy/actions/BaseAction.jsm
@@ -15,60 +15,37 @@ var EXPORTED_SYMBOLS = ["BaseAction"];
  * 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.
  */
 class BaseAction {
   constructor() {
-    this.state = BaseAction.STATE_PREPARING;
+    this.finalized = false;
+    this.failed = false;
     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);
-      this.fail(Uptake.ACTION_PRE_EXECUTION_ERROR);
+      Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR);
     }
   }
 
   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() {
-    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() {
     return this.constructor.name;
   }
 
   /**
    * Action specific pre-execution behavior should be implemented
@@ -81,23 +58,23 @@ class BaseAction {
   /**
    * Execute the per-recipe behavior of this action for a given
    * recipe.  Reports Uptake telemetry for the execution of the recipe.
    *
    * @param {Recipe} recipe
    * @throws If this action has already been finalized.
    */
   async runRecipe(recipe) {
-    if (this.state === BaseAction.STATE_FINALIZED) {
+    if (this.finalized) {
       throw new Error("Action has already been finalized");
     }
 
-    if (this.state !== BaseAction.STATE_READY) {
+    if (this.failed) {
       Uptake.reportRecipe(recipe.id, Uptake.RECIPE_ACTION_DISABLED);
-      this.log.warn(`Skipping recipe ${recipe.name} because ${this.name} was disabled during preExecution.`);
+      this.log.warn(`Skipping recipe ${recipe.name} because ${this.name} failed during preExecution.`);
       return;
     }
 
     let [valid, validatedArguments] = JsonSchemaValidator.validateAndParseParameters(recipe.arguments, this.schema);
     if (!valid) {
       Cu.reportError(new Error(`Arguments do not match schema. arguments: ${JSON.stringify(recipe.arguments)}. schema: ${JSON.stringify(this.schema)}`));
       Uptake.reportRecipe(recipe.id, Uptake.RECIPE_EXECUTION_ERROR);
       return;
@@ -125,67 +102,39 @@ class BaseAction {
   }
 
   /**
    * 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() {
-    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}`);
-          }
+    if (this.finalized) {
+      throw new Error("Action has already been finalized");
+    }
 
-          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;
     }
 
-    this.state = BaseAction.STATE_FINALIZED;
-    if (status) {
+    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;
       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() {
     // 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";
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -56,52 +56,15 @@ const ActionSchemas = {
     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"
-    ],
-    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 */
   module.exports = ActionSchemas;
 }
--- a/toolkit/components/normandy/actions/schemas/package.json
+++ b/toolkit/components/normandy/actions/schemas/package.json
@@ -1,11 +1,11 @@
 {
   "name": "@mozilla/normandy-action-argument-schemas",
-  "version": "0.4.0",
+  "version": "0.3.0",
   "description": "Schemas for Normandy action arguments",
   "main": "index.js",
   "author": "Michael Cooper <mcooper@mozilla.com>",
   "license": "MPL-2.0",
   "scripts": {
     "prepack": "node export_json.js"
   }
 }
--- a/toolkit/components/normandy/lib/ActionsManager.jsm
+++ b/toolkit/components/normandy/lib/ActionsManager.jsm
@@ -1,16 +1,15 @@
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 
 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",
 });
 
 var EXPORTED_SYMBOLS = ["ActionsManager"];
 
 const log = LogManager.getLogger("recipe-runner");
@@ -24,24 +23,20 @@ const log = LogManager.getLogger("recipe
  * Local actions have their implementations packaged in the Normandy
  * client, and manage their lifecycles internally.
  */
 class ActionsManager {
   constructor() {
     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
     };
   }
 
   async fetchRemoteActions() {
     const actions = await NormandyApi.fetchActions();
 
     for (const action of actions) {
       // Skip actions with local implementations
--- a/toolkit/components/normandy/lib/AddonStudies.jsm
+++ b/toolkit/components/normandy/lib/AddonStudies.jsm
@@ -23,25 +23,30 @@
  * @property {string} studyStartDate
  *   Date when the study was started.
  * @property {Date} studyEndDate
  *   Date when the study was ended.
  */
 
 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";
 const STORE_NAME = "addon-studies";
 const DB_OPTIONS = {
   version: 1,
 };
 const STUDY_ENDED_TOPIC = "shield-study-ended";
@@ -77,16 +82,39 @@ async function getDatabase() {
  * 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) {
   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
    * 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.
@@ -118,20 +146,21 @@ var AddonStudies = {
       };
     };
   },
 
   async init() {
     // 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 this.markAsEnded(study, "uninstalled-sideload");
+        await markAsEnded(db, study, "uninstalled-sideload");
       }
     }
     await this.close();
 
     // Listen for add-on uninstalls so we can stop the corresponding studies.
     AddonManager.addAddonListener(this);
     CleanupManager.addCleanupHandler(() => {
       AddonManager.removeAddonListener(this);
@@ -144,17 +173,17 @@ var AddonStudies = {
    */
   async onUninstalled(addon) {
     const activeStudies = (await this.getAll()).filter(study => study.active);
     const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
     if (matchingStudy) {
       // 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 this.markAsEnded(matchingStudy, "uninstalled");
+      await markAsEnded(db, matchingStudy, "uninstalled");
       await db.close();
     }
   },
 
   /**
    * Remove all stored studies.
    */
   async clear() {
@@ -200,50 +229,127 @@ var AddonStudies = {
    * @return {Array<Study>}
    */
   async getAll() {
     const db = await getDatabase();
     return getStore(db).getAll();
   },
 
   /**
-   * Add a study to storage.
-   * @return {Promise<void, Error>} Resolves when the study is stored, or rejects with an error.
+   * 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.
    */
-  async add(study) {
+  async start({recipeId, name, description, addonUrl}) {
+    if (!recipeId || !name || !description || !addonUrl) {
+      throw new Error("Required arguments (recipeId, name, description, addonUrl) missing.");
+    }
+
     const db = await getDatabase();
-    return getStore(db).add(study);
-  },
+    if (await getStore(db).get(recipeId)) {
+      throw new Error(`A study for recipe ${recipeId} already exists.`);
+    }
 
-  /**
-   * Remove a study from storage
-   * @param recipeId The recipeId of the study to delete
-   * @return {Promise<void, Error>} Resolves when the study is deleted, or rejects with an error.
-   */
-  async delete(recipeId) {
-    const db = await getDatabase();
-    return getStore(db).delete(recipeId);
+    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);
+      }
+    }
   },
 
   /**
-   * 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.
+   * Download a remote add-on and store it in a temporary nsIFile.
+   * @param {String} addonUrl
+   * @returns {nsIFile}
    */
-  async markAsEnded(study, reason) {
-    if (reason === "unknown") {
-      log.warn(`Study ${study.name} ending for unknown reason.`);
+  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();
     }
 
-    study.active = false;
-    study.studyEndDate = new Date();
-    const db = await getDatabase();
-    await getStore(db).put(study);
+    return new FileUtils.File(uniquePath);
+  },
 
-    Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`);
-    TelemetryEvents.sendEvent("unenroll", "addon_study", study.name, {
-      addonId: study.addonId,
-      addonVersion: study.addonVersion,
-      reason,
-    });
+  /**
+   * 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.
+   */
+  async stop(recipeId, reason = "unknown") {
+    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 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);
+    }
   },
 };
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/Addons.jsm
@@ -0,0 +1,118 @@
+/* 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,
+    };
+  },
+};
--- a/toolkit/components/normandy/lib/NormandyDriver.jsm
+++ b/toolkit/components/normandy/lib/NormandyDriver.jsm
@@ -4,25 +4,28 @@
 
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 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");
 ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm");
 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);
 
 var EXPORTED_SYMBOLS = ["NormandyDriver"];
 
 const log = LogManager.getLogger("normandy-driver");
 const actionLog = LogManager.getLogger("normandy-driver.actions");
 
@@ -149,16 +152,22 @@ var NormandyDriver = function(sandboxMan
       return Cu.cloneInto(token, sandbox);
     },
 
     clearTimeout(token) {
       clearTimeout(token);
       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),
 
     // Preference Experiment API
     preferenceExperiments: {
       start: sandboxManager.wrapAsync(
         PreferenceExperiments.start.bind(PreferenceExperiments),
         {cloneArguments: true}
@@ -173,16 +182,28 @@ var NormandyDriver = function(sandboxMan
       ),
       getAllActive: sandboxManager.wrapAsync(
         PreferenceExperiments.getAllActive.bind(PreferenceExperiments),
         {cloneInto: true}
       ),
       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),
       getInt: wrapPrefGetter(Services.prefs.getIntPref),
       getChar: wrapPrefGetter(Services.prefs.getCharPref),
       has(name) {
         return Services.prefs.getPrefType(name) !== Services.prefs.PREF_INVALID;
       },
--- a/toolkit/components/normandy/lib/ShieldPreferences.jsm
+++ b/toolkit/components/normandy/lib/ShieldPreferences.jsm
@@ -1,35 +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/. */
 "use strict";
 
-ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://gre/modules/Services.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",
-});
+ChromeUtils.defineModuleGetter(
+  this, "AddonStudies", "resource://normandy/lib/AddonStudies.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this, "CleanupManager", "resource://normandy/lib/CleanupManager.jsm"
+);
 
 var EXPORTED_SYMBOLS = ["ShieldPreferences"];
 
 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.
  */
 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);
     });
   },
 
   observe(subject, topic, data) {
     switch (topic) {
       case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID:
@@ -40,24 +39,19 @@ var ShieldPreferences = {
 
   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 AddonStudyAction();
           for (const study of await AddonStudies.getAll()) {
             if (study.active) {
-              try {
-                await action.unenroll(study.recipeId, "general-opt-out");
-              } catch (err) {
-                Cu.reportError(err);
-              }
+              await AddonStudies.stop(study.recipeId, "general-opt-out");
             }
           }
         }
         break;
       }
     }
   },
 };
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -3,22 +3,22 @@ support-files =
   action_server.sjs
   fixtures/normandy.xpi
 head = head.js
 [browser_about_preferences.js]
 # Skip this test when FHR/Telemetry aren't available.
 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]
 [browser_CleanupManager.js]
 [browser_ClientEnvironment.js]
 [browser_EventEmitter.js]
 [browser_Heartbeat.js]
 [browser_LogManager.js]
--- a/toolkit/components/normandy/test/browser/browser_BaseAction.js
+++ b/toolkit/components/normandy/test/browser/browser_BaseAction.js
@@ -1,74 +1,74 @@
 "use strict";
 
 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() {
-    this._testPreExecutionFlag = true;
+    throw new Error("Test error");
   }
 
-  _run(recipe) {
+  _run() {
     this._testRunFlag = true;
   }
 
   _finalize() {
     this._testFinalizeFlag = true;
   }
 }
 
-class FailPreExecutionAction extends NoopAction {
-  _preExecution() {
+class FailRunAction extends BaseAction {
+  constructor() {
+    super();
+    this._testRunFlag = false;
+    this._testFinalizeFlag = false;
+  }
+
+  _run(recipe) {
     throw new Error("Test error");
   }
+
+  _finalize() {
+    this._testFinalizeFlag = true;
+  }
 }
 
-class FailRunAction extends NoopAction {
+class FailFinalizeAction extends BaseAction {
   _run(recipe) {
-    throw new Error("Test error");
+    // does nothing
   }
-}
 
-class FailFinalizeAction extends NoopAction {
   _finalize() {
     throw new Error("Test error");
   }
 }
 
-// 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");
-  }
-);
+let _recipeId = 1;
+function recipeFactory(overrides) {
+  let defaults = {
+    id: _recipeId++,
+    arguments: {},
+  };
+  Object.assign(defaults, overrides);
+  return defaults;
+}
 
 // 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.runRecipe(recipe);
@@ -81,17 +81,17 @@ decorate_task(
 );
 
 // 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");
+    ok(action.finalized, "Action should be marked as finalized");
     Assert.deepEqual(
       reportActionStub.args,
       [[action.name, Uptake.ACTION_SUCCESS]],
       "action uptake telemetry should be reported",
     );
   },
 );
 
@@ -122,28 +122,26 @@ decorate_task(
 
 // 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_FAILED, "Action should fail during pre-execution fail");
+    ok(action.failed, "Action should fail during pre-execution fail");
 
-    // Should not throw, even though the action is in a disabled state.
+    // Should not throw, even though the action is in a failed 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 disabled state.
+    // Should not throw, even though the action is in a failed state.
     await action.finalize();
-    is(action.state, FailPreExecutionAction.STATE_FINALIZED, "Action should be finalized");
 
-    is(action._testRunFlag, false, "_run should not have been called");
-    is(action._testFinalizeFlag, false, "_finalize should not have been called");
+    is(action._testRunFlag, false, "_run should not have been caled");
+    is(action._testFinalizeFlag, false, "_finalize should not have been caled");
 
     Assert.deepEqual(
       reportRecipeStub.args,
       [[recipe.id, Uptake.RECIPE_ACTION_DISABLED]],
       "Recipe should report recipe status as action disabled",
     );
 
     Assert.deepEqual(
@@ -157,19 +155,18 @@ decorate_task(
 // 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.runRecipe(recipe);
-    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.failed, "Action should not be marked as failed due to a recipe failure");
 
     ok(action._testFinalizeFlag, "_finalize should have been called");
 
     Assert.deepEqual(
       reportRecipeStub.args,
       [[recipe.id, Uptake.RECIPE_EXECUTION_ERROR]],
       "Recipe should report recipe execution error",
     );
@@ -200,42 +197,8 @@ decorate_task(
 
     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.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",
-    );
-  },
-);
--- a/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
+++ b/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
@@ -100,31 +100,28 @@ add_task(async function testExperiments(
   );
 
   getAll.restore();
 });
 
 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 addonInstall = await AddonManager.getInstallForURL(TEST_XPI_URL, "application/x-xpinstall");
-  await addonInstall.install();
-  const addonId = addonInstall.addon.id;
+  const addonId = await driver.addons.install(TEST_XPI_URL);
   await startupPromise;
 
   const addons = await ClientEnvironment.addons;
   Assert.deepEqual(addons[addonId], {
     id: [addonId],
     name: "normandy_fixture",
     version: "1.0",
     installDate: addons[addonId].installDate,
     isActive: true,
     type: "extension",
   }, "addons should be available in context");
 
-  const addon = await AddonManager.getAddonByID(addonId);
-  await addon.uninstall();
+  await driver.addons.uninstall(addonId);
 }));
 
 add_task(async function isFirstRun() {
   await SpecialPowers.pushPrefEnv({set: [["app.normandy.first_run", true]]});
   ok(ClientEnvironment.isFirstRun, "isFirstRun is read from preferences");
 });
--- a/toolkit/components/normandy/test/browser/browser_NormandyDriver.js
+++ b/toolkit/components/normandy/test/browser/browser_NormandyDriver.js
@@ -1,24 +1,69 @@
 "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);
 
 add_task(withDriver(Assert, async function uuids(driver) {
   // Test that it is a UUID
   const uuid1 = driver.uuid();
   ok(UUID_REGEX.test(uuid1), "valid uuid format");
 
   // Test that UUIDs are different each time
   const uuid2 = driver.uuid();
   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");
 }));
 
 add_task(withDriver(Assert, async function syncDeviceCounts(driver) {
   let client = await driver.client();
   is(client.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
@@ -81,16 +126,119 @@ decorate_task(
         is(await store.getItem("willremove"), null, "createStorage removes items");
 
         is('prefix' in store, false, "createStorage doesn't expose non-whitelist attributes");
       })();
     `);
   }
 );
 
+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: [
       ["test.char", "a string"],
       ["test.int", 5],
       ["test.bool", true],
     ],
   }),
--- a/toolkit/components/normandy/test/browser/browser_ShieldPreferences.js
+++ b/toolkit/components/normandy/test/browser/browser_ShieldPreferences.js
@@ -1,32 +1,28 @@
 "use strict";
 
 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_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
-
-ShieldPreferences.init();
+const OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
 
 decorate_task(
   withMockPreferences,
   AddonStudies.withStudies([
     studyFactory({active: true}),
     studyFactory({active: true}),
   ]),
   async function testDisableStudiesWhenOptOutDisabled(mockPreferences, [study1, study2]) {
-
-    mockPreferences.set(OPT_OUT_STUDIES_ENABLED_PREF, true);
+    mockPreferences.set(OPT_OUT_PREF, true);
     const observers = [
       studyEndObserved(study1.recipeId),
       studyEndObserved(study2.recipeId),
     ];
-    Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, false);
+    Services.prefs.setBoolPref(OPT_OUT_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."
     );
deleted file mode 100644
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
+++ /dev/null
@@ -1,301 +0,0 @@
-"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");
-  },
-);
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -1,12 +1,13 @@
 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);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
 
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/docs/
 /* global sinon */
@@ -65,40 +66,31 @@ this.withWebExtension = function(manifes
         await testFunction(...args, [id, addonFile]);
       } finally {
         AddonTestUtils.cleanupTempXPIs();
       }
     };
   };
 };
 
-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) {
+this.withInstalledWebExtension = function(manifestOverrides = {}) {
   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 addonInstall = await AddonManager.getInstallForFile(file, "application/x-xpinstall");
-        await addonInstall.install();
+        const url = Services.io.newFileURI(file).spec;
+        await Addons.install(url);
         await startupPromise;
-
         try {
           await testFunction(...args);
         } finally {
-          const addonToUninstall = await AddonManager.getAddonByID(id);
-          if (addonToUninstall) {
-            await addonToUninstall.uninstall();
-          } else {
-            ok(expectUninstall, "Add-on should not be unexpectedly uninstalled during test");
+          if (await Addons.get(id)) {
+            await Addons.uninstall(id);
           }
         }
       }
     );
   };
 };
 
 this.withSandboxManager = function(Assert) {
@@ -234,17 +226,17 @@ this.withPrefEnv = function(inPrefs) {
         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
+ * to the preceeding 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));
  */
@@ -357,16 +349,8 @@ this.withSendEventStub = function(testFu
     stub.callsFake(checkEventMatchesSchema);
     try {
       await testFunction(...args, stub);
     } finally {
       stub.restore();
     }
   };
 };
-
-let _recipeId = 1;
-this.recipeFactory = function(overrides = {}) {
-  return Object.assign({
-    id: _recipeId++,
-    arguments: overrides.arguments || {},
-  }, overrides);
-};