Bug 1325409 - shield-recipe-client: Update to newer upstream Github revision and move event emitter out of sandbox. r=Gijs, a=gchang
authorMythmon <mcooper@mozilla.com>
Mon, 30 Jan 2017 10:38:02 -0800
changeset 358923 c02020dd7e6e190b046e0bdc992a75b647c4cffa
parent 358922 e2782cbfdefff36f45c159d8a8b3001db173f46b
child 358924 692d354c0e6b85adf2c0ded9292418122ccfbdcb
push id10678
push userryanvm@gmail.com
push dateFri, 03 Feb 2017 14:56:31 +0000
treeherdermozilla-aurora@c02020dd7e6e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs, gchang
bugs1325409
milestone53.0a2
Bug 1325409 - shield-recipe-client: Update to newer upstream Github revision and move event emitter out of sandbox. r=Gijs, a=gchang
browser/extensions/shield-recipe-client/bootstrap.js
browser/extensions/shield-recipe-client/data/EventEmitter.js
browser/extensions/shield-recipe-client/jar.mn
browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
browser/extensions/shield-recipe-client/lib/EventEmitter.jsm
browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
browser/extensions/shield-recipe-client/lib/LogManager.jsm
browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
browser/extensions/shield-recipe-client/lib/Sampling.jsm
browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
browser/extensions/shield-recipe-client/lib/Storage.jsm
browser/extensions/shield-recipe-client/moz.build
browser/extensions/shield-recipe-client/test/.eslintrc.js
browser/extensions/shield-recipe-client/test/TestUtils.jsm
browser/extensions/shield-recipe-client/test/browser.ini
browser/extensions/shield-recipe-client/test/browser/Utils.jsm
browser/extensions/shield-recipe-client/test/browser/browser.ini
browser/extensions/shield-recipe-client/test/browser/browser_EnvExpressions.js
browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
browser/extensions/shield-recipe-client/test/browser/browser_Heartbeat.js
browser/extensions/shield-recipe-client/test/browser/browser_LogManager.js
browser/extensions/shield-recipe-client/test/browser/browser_NormandyApi.js
browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
browser/extensions/shield-recipe-client/test/browser/browser_Storage.js
browser/extensions/shield-recipe-client/test/browser/test_server.sjs
browser/extensions/shield-recipe-client/test/browser_EventEmitter.js
browser/extensions/shield-recipe-client/test/browser_Heartbeat.js
browser/extensions/shield-recipe-client/test/browser_NormandyApi.js
browser/extensions/shield-recipe-client/test/browser_RecipeRunner.js
browser/extensions/shield-recipe-client/test/browser_Storage.js
browser/extensions/shield-recipe-client/test/browser_driver_uuids.js
browser/extensions/shield-recipe-client/test/browser_env_expressions.js
browser/extensions/shield-recipe-client/test/test_server.sjs
browser/extensions/shield-recipe-client/test/unit/test_Sampling.js
browser/extensions/shield-recipe-client/test/unit/xpc_head.js
browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
--- a/browser/extensions/shield-recipe-client/bootstrap.js
+++ b/browser/extensions/shield-recipe-client/bootstrap.js
@@ -1,16 +1,17 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {utils: Cu} = Components;
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
 
 const REASONS = {
   APP_STARTUP: 1,      // The application is starting up.
   APP_SHUTDOWN: 2,     // The application is shutting down.
   ADDON_ENABLE: 3,     // The add-on is being enabled.
   ADDON_DISABLE: 4,    // The add-on is being disabled. (Also sent during uninstallation)
   ADDON_INSTALL: 5,    // The add-on is being installed.
   ADDON_UNINSTALL: 6,  // The add-on is being uninstalled.
@@ -19,19 +20,22 @@ const REASONS = {
 };
 
 const PREF_BRANCH = "extensions.shield-recipe-client.";
 const DEFAULT_PREFS = {
   api_url: "https://self-repair.mozilla.org/api/v1",
   dev_mode: false,
   enabled: true,
   startup_delay_seconds: 300,
+  "logging.level": Log.Level.Warn,
+  user_id: "",
 };
 const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
 const PREF_SELF_SUPPORT_ENABLED = "browser.selfsupport.enabled";
+const PREF_LOGGING_LEVEL = PREF_BRANCH + "logging.level";
 
 let shouldRun = true;
 
 this.install = function() {
   // Self Repair only checks its pref on start, so if we disable it, wait until
   // next startup to run, unless the dev_mode preference is set.
   if (Preferences.get(PREF_SELF_SUPPORT_ENABLED, true)) {
     Preferences.set(PREF_SELF_SUPPORT_ENABLED, false);
@@ -43,34 +47,42 @@ this.install = function() {
 
 this.startup = function() {
   setDefaultPrefs();
 
   if (!shouldRun) {
     return;
   }
 
+  // Setup logging and listen for changes to logging prefs
+  Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
+  LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL));
+  Preferences.observe(PREF_LOGGING_LEVEL, LogManager.configure);
+
   Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm");
   RecipeRunner.init();
 };
 
 this.shutdown = function(data, reason) {
+  Preferences.ignore(PREF_LOGGING_LEVEL, LogManager.configure);
+
   Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
 
   CleanupManager.cleanup();
 
   if (reason === REASONS.ADDON_DISABLE || reason === REASONS.ADDON_UNINSTALL) {
     Services.prefs.setBoolPref(PREF_SELF_SUPPORT_ENABLED, true);
   }
 
   const modules = [
-    "data/EventEmitter.js",
     "lib/CleanupManager.jsm",
     "lib/EnvExpressions.jsm",
+    "lib/EventEmitter.jsm",
     "lib/Heartbeat.jsm",
+    "lib/LogManager.jsm",
     "lib/NormandyApi.jsm",
     "lib/NormandyDriver.jsm",
     "lib/RecipeRunner.jsm",
     "lib/Sampling.jsm",
     "lib/SandboxManager.jsm",
     "lib/Storage.jsm",
   ];
   for (const module in modules) {
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/data/EventEmitter.js
+++ /dev/null
@@ -1,60 +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 file is meant to run inside action sandboxes
-
-"use strict";
-
-
-this.EventEmitter = function(driver) {
-  if (!driver) {
-    throw new Error("driver must be provided");
-  }
-
-  const listeners = {};
-
-  return {
-    emit(eventName, event) {
-      // Fire events async
-      Promise.resolve()
-        .then(() => {
-          if (!(eventName in listeners)) {
-            driver.log(`EventEmitter: Event fired with no listeners: ${eventName}`);
-            return;
-          }
-          // freeze event to prevent handlers from modifying it
-          const frozenEvent = Object.freeze(event);
-          // Clone callbacks array to avoid problems with mutation while iterating
-          const callbacks = Array.from(listeners[eventName]);
-          for (const cb of callbacks) {
-            cb(frozenEvent);
-          }
-        });
-    },
-
-    on(eventName, callback) {
-      if (!(eventName in listeners)) {
-        listeners[eventName] = [];
-      }
-      listeners[eventName].push(callback);
-    },
-
-    off(eventName, callback) {
-      if (eventName in listeners) {
-        const index = listeners[eventName].indexOf(callback);
-        if (index !== -1) {
-          listeners[eventName].splice(index, 1);
-        }
-      }
-    },
-
-    once(eventName, callback) {
-      const inner = event => {
-        callback(event);
-        this.off(eventName, inner);
-      };
-      this.on(eventName, inner);
-    },
-  };
-};
--- a/browser/extensions/shield-recipe-client/jar.mn
+++ b/browser/extensions/shield-recipe-client/jar.mn
@@ -1,9 +1,10 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 [features/shield-recipe-client@mozilla.org] chrome.jar:
 % resource shield-recipe-client %content/
   content/lib/ (./lib/*)
   content/data/ (./data/*)
+  content/test/ (./test/*)
   content/node_modules/jexl/ (./node_modules/jexl/*)
--- a/browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
+++ b/browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
@@ -1,65 +1,87 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-const {utils: Cu} = Components;
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/TelemetryArchive.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
+
+const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 this.EXPORTED_SYMBOLS = ["EnvExpressions"];
 
+const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
+
 XPCOMUtils.defineLazyGetter(this, "nodeRequire", () => {
   const {Loader, Require} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
   const loader = new Loader({
     paths: {
       "": "resource://shield-recipe-client/node_modules/",
     },
   });
   return new Require(loader, {});
 });
 
 XPCOMUtils.defineLazyGetter(this, "jexl", () => {
   const {Jexl} = nodeRequire("jexl/lib/Jexl.js");
   const jexl = new Jexl();
   jexl.addTransforms({
     date: dateString => new Date(dateString),
     stableSample: Sampling.stableSample,
+    bucketSample: Sampling.bucketSample,
   });
   return jexl;
 });
 
-const getLatestTelemetry = Task.async(function *() {
-  const pings = yield TelemetryArchive.promiseArchivedPingList();
+this.EnvExpressions = {
+  getLatestTelemetry: Task.async(function *() {
+    const pings = yield TelemetryArchive.promiseArchivedPingList();
 
-  // get most recent ping per type
-  const mostRecentPings = {};
-  for (const ping of pings) {
-    if (ping.type in mostRecentPings) {
-      if (mostRecentPings[ping.type].timeStampCreated < ping.timeStampCreated) {
+    // get most recent ping per type
+    const mostRecentPings = {};
+    for (const ping of pings) {
+      if (ping.type in mostRecentPings) {
+        if (mostRecentPings[ping.type].timeStampCreated < ping.timeStampCreated) {
+          mostRecentPings[ping.type] = ping;
+        }
+      } else {
         mostRecentPings[ping.type] = ping;
       }
-    } else {
-      mostRecentPings[ping.type] = ping;
     }
-  }
+
+    const telemetry = {};
+    for (const key in mostRecentPings) {
+      const ping = mostRecentPings[key];
+      telemetry[ping.type] = yield TelemetryArchive.promiseArchivedPingById(ping.id);
+    }
+    return telemetry;
+  }),
 
-  const telemetry = {};
-  for (const key in mostRecentPings) {
-    const ping = mostRecentPings[key];
-    telemetry[ping.type] = yield TelemetryArchive.promiseArchivedPingById(ping.id);
-  }
-  return telemetry;
-});
+  getUserId() {
+    let id = prefs.getCharPref("user_id");
+    if (id === "") {
+      // generateUUID adds leading and trailing "{" and "}". strip them off.
+      id = generateUUID().toString().slice(1, -1);
+      prefs.setCharPref("user_id", id);
+    }
+    return id;
+  },
 
-this.EnvExpressions = {
   eval(expr, extraContext = {}) {
-    const context = Object.assign({telemetry: getLatestTelemetry()}, extraContext);
+    // First clone the extra context
+    const context = Object.assign({}, extraContext);
+    // jexl handles promises, so it is fine to include them in this data.
+    context.telemetry = EnvExpressions.getLatestTelemetry();
+    context.normandy = context.normandy || {};
+    context.normandy.userId = EnvExpressions.getUserId();
+
     const onelineExpr = expr.replace(/[\t\n\r]/g, " ");
+
     return jexl.eval(onelineExpr, context);
   },
 };
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/EventEmitter.jsm
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
+
+this.EXPORTED_SYMBOLS = ["EventEmitter"];
+
+const log = LogManager.getLogger("event-emitter");
+
+this.EventEmitter = function(sandboxManager) {
+  const listeners = {};
+
+  return {
+    createSandboxedEmitter() {
+      return sandboxManager.cloneInto({
+        on: this.on.bind(this),
+        off: this.off.bind(this),
+        once: this.once.bind(this),
+      }, {cloneFunctions: true});
+    },
+
+    emit(eventName, event) {
+      // Fire events async
+      Promise.resolve()
+        .then(() => {
+          if (!(eventName in listeners)) {
+            log.info(`EventEmitter: Event fired with no listeners: ${eventName}`);
+            return;
+          }
+          // Clone callbacks array to avoid problems with mutation while iterating
+          const callbacks = Array.from(listeners[eventName]);
+          for (const cb of callbacks) {
+            cb(sandboxManager.cloneInto(event));
+          }
+        });
+    },
+
+    on(eventName, callback) {
+      if (!(eventName in listeners)) {
+        listeners[eventName] = [];
+      }
+      listeners[eventName].push(callback);
+    },
+
+    off(eventName, callback) {
+      if (eventName in listeners) {
+        const index = listeners[eventName].indexOf(callback);
+        if (index !== -1) {
+          listeners[eventName].splice(index, 1);
+        }
+      }
+    },
+
+    once(eventName, callback) {
+      const inner = event => {
+        callback(event);
+        this.off(eventName, inner);
+      };
+      this.on(eventName, inner);
+    },
+  };
+};
--- a/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
+++ b/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
@@ -4,34 +4,33 @@
 
 "use strict";
 
 const {utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/TelemetryController.jsm");
 Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout, clearTimeout */
-Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
+Cu.import("resource://shield-recipe-client/lib/EventEmitter.jsm");
+Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
 
 Cu.importGlobalProperties(["URL"]); /* globals URL */
 
 this.EXPORTED_SYMBOLS = ["Heartbeat"];
 
-const log = Log.repository.getLogger("shield-recipe-client");
+const log = LogManager.getLogger("heartbeat");
 const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
 const NOTIFICATION_TIME = 3000;
 
 /**
  * Show the Heartbeat UI to request user feedback.
  *
  * @param chromeWindow
  *        The chrome window that the heartbeat notification is displayed in.
- * @param eventEmitter
- *        An EventEmitter instance to report status to.
  * @param sandboxManager
  *        The manager for the sandbox this was called from. Heartbeat will
  *        increment the hold counter on the manager.
  * @param {Object} options Options object.
  * @param {String} options.message
  *        The message, or question, to display on the notification.
  * @param {String} options.thanksMessage
  *        The thank you message to display after user votes.
@@ -51,17 +50,17 @@ const NOTIFICATION_TIME = 3000;
  * @param {Number} [options.surveyVersion]
  *        Survey's version number, reflected in the Telemetry ping.
  * @param {boolean} [options.testing]
  *        Whether this is a test survey, reflected in the Telemetry ping.
  * @param {String} [options.postAnswerURL=null]
  *        The url to visit after the user answers the question.
  */
 this.Heartbeat = class {
-  constructor(chromeWindow, eventEmitter, sandboxManager, options) {
+  constructor(chromeWindow, sandboxManager, options) {
     if (typeof options.flowId !== "string") {
       throw new Error("flowId must be a string");
     }
 
     if (!options.flowId) {
       throw new Error("flowId must not be an empty string");
     }
 
@@ -87,17 +86,17 @@ this.Heartbeat = class {
       try {
         options.learnMoreUrl = new URL(options.learnMoreUrl);
       } catch (e) {
         options.learnMoreUrl = null;
       }
     }
 
     this.chromeWindow = chromeWindow;
-    this.eventEmitter = eventEmitter;
+    this.eventEmitter = new EventEmitter(sandboxManager);
     this.sandboxManager = sandboxManager;
     this.options = options;
     this.surveyResults = {};
     this.buttons = null;
 
     // so event handlers are consistent
     this.handleWindowClosed = this.handleWindowClosed.bind(this);
     this.close = this.close.bind(this);
@@ -256,17 +255,17 @@ this.Heartbeat = class {
         log.error("Unrecognized Heartbeat event:", name);
       },
     };
 
     (phases[name] || phases.default)();
 
     data.timestamp = timestamp;
     data.flowId = this.options.flowId;
-    this.eventEmitter.emit(name, Cu.cloneInto(data, this.sandboxManager.sandbox));
+    this.eventEmitter.emit(name, data);
 
     if (sendPing) {
       // Send the ping to Telemetry
       const payload = Object.assign({version: 1}, this.surveyResults);
       for (const meta of ["surveyId", "surveyVersion", "testing"]) {
         if (this.options.hasOwnProperty(meta)) {
           payload[meta] = this.options[meta];
         }
@@ -274,17 +273,17 @@ this.Heartbeat = class {
 
       log.debug("Sending telemetry");
       TelemetryController.submitExternalPing("heartbeat", payload, {
         addClientId: true,
         addEnvironment: true,
       });
 
       // only for testing
-      this.eventEmitter.emit("TelemetrySent", Cu.cloneInto(payload, this.sandboxManager.sandbox));
+      this.eventEmitter.emit("TelemetrySent", payload);
 
       // Survey is complete, clear out the expiry timer & survey configuration
       this.endTimerIfPresent("surveyEndTimer");
 
       this.pingSent = true;
       this.surveyResults = null;
     }
 
@@ -326,17 +325,16 @@ this.Heartbeat = class {
   }
 
   handleWindowClosed() {
     this.maybeNotifyHeartbeat("WindowClosed");
   }
 
   close() {
     this.notificationBox.removeNotification(this.notice);
-    this.cleanup();
   }
 
   cleanup() {
     // Kill the timers which might call things after we've cleaned up:
     this.endTimerIfPresent("surveyEndTimer");
     this.endTimerIfPresent("engagementCloseTimer");
 
     this.sandboxManager.removeHold("heartbeat");
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/LogManager.jsm
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["LogManager"];
+
+const ROOT_LOGGER_NAME = "extensions.shield-recipe-client"
+let rootLogger = null;
+
+this.LogManager = {
+  /**
+   * Configure the root logger for the Recipe Client. Must be called at
+   * least once before using any loggers created via getLogger.
+   * @param {Number} loggingLevel
+   *        Logging level to use as defined in Log.jsm
+   */
+  configure(loggingLevel) {
+    if (!rootLogger) {
+      rootLogger = Log.repository.getLogger(ROOT_LOGGER_NAME);
+      rootLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+    }
+    rootLogger.level = loggingLevel;
+  },
+
+  /**
+   * Obtain a named logger with the recipe client logger as its parent.
+   * @param {String} name
+   *        Name of the logger to obtain.
+   * @return {Logger}
+   */
+  getLogger(name) {
+    return Log.repository.getLogger(`${ROOT_LOGGER_NAME}.${name}`);
+  },
+};
--- a/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
+++ b/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
@@ -4,22 +4,22 @@
 /* globals URLSearchParams */
 
 "use strict";
 
 const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/CanonicalJSON.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
 Cu.importGlobalProperties(["fetch"]); /* globals fetch */
 
 this.EXPORTED_SYMBOLS = ["NormandyApi"];
 
-const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const log = LogManager.getLogger("normandy-api");
 const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
 
 this.NormandyApi = {
   apiCall(method, endpoint, data = {}) {
     const api_url = prefs.getCharPref("api_url");
     let url = `${api_url}/${endpoint}`;
     method = method.toLowerCase();
 
--- a/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
+++ b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
@@ -7,63 +7,66 @@
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource:///modules/ShellService.jsm");
 Cu.import("resource://gre/modules/AddonManager.jsm");
 Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout, clearTimeout */
-Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
 Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
 Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm");
 
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 this.EXPORTED_SYMBOLS = ["NormandyDriver"];
 
-const log = Log.repository.getLogger("extensions.shield-recipe-client");
-const actionLog = Log.repository.getLogger("extensions.shield-recipe-client.actions");
+const log = LogManager.getLogger("normandy-driver");
+const actionLog = LogManager.getLogger("normandy-driver.actions");
 
 this.NormandyDriver = function(sandboxManager, extraContext = {}) {
   if (!sandboxManager) {
     throw new Error("sandboxManager is required");
   }
   const {sandbox} = sandboxManager;
 
   return {
     testing: false,
 
     get locale() {
       return Cc["@mozilla.org/chrome/chrome-registry;1"]
         .getService(Ci.nsIXULChromeRegistry)
         .getSelectedLocale("browser");
     },
 
+    get userId() {
+      return EnvExpressions.getUserId();
+    },
+
     log(message, level = "debug") {
       const levels = ["debug", "info", "warn", "error"];
       if (levels.indexOf(level) === -1) {
         throw new Error(`Invalid log level "${level}"`);
       }
       actionLog[level](message);
     },
 
     showHeartbeat(options) {
       log.info(`Showing heartbeat prompt "${options.message}"`);
       const aWindow = Services.wm.getMostRecentWindow("navigator:browser");
 
       if (!aWindow) {
         return sandbox.Promise.reject(new sandbox.Error("No window to show heartbeat in"));
       }
 
-      const sandboxedDriver = Cu.cloneInto(this, sandbox, {cloneFunctions: true});
-      const ee = new sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
       const internalOptions = Object.assign({}, options, {testing: this.testing});
-      new Heartbeat(aWindow, ee, sandboxManager, internalOptions);
-      return sandbox.Promise.resolve(ee);
+      const heartbeat = new Heartbeat(aWindow, sandboxManager, internalOptions);
+      return sandbox.Promise.resolve(heartbeat.eventEmitter.createSandboxedEmitter());
     },
 
     saveHeartbeatFlow() {
       // no-op required by spec
     },
 
     client() {
       const appinfo = {
@@ -82,17 +85,21 @@ this.NormandyDriver = function(sandboxMa
             appinfo.searchEngine = Services.search.defaultEngine.identifier;
           }
           resolve();
         });
       });
 
       const pluginsPromise = new Promise(resolve => {
         AddonManager.getAddonsByTypes(["plugin"], plugins => {
-          plugins.forEach(plugin => appinfo.plugins[plugin.name] = plugin);
+          plugins.forEach(plugin => appinfo.plugins[plugin.name] = {
+            name: plugin.name,
+            description: plugin.description,
+            version: plugin.version,
+          });
           resolve();
         });
       });
 
       return new sandbox.Promise(resolve => {
         Promise.all([searchEnginePromise, pluginsPromise]).then(() => {
           resolve(Cu.cloneInto(appinfo, sandbox));
         });
--- a/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
+++ b/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
@@ -3,26 +3,26 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {utils: Cu} = Components;
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout */
 Cu.import("resource://gre/modules/Task.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
 Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
 Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm");
 Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm");
 Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
 Cu.importGlobalProperties(["fetch"]); /* globals fetch */
 
 this.EXPORTED_SYMBOLS = ["RecipeRunner"];
 
-const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const log = LogManager.getLogger("recipe-runner");
 const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
 
 this.RecipeRunner = {
   init() {
     if (!this.checkPrefs()) {
       return;
     }
 
@@ -67,17 +67,17 @@ this.RecipeRunner = {
       log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
       return;
     }
 
     let extraContext;
     try {
       extraContext = yield this.getExtraContext();
     } catch (e) {
-      log.warning(`Couldn't get extra filter context: ${e}`);
+      log.warn(`Couldn't get extra filter context: ${e}`);
       extraContext = {};
     }
 
     const recipesToRun = [];
 
     for (const recipe of recipes) {
       if (yield this.checkFilter(recipe, extraContext)) {
         recipesToRun.push(recipe);
@@ -140,40 +140,49 @@ this.RecipeRunner = {
    * @param  {Object} recipe A recipe to execute
    * @param  {Object} extraContext Extra data about the user, see NormandyDriver
    * @param  {String} actionScript The JavaScript for the action to execute.
    * @promise Resolves or rejects when the action has executed or failed.
    */
   executeAction(recipe, extraContext, actionScript) {
     return new Promise((resolve, reject) => {
       const sandboxManager = new SandboxManager();
-      const {sandbox} = sandboxManager;
       const prepScript = `
         function registerAction(name, Action) {
           let a = new Action(sandboxedDriver, sandboxedRecipe);
           a.execute()
             .then(actionFinished)
-            .catch(err => sandboxedDriver.log(err, 'error'));
+            .catch(actionFailed);
         };
 
-        window.registerAction = registerAction;
-        window.setTimeout = sandboxedDriver.setTimeout;
-        window.clearTimeout = sandboxedDriver.clearTimeout;
+        this.window = this;
+        this.registerAction = registerAction;
+        this.setTimeout = sandboxedDriver.setTimeout;
+        this.clearTimeout = sandboxedDriver.clearTimeout;
       `;
 
       const driver = new NormandyDriver(sandboxManager, extraContext);
-      sandbox.sandboxedDriver = Cu.cloneInto(driver, sandbox, {cloneFunctions: true});
-      sandbox.sandboxedRecipe = Cu.cloneInto(recipe, sandbox);
-      sandbox.actionFinished = result => {
+      sandboxManager.cloneIntoGlobal("sandboxedDriver", driver, {cloneFunctions: true});
+      sandboxManager.cloneIntoGlobal("sandboxedRecipe", recipe);
+
+      // Results are cloned so that they don't become inaccessible when
+      // the sandbox they came from is nuked when the hold is removed.
+      sandboxManager.addGlobal("actionFinished", result => {
+        const clonedResult = Cu.cloneInto(result, {});
         sandboxManager.removeHold("recipeExecution");
-        resolve(result);
-      };
-      sandbox.actionFailed = err => {
+        resolve(clonedResult);
+      });
+      sandboxManager.addGlobal("actionFailed", err => {
+        Cu.reportError(err);
+
+        // Error objects can't be cloned, so we just copy the message
+        // (which doesn't need to be cloned) to be somewhat useful.
+        const message = err.message;
         sandboxManager.removeHold("recipeExecution");
-        reject(err);
-      };
+        reject(new Error(message));
+      });
 
       sandboxManager.addHold("recipeExecution");
-      Cu.evalInSandbox(prepScript, sandbox);
-      Cu.evalInSandbox(actionScript, sandbox);
+      sandboxManager.evalInSandbox(prepScript);
+      sandboxManager.evalInSandbox(actionScript);
     });
   },
 };
--- a/browser/extensions/shield-recipe-client/lib/Sampling.jsm
+++ b/browser/extensions/shield-recipe-client/lib/Sampling.jsm
@@ -1,81 +1,133 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {utils: Cu} = Components;
-Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
 Cu.importGlobalProperties(["crypto", "TextEncoder"]);
 
 this.EXPORTED_SYMBOLS = ["Sampling"];
 
-const log = Log.repository.getLogger("extensions.shield-recipe-client");
-
-/**
- * Map from the range [0, 1] to [0, max(sha256)].
- * @param  {number} frac A float from 0.0 to 1.0.
- * @return {string} A 48 bit number represented in hex, padded to 12 characters.
- */
-function fractionToKey(frac) {
-  const hashBits = 48;
-  const hashLength = hashBits / 4;
-
-  if (frac < 0 || frac > 1) {
-    throw new Error(`frac must be between 0 and 1 inclusive (got ${frac})`);
-  }
-
-  const mult = Math.pow(2, hashBits) - 1;
-  const inDecimal = Math.floor(frac * mult);
-  let hexDigits = inDecimal.toString(16);
-  if (hexDigits.length < hashLength) {
-    // Left pad with zeroes
-    // If N zeroes are needed, generate an array of nulls N+1 elements long,
-    // and inserts zeroes between each null.
-    hexDigits = Array(hashLength - hexDigits.length + 1).join("0") + hexDigits;
-  }
-
-  // Saturate at 2**48 - 1
-  if (hexDigits.length > hashLength) {
-    hexDigits = Array(hashLength + 1).join("f");
-  }
-
-  return hexDigits;
-}
-
-function bufferToHex(buffer) {
-  const hexCodes = [];
-  const view = new DataView(buffer);
-  for (let i = 0; i < view.byteLength; i += 4) {
-    // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
-    const value = view.getUint32(i);
-    // toString(16) will give the hex representation of the number without padding
-    hexCodes.push(value.toString(16).padStart(8, "0"));
-  }
-
-  // Join all the hex strings into one
-  return hexCodes.join("");
-}
+const hashBits = 48;
+const hashLength = hashBits / 4;  // each hexadecimal digit represents 4 bits
+const hashMultiplier = Math.pow(2, hashBits) - 1;
 
 this.Sampling = {
-  stableSample(input, rate) {
-    const hasher = crypto.subtle;
+  /**
+   * Map from the range [0, 1] to [0, 2^48].
+   * @param  {number} frac A float from 0.0 to 1.0.
+   * @return {string} A 48 bit number represented in hex, padded to 12 characters.
+   */
+  fractionToKey(frac) {
+    if (frac < 0 || frac > 1) {
+      throw new Error(`frac must be between 0 and 1 inclusive (got ${frac})`);
+    }
+
+    return Math.floor(frac * hashMultiplier).toString(16).padStart(hashLength, "0");
+  },
+
+  /**
+   * @param {ArrayBuffer} buffer Data to convert
+   * @returns {String}    `buffer`'s content, converted to a hexadecimal string.
+   */
+  bufferToHex(buffer) {
+    const hexCodes = [];
+    const view = new DataView(buffer);
+    for (let i = 0; i < view.byteLength; i += 4) {
+      // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
+      const value = view.getUint32(i);
+      // toString(16) will give the hex representation of the number without padding
+      hexCodes.push(value.toString(16).padStart(8, "0"));
+    }
 
-    return hasher.digest("SHA-256", new TextEncoder("utf-8").encode(JSON.stringify(input)))
-      .then(hash => {
-        // truncate hash to 12 characters (2^48)
-        const inputHash = bufferToHex(hash).slice(0, 12);
-        const samplePoint = fractionToKey(rate);
+    // Join all the hex strings into one
+    return hexCodes.join("");
+  },
+
+  /**
+   * Check if an input hash is contained in a bucket range.
+   *
+   * isHashInBucket(fractionToKey(0.5), 3, 6, 10) -> returns true
+   *
+   *              minBucket
+   *              |     hash
+   *              v     v
+   *    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+   *                       ^
+   *                       maxBucket
+   *
+   * @param inputHash {String}
+   * @param minBucket {int} The lower boundary, inclusive, of the range to check.
+   * @param maxBucket {int} The upper boundary, exclusive, of the range to check.
+   * @param bucketCount {int} The total number of buckets. Should be greater than
+   *                          or equal to maxBucket.
+   */
+  isHashInBucket(inputHash, minBucket, maxBucket, bucketCount) {
+    const minHash = Sampling.fractionToKey(minBucket / bucketCount);
+    const maxHash = Sampling.fractionToKey(maxBucket / bucketCount);
+    return (minHash <= inputHash) && (inputHash < maxHash);
+  },
 
-        if (samplePoint.length !== 12 || inputHash.length !== 12) {
-          throw new Error("Unexpected hash length");
-        }
+  /**
+   * @promise A hash of `data`, truncated to the 12 most significant characters.
+   */
+  truncatedHash: Task.async(function* (data) {
+    const hasher = crypto.subtle;
+    const input = new TextEncoder("utf-8").encode(JSON.stringify(data));
+    const hash = yield hasher.digest("SHA-256", input);
+    // truncate hash to 12 characters (2^48), because the full hash is larger
+    // than JS can meaningfully represent as a number.
+    return Sampling.bufferToHex(hash).slice(0, 12);
+  }),
 
-        return inputHash < samplePoint;
+  /**
+   * Sample by splitting the input into two buckets, one with a size (rate) and
+   * another with a size (1.0 - rate), and then check if the input's hash falls
+   * into the first bucket.
+   *
+   * @param    {object}  input Input to hash to determine the sample.
+   * @param    {Number}  rate  Number between 0.0 and 1.0 to sample at. A value of
+   *                           0.25 returns true 25% of the time.
+   * @promises {boolean} True if the input is in the sample.
+   */
+  stableSample: Task.async(function* (input, rate) {
+    const inputHash = yield Sampling.truncatedHash(input);
+    const samplePoint = Sampling.fractionToKey(rate);
+
+    return inputHash < samplePoint;
+  }),
 
-      })
-      .catch(error => {
-        log.error(`Error: ${error}`);
-      });
-  },
+  /**
+   * Sample by splitting the input space into a series of buckets, and checking
+   * if the given input is in a range of buckets.
+   *
+   * The range to check is defined by a start point and length, and can wrap
+   * around the input space. For example, if there are 100 buckets, and we ask to
+   * check 50 buckets starting from bucket 70, then buckets 70-99 and 0-19 will
+   * be checked.
+   *
+   * @param {object}     input Input to hash to determine the matching bucket.
+   * @param {integer}    start Index of the bucket to start checking.
+   * @param {integer}    count Number of buckets to check.
+   * @param {integer}    total Total number of buckets to group inputs into.
+   * @promises {boolean} True if the given input is within the range of buckets
+   *                     we're checking. */
+  bucketSample: Task.async(function* (input, start, count, total) {
+    const inputHash = yield Sampling.truncatedHash(input);
+    const wrappedStart = start % total;
+    const end = wrappedStart + count;
+
+    // If the range we're testing wraps, we have to check two ranges: from start
+    // to max, and from min to end.
+    if (end > total) {
+      return (
+        Sampling.isHashInBucket(inputHash, 0, end % total, total)
+        || Sampling.isHashInBucket(inputHash, wrappedStart, total, total)
+      );
+    }
+
+    return Sampling.isHashInBucket(inputHash, wrappedStart, end, total);
+  }),
 };
--- a/browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
+++ b/browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
@@ -24,16 +24,34 @@ this.SandboxManager = class {
     const index = this.holds.indexOf(name);
     if (index === -1) {
       throw new Error(`Tried to remove non-existant hold "${name}"`);
     }
     this.holds.splice(index, 1);
     this.tryCleanup();
   }
 
+  cloneInto(value, options = {}) {
+    return Cu.cloneInto(value, this.sandbox, options);
+  }
+
+  cloneIntoGlobal(name, value, options = {}) {
+    const clonedValue = Cu.cloneInto(value, this.sandbox, options);
+    this.addGlobal(name, clonedValue);
+    return clonedValue;
+  }
+
+  addGlobal(name, value) {
+    this.sandbox[name] = value;
+  }
+
+  evalInSandbox(script) {
+    return Cu.evalInSandbox(script, this.sandbox);
+  }
+
   tryCleanup() {
     if (this.holds.length === 0) {
       const sandbox = this._sandbox;
       this._sandbox = null;
       Cu.nukeSandbox(sandbox);
     }
   }
 
@@ -51,15 +69,10 @@ this.SandboxManager = class {
 
 
 function makeSandbox() {
   const sandbox = new Cu.Sandbox(null, {
     wantComponents: false,
     wantGlobalProperties: ["URL", "URLSearchParams"],
   });
 
-  sandbox.window = Cu.cloneInto({}, sandbox);
-
-  const url = "resource://shield-recipe-client/data/EventEmitter.js";
-  Services.scriptloader.loadSubScript(url, sandbox);
-
   return sandbox;
 }
--- a/browser/extensions/shield-recipe-client/lib/Storage.jsm
+++ b/browser/extensions/shield-recipe-client/lib/Storage.jsm
@@ -1,25 +1,25 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {utils: Cu} = Components;
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", "resource://gre/modules/JSONFile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
 
 this.EXPORTED_SYMBOLS = ["Storage"];
 
-const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const log = LogManager.getLogger("storage");
 let storePromise;
 
 function loadStorage() {
   if (storePromise === undefined) {
     const path = OS.Path.join(OS.Constants.Path.profileDir, "shield-recipe-client.json");
     const storage = new JSONFile({path});
     storePromise = Task.spawn(function* () {
       yield storage.load();
@@ -126,9 +126,23 @@ this.Storage = {
         });
       },
     };
 
     return Cu.cloneInto(storageInterface, sandbox, {
       cloneFunctions: true,
     });
   },
+
+  /**
+   * Clear ALL storage data and save to the disk.
+   */
+  clearAllStorage() {
+    return loadStorage()
+      .then(store => {
+        store.data = {};
+        store.saveSoon();
+      })
+      .catch(err => {
+        log.error(err);
+      });
+  },
 };
--- a/browser/extensions/shield-recipe-client/moz.build
+++ b/browser/extensions/shield-recipe-client/moz.build
@@ -10,13 +10,13 @@ DEFINES['MOZ_APP_MAXVERSION'] = CONFIG['
 FINAL_TARGET_FILES.features['shield-recipe-client@mozilla.org'] += [
   'bootstrap.js',
 ]
 
 FINAL_TARGET_PP_FILES.features['shield-recipe-client@mozilla.org'] += [
   'install.rdf.in'
 ]
 
-BROWSER_CHROME_MANIFESTS += [
-    'test/browser.ini',
-]
+BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
+
+XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
 
 JAR_MANIFESTS += ['jar.mn']
--- a/browser/extensions/shield-recipe-client/test/.eslintrc.js
+++ b/browser/extensions/shield-recipe-client/test/.eslintrc.js
@@ -4,14 +4,17 @@ module.exports = {
   globals: {
     Assert: false,
     BrowserTestUtils: false,
     add_task: false,
     is: false,
     isnot: false,
     ok: false,
     SpecialPowers: false,
+    SimpleTest: false,
+    registerCleanupFunction: false,
   },
   rules: {
     "spaced-comment": 2,
     "space-before-function-paren": 2,
+    "require-yield": 0
   }
 };
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/TestUtils.jsm
+++ /dev/null
@@ -1,21 +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";
-
-/* eslint-disable no-console */
-this.EXPORTED_SYMBOLS = ["TestUtils"];
-
-this.TestUtils = {
-  promiseTest(test) {
-    return function(assert, done) {
-      test(assert)
-      .catch(err => {
-        console.error(err);
-        assert.ok(false, err);
-      })
-      .then(() => done());
-    };
-  },
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser.ini
+++ /dev/null
@@ -1,9 +0,0 @@
-[browser_driver_uuids.js]
-[browser_env_expressions.js]
-[browser_EventEmitter.js]
-[browser_Storage.js]
-[browser_Heartbeat.js]
-[browser_NormandyApi.js]
-  support-files =
-    test_server.sjs
-[browser_RecipeRunner.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/Utils.jsm
@@ -0,0 +1,30 @@
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+
+this.EXPORTED_SYMBOLS = ["Utils"];
+
+this.Utils = {
+  UUID_REGEX: /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/,
+
+  withSandboxManager(Assert, testGenerator) {
+    return function* inner() {
+      const sandboxManager = new SandboxManager();
+      sandboxManager.addHold("test running");
+
+      yield testGenerator(sandboxManager);
+
+      sandboxManager.removeHold("test running");
+      yield sandboxManager.isNuked()
+        .then(() => Assert.ok(true, "sandbox is nuked"))
+        .catch(e => Assert.ok(false, "sandbox is nuked", e));
+    };
+  },
+
+  withDriver(Assert, testGenerator) {
+    return Utils.withSandboxManager(Assert, function* inner(sandboxManager) {
+      const driver = new NormandyDriver(sandboxManager);
+      yield testGenerator(driver);
+    });
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser.ini
@@ -0,0 +1,10 @@
+[browser_NormandyDriver.js]
+[browser_EnvExpressions.js]
+[browser_EventEmitter.js]
+[browser_Storage.js]
+[browser_Heartbeat.js]
+[browser_NormandyApi.js]
+  support-files =
+    test_server.sjs
+[browser_RecipeRunner.js]
+[browser_LogManager.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_EnvExpressions.js
@@ -0,0 +1,85 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/TelemetryController.jsm", this);
+Cu.import("resource://gre/modules/Task.jsm", this);
+
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm", this);
+Cu.import("resource://shield-recipe-client/test/browser/Utils.jsm", this);
+
+add_task(function* () {
+  // setup
+  yield TelemetryController.submitExternalPing("testfoo", {foo: 1});
+  yield TelemetryController.submitExternalPing("testbar", {bar: 2});
+
+  let val;
+  // Test that basic expressions work
+  val = yield EnvExpressions.eval("2+2");
+  is(val, 4, "basic expression works");
+
+  // Test that multiline expressions work
+  val = yield EnvExpressions.eval(`
+    2
+    +
+    2
+  `);
+  is(val, 4, "multiline expression works");
+
+  // Test it can access telemetry
+  val = yield EnvExpressions.eval("telemetry");
+  is(typeof val, "object", "Telemetry is accesible");
+
+  // Test it reads different types of telemetry
+  val = yield EnvExpressions.eval("telemetry");
+  is(val.testfoo.payload.foo, 1, "value 'foo' is in mock telemetry");
+  is(val.testbar.payload.bar, 2, "value 'bar' is in mock telemetry");
+
+  // Test has a date transform
+  val = yield EnvExpressions.eval('"2016-04-22"|date');
+  const d = new Date(Date.UTC(2016, 3, 22)); // months are 0 based
+  is(val.toString(), d.toString(), "Date transform works");
+
+  // Test dates are comparable
+  const context = {someTime: Date.UTC(2016, 0, 1)};
+  val = yield EnvExpressions.eval('"2015-01-01"|date < someTime', context);
+  ok(val, "dates are comparable with less-than");
+  val = yield EnvExpressions.eval('"2017-01-01"|date > someTime', context);
+  ok(val, "dates are comparable with greater-than");
+
+  // Test stable sample returns true for matching samples
+  val = yield EnvExpressions.eval('["test"]|stableSample(1)');
+  is(val, true, "Stable sample returns true for 100% sample");
+
+  // Test stable sample returns true for matching samples
+  val = yield EnvExpressions.eval('["test"]|stableSample(0)');
+  is(val, false, "Stable sample returns false for 0% sample");
+
+  // Test stable sample for known samples
+  val = yield EnvExpressions.eval('["test-1"]|stableSample(0.5)');
+  is(val, true, "Stable sample returns true for a known sample");
+  val = yield EnvExpressions.eval('["test-4"]|stableSample(0.5)');
+  is(val, false, "Stable sample returns false for a known sample");
+
+  // Test bucket sample for known samples
+  val = yield EnvExpressions.eval('["test-1"]|bucketSample(0, 5, 10)');
+  is(val, true, "Bucket sample returns true for a known sample");
+  val = yield EnvExpressions.eval('["test-4"]|bucketSample(0, 5, 10)');
+  is(val, false, "Bucket sample returns false for a known sample");
+
+  // Test that userId is available
+  val = yield EnvExpressions.eval("normandy.userId");
+  ok(Utils.UUID_REGEX.test(val), "userId available");
+
+  // test that it pulls from the right preference
+  yield SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.user_id", "fake id"]]});
+  val = yield EnvExpressions.eval("normandy.userId");
+  Assert.equal(val, "fake id", "userId is pulled from preferences");
+
+  // test that it merges context correctly, `userId` comes from the default context, and
+  // `injectedValue` comes from us. Expect both to be on the final `normandy` object.
+  val = yield EnvExpressions.eval(
+    "[normandy.userId, normandy.injectedValue]",
+    {normandy: {injectedValue: "injected"}});
+  Assert.deepEqual(val, ["fake id", "injected"], "context is correctly merged");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
@@ -0,0 +1,127 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/test/browser/Utils.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/EventEmitter.jsm", this);
+
+const evidence = {
+  a: 0,
+  b: 0,
+  c: 0,
+  log: "",
+};
+
+function listenerA(x = 1) {
+  evidence.a += x;
+  evidence.log += "a";
+}
+
+function listenerB(x = 1) {
+  evidence.b += x;
+  evidence.log += "b";
+}
+
+function listenerC(x = 1) {
+  evidence.c += x;
+  evidence.log += "c";
+}
+
+add_task(Utils.withSandboxManager(Assert, function* (sandboxManager) {
+  const eventEmitter = new EventEmitter(sandboxManager);
+
+  // Fire an unrelated event, to make sure nothing goes wrong
+  eventEmitter.on("nothing");
+
+  // bind listeners
+  eventEmitter.on("event", listenerA);
+  eventEmitter.on("event", listenerB);
+  eventEmitter.once("event", listenerC);
+
+  // one event for all listeners
+  eventEmitter.emit("event");
+  // another event for a and b, since c should have turned off already
+  eventEmitter.emit("event", 10);
+
+  // make sure events haven't actually fired yet, just queued
+  Assert.deepEqual(evidence, {
+    a: 0,
+    b: 0,
+    c: 0,
+    log: "",
+  }, "events are fired async");
+
+  // Spin the event loop to run events, so we can safely "off"
+  yield Promise.resolve();
+
+  // Check intermediate event results
+  Assert.deepEqual(evidence, {
+    a: 11,
+    b: 11,
+    c: 1,
+    log: "abcab",
+  }, "intermediate events are fired");
+
+  // one more event for a
+  eventEmitter.off("event", listenerB);
+  eventEmitter.emit("event", 100);
+
+  // And another unrelated event
+  eventEmitter.on("nothing");
+
+  // Spin the event loop to run events
+  yield Promise.resolve();
+
+  Assert.deepEqual(evidence, {
+    a: 111,
+    b: 11,
+    c: 1,
+    log: "abcaba",  // events are in order
+  }, "events fired as expected");
+
+  // Test that mutating the data passed to the event doesn't actually
+  // mutate it for other events.
+  let handlerRunCount = 0;
+  const mutationHandler = data => {
+    handlerRunCount++;
+    data.count++;
+    is(data.count, 1, "Event data is not mutated between handlers.");
+  };
+  eventEmitter.on("mutationTest", mutationHandler);
+  eventEmitter.on("mutationTest", mutationHandler);
+
+  const data = {count: 0};
+  eventEmitter.emit("mutationTest", data);
+  yield Promise.resolve();
+
+  is(handlerRunCount, 2, "Mutation handler was executed twice.");
+  is(data.count, 0, "Event data cannot be mutated by handlers.");
+}));
+
+add_task(Utils.withSandboxManager(Assert, function* sandboxedEmitter(sandboxManager) {
+  const eventEmitter = new EventEmitter(sandboxManager);
+
+  // Event handlers inside the sandbox should be run in response to
+  // events triggered outside the sandbox.
+  sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
+  sandboxManager.evalInSandbox(`
+    this.eventCounts = {on: 0, once: 0};
+    emitter.on("event", value => {
+      this.eventCounts.on += value;
+    });
+    emitter.once("eventOnce", value => {
+      this.eventCounts.once += value;
+    });
+  `);
+
+  eventEmitter.emit("event", 5);
+  eventEmitter.emit("event", 10);
+  eventEmitter.emit("eventOnce", 5);
+  eventEmitter.emit("eventOnce", 10);
+  yield Promise.resolve();
+
+  const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
+  Assert.deepEqual(eventCounts, {
+    on: 15,
+    once: 5,
+  }, "Events emitted outside a sandbox trigger handlers within a sandbox.");
+}));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_Heartbeat.js
@@ -0,0 +1,182 @@
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+
+/**
+ * Assert an array is in non-descending order, and that every element is a number
+ */
+function assertOrdered(arr) {
+  for (let i = 0; i < arr.length; i++) {
+    Assert.equal(typeof arr[i], "number", `element ${i} is type "number"`);
+  }
+  for (let i = 0; i < arr.length - 1; i++) {
+    Assert.lessOrEqual(arr[i], arr[i + 1],
+      `element ${i} is less than or equal to element ${i + 1}`);
+  }
+}
+
+/* Close every notification in a target window and notification box */
+function closeAllNotifications(targetWindow, notificationBox) {
+  if (notificationBox.allNotifications.length === 0) {
+    return Promise.resolve();
+  }
+
+
+  return new Promise(resolve => {
+    const notificationSet = new Set(notificationBox.allNotifications);
+
+    const observer = new targetWindow.MutationObserver(mutations => {
+      for (const mutation of mutations) {
+        for (let i = 0; i < mutation.removedNodes.length; i++) {
+          const node = mutation.removedNodes.item(i);
+          if (notificationSet.has(node)) {
+            notificationSet.delete(node);
+          }
+        }
+      }
+      if (notificationSet.size === 0) {
+        Assert.equal(notificationBox.allNotifications.length, 0, "No notifications left");
+        observer.disconnect();
+        resolve();
+      }
+    });
+
+    observer.observe(notificationBox, {childList: true});
+
+    for (const notification of notificationBox.allNotifications) {
+      notification.close();
+    }
+  });
+}
+
+/* Check that the correct telemetry was sent */
+function assertTelemetrySent(hb, eventNames) {
+  return new Promise(resolve => {
+    hb.eventEmitter.once("TelemetrySent", payload => {
+      const events = [0];
+      for (const name of eventNames) {
+        Assert.equal(typeof payload[name], "number", `payload field ${name} is a number`);
+        events.push(payload[name]);
+      }
+      events.push(Date.now());
+
+      assertOrdered(events);
+      resolve();
+    });
+  });
+}
+
+
+const sandboxManager = new SandboxManager();
+sandboxManager.addHold("test running");
+
+
+// Several of the behaviors of heartbeat prompt are mutually exclusive, so checks are broken up
+// into three batches.
+
+/* Batch #1 - General UI, Stars, and telemetry data */
+add_task(function* () {
+  const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+  const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
+
+  const preCount = notificationBox.childElementCount;
+  const hb = new Heartbeat(targetWindow, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    engagementButtonLabel: undefined,
+    learnMoreMessage: "Learn More",
+    learnMoreUrl: "https://example.org/learnmore",
+  });
+
+  // Check UI
+  const learnMoreEl = hb.notice.querySelector(".text-link");
+  const messageEl = targetWindow.document.getAnonymousElementByAttribute(hb.notice, "anonid", "messageText");
+  Assert.equal(notificationBox.childElementCount, preCount + 1, "Correct number of notifications open");
+  Assert.equal(hb.notice.querySelectorAll(".star-x").length, 5, "Correct number of stars");
+  Assert.equal(hb.notice.querySelectorAll(".notification-button").length, 0, "Engagement button not shown");
+  Assert.equal(learnMoreEl.href, "https://example.org/learnmore", "Learn more url correct");
+  Assert.equal(learnMoreEl.value, "Learn More", "Learn more label correct");
+  Assert.equal(messageEl.textContent, "test", "Message is correct");
+
+  // Check that when clicking the learn more link, a tab opens with the right URL
+  const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
+  learnMoreEl.click();
+  const tab = yield tabOpenPromise;
+  const tabUrl = yield BrowserTestUtils.browserLoaded(
+    tab.linkedBrowser, true, url => url && url !== "about:blank");
+
+  Assert.equal(tabUrl, "https://example.org/learnmore", "Learn more link opened the right url");
+
+  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "learnMoreTS", "closedTS"]);
+  // Close notification to trigger telemetry to be sent
+  yield closeAllNotifications(targetWindow, notificationBox);
+  yield telemetrySentPromise;
+  yield BrowserTestUtils.removeTab(tab);
+});
+
+
+// Batch #2 - Engagement buttons
+add_task(function* () {
+  const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+  const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
+  const hb = new Heartbeat(targetWindow, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    engagementButtonLabel: "Click me!",
+    postAnswerUrl: "https://example.org/postAnswer",
+    learnMoreMessage: "Learn More",
+    learnMoreUrl: "https://example.org/learnMore",
+  });
+  const engagementButton = hb.notice.querySelector(".notification-button");
+
+  Assert.equal(hb.notice.querySelectorAll(".star-x").length, 0, "Stars not shown");
+  Assert.ok(engagementButton, "Engagement button added");
+  Assert.equal(engagementButton.label, "Click me!", "Engagement button has correct label");
+
+  const engagementEl = hb.notice.querySelector(".notification-button");
+  const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
+  engagementEl.click();
+  const tab = yield tabOpenPromise;
+  const tabUrl = yield BrowserTestUtils.browserLoaded(
+        tab.linkedBrowser, true, url => url && url !== "about:blank");
+  // the postAnswer url gets query parameters appended onto the end, so use Assert.startsWith instead of Assert.equal
+  Assert.ok(tabUrl.startsWith("https://example.org/postAnswer"), "Engagement button opened the right url");
+
+  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "engagedTS", "closedTS"]);
+  // Close notification to trigger telemetry to be sent
+  yield closeAllNotifications(targetWindow, notificationBox);
+  yield telemetrySentPromise;
+  yield BrowserTestUtils.removeTab(tab);
+});
+
+// Batch 3 - Closing the window while heartbeat is open
+add_task(function* () {
+  const targetWindow = yield BrowserTestUtils.openNewBrowserWindow();
+
+  const hb = new Heartbeat(targetWindow, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+  });
+
+  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "windowClosedTS"]);
+  // triggers sending ping to normandy
+  yield BrowserTestUtils.closeWindow(targetWindow);
+  yield telemetrySentPromise;
+});
+
+
+// Cleanup
+add_task(function* () {
+  // Make sure the sandbox is clean.
+  sandboxManager.removeHold("test running");
+  yield sandboxManager.isNuked()
+    .then(() => ok(true, "sandbox is nuked"))
+    .catch(e => ok(false, "sandbox is nuked", e));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_LogManager.js
@@ -0,0 +1,28 @@
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Log.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/LogManager.jsm", this);
+
+add_task(function*() {
+  // Ensure that configuring the logger affects all generated loggers.
+  const firstLogger = LogManager.getLogger("first");
+  LogManager.configure(5);
+  const secondLogger = LogManager.getLogger("second");
+  is(firstLogger.level, 5, "First logger level inherited from root logger.");
+  is(secondLogger.level, 5, "Second logger level inherited from root logger.");
+
+  // Ensure that our loggers have at least one appender.
+  LogManager.configure(Log.Level.Warn);
+  const logger  = LogManager.getLogger("test");
+  ok(logger.appenders.length > 0, true, "Loggers have at least one appender.");
+
+  // Ensure our loggers log to the console.
+  yield new Promise(resolve => {
+    SimpleTest.waitForExplicitFinish();
+    SimpleTest.monitorConsole(resolve, [{message: /legend has it/}]);
+    logger.warn("legend has it");
+    SimpleTest.endMonitorConsole();
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_NormandyApi.js
@@ -0,0 +1,21 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
+
+add_task(function* () {
+  // Point the add-on to the test server.
+  yield SpecialPowers.pushPrefEnv({
+    set: [
+      [
+        "extensions.shield-recipe-client.api_url",
+        "http://mochi.test:8888/browser/browser/extensions/shield-recipe-client/test",
+      ],
+    ],
+  });
+
+  // Test that NormandyApi can fetch from the test server.
+  const response = yield NormandyApi.get("browser/test_server.sjs");
+  const data = yield response.json();
+  Assert.deepEqual(data, {test: "data"}, "NormandyApi returned incorrect server data.");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
@@ -0,0 +1,20 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/test/browser/Utils.jsm", this);
+Cu.import("resource://gre/modules/Console.jsm", this);
+
+add_task(Utils.withDriver(Assert, function* uuids(driver) {
+  // Test that it is a UUID
+  const uuid1 = driver.uuid();
+  ok(Utils.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(Utils.withDriver(Assert, function* userId(driver) {
+  // Test that userId is a UUID
+  ok(Utils.UUID_REGEX.test(driver.userId), "userId is a uuid");
+}));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
@@ -0,0 +1,87 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
+
+add_task(function* execute() {
+  // Test that RecipeRunner can execute a basic recipe/action and return
+  // the result of execute.
+  const recipe = {
+    foo: "bar",
+  };
+  const actionScript = `
+    class TestAction {
+      constructor(driver, recipe) {
+        this.recipe = recipe;
+      }
+
+      execute() {
+        return new Promise(resolve => {
+          resolve({foo: this.recipe.foo});
+        });
+      }
+    }
+
+    registerAction('test-action', TestAction);
+  `;
+
+  const result = yield RecipeRunner.executeAction(recipe, {}, actionScript);
+  is(result.foo, "bar", "Recipe executed correctly");
+});
+
+add_task(function* error() {
+  // Test that RecipeRunner rejects with error messages from within the
+  // sandbox.
+  const actionScript = `
+    class TestAction {
+      execute() {
+        return new Promise((resolve, reject) => {
+          reject(new Error("ERROR MESSAGE"));
+        });
+      }
+    }
+
+    registerAction('test-action', TestAction);
+  `;
+
+  let gotException = false;
+  try {
+    yield RecipeRunner.executeAction({}, {}, actionScript);
+  } catch (err) {
+    gotException = true;
+    is(err.message, "ERROR MESSAGE", "RecipeRunner throws errors from the sandbox correctly.");
+  }
+  ok(gotException, "RecipeRunner threw an error from the sandbox.");
+});
+
+add_task(function* globalObject() {
+  // Test that window is an alias for the global object, and that it
+  // has some expected functions available on it.
+  const actionScript = `
+    window.setOnWindow = "set";
+    this.setOnGlobal = "set";
+
+    class TestAction {
+      execute() {
+        return new Promise(resolve => {
+          resolve({
+            setOnWindow: setOnWindow,
+            setOnGlobal: window.setOnGlobal,
+            setTimeoutExists: setTimeout !== undefined,
+            clearTimeoutExists: clearTimeout !== undefined,
+          });
+        });
+      }
+    }
+
+    registerAction('test-action', TestAction);
+  `;
+
+  const result = yield RecipeRunner.executeAction({}, {}, actionScript);
+  Assert.deepEqual(result, {
+    setOnWindow: "set",
+    setOnGlobal: "set",
+    setTimeoutExists: true,
+    clearTimeoutExists: true,
+  }, "sandbox.window is the global object and has expected functions.");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_Storage.js
@@ -0,0 +1,47 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/Storage.jsm", this);
+
+const fakeSandbox = {Promise};
+const store1 = Storage.makeStorage("prefix1", fakeSandbox);
+const store2 = Storage.makeStorage("prefix2", fakeSandbox);
+
+add_task(function* () {
+  // Make sure values return null before being set
+  Assert.equal(yield store1.getItem("key"), null);
+  Assert.equal(yield store2.getItem("key"), null);
+
+  // Set values to check
+  yield store1.setItem("key", "value1");
+  yield store2.setItem("key", "value2");
+
+  // Check that they are available
+  Assert.equal(yield store1.getItem("key"), "value1");
+  Assert.equal(yield store2.getItem("key"), "value2");
+
+  // Remove them, and check they are gone
+  yield store1.removeItem("key");
+  yield store2.removeItem("key");
+  Assert.equal(yield store1.getItem("key"), null);
+  Assert.equal(yield store2.getItem("key"), null);
+
+  // Check that numbers are stored as numbers (not strings)
+  yield store1.setItem("number", 42);
+  Assert.equal(yield store1.getItem("number"), 42);
+
+  // Check complex types work
+  const complex = {a: 1, b: [2, 3], c: {d: 4}};
+  yield store1.setItem("complex", complex);
+  Assert.deepEqual(yield store1.getItem("complex"), complex);
+
+  // Check that clearing the storage removes data from multiple
+  // prefixes.
+  yield store1.setItem("removeTest", 1);
+  yield store2.setItem("removeTest", 2);
+  Assert.equal(yield store1.getItem("removeTest"), 1);
+  Assert.equal(yield store2.getItem("removeTest"), 2);
+  yield Storage.clearAllStorage();
+  Assert.equal(yield store1.getItem("removeTest"), null);
+  Assert.equal(yield store2.getItem("removeTest"), null);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/test_server.sjs
@@ -0,0 +1,8 @@
+function handleRequest(request, response) {
+  // Allow cross-origin, so you can XHR to it!
+  response.setHeader("Access-Control-Allow-Origin", "*", false);
+  // Avoid confusing cache behaviors
+  response.setHeader("Cache-Control", "no-cache", false);
+  response.setHeader("Content-Type", "application/json", false);
+  response.write(JSON.stringify({test: "data"}))
+}
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser_EventEmitter.js
+++ /dev/null
@@ -1,92 +0,0 @@
-"use strict";
-
-const {utils: Cu} = Components;
-Cu.import("resource://gre/modules/Log.jsm", this);
-Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
-Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
-
-const sandboxManager = new SandboxManager();
-sandboxManager.addHold("test running");
-const driver = new NormandyDriver(sandboxManager);
-const sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
-const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
-
-
-const evidence = {
-  a: 0,
-  b: 0,
-  c: 0,
-  log: "",
-};
-
-function listenerA(x = 1) {
-  evidence.a += x;
-  evidence.log += "a";
-}
-
-function listenerB(x = 1) {
-  evidence.b += x;
-  evidence.log += "b";
-}
-
-function listenerC(x = 1) {
-  evidence.c += x;
-  evidence.log += "c";
-}
-
-add_task(function* () {
-  // Fire an unrelated event, to make sure nothing goes wrong
-  eventEmitter.on("nothing");
-
-  // bind listeners
-  eventEmitter.on("event", listenerA);
-  eventEmitter.on("event", listenerB);
-  eventEmitter.once("event", listenerC);
-
-  // one event for all listeners
-  eventEmitter.emit("event");
-  // another event for a and b, since c should have turned off already
-  eventEmitter.emit("event", 10);
-
-  // make sure events haven't actually fired yet, just queued
-  Assert.deepEqual(evidence, {
-    a: 0,
-    b: 0,
-    c: 0,
-    log: "",
-  }, "events are fired async");
-
-  // Spin the event loop to run events, so we can safely "off"
-  yield Promise.resolve();
-
-  // Check intermediate event results
-  Assert.deepEqual(evidence, {
-    a: 11,
-    b: 11,
-    c: 1,
-    log: "abcab",
-  }, "intermediate events are fired");
-
-  // one more event for a
-  eventEmitter.off("event", listenerB);
-  eventEmitter.emit("event", 100);
-
-  // And another unrelated event
-  eventEmitter.on("nothing");
-
-  // Spin the event loop to run events
-  yield Promise.resolve();
-
-  Assert.deepEqual(evidence, {
-    a: 111,
-    b: 11,
-    c: 1,
-    log: "abcaba",  // events are in order
-  }, "events fired as expected");
-
-  sandboxManager.removeHold("test running");
-
-  yield sandboxManager.isNuked()
-    .then(() => ok(true, "sandbox is nuked"))
-    .catch(e => ok(false, "sandbox is nuked", e));
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser_Heartbeat.js
+++ /dev/null
@@ -1,188 +0,0 @@
-"use strict";
-
-const {utils: Cu} = Components;
-
-Cu.import("resource://gre/modules/Services.jsm", this);
-Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm", this);
-Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
-Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
-
-/**
- * Assert an array is in non-descending order, and that every element is a number
- */
-function assertOrdered(arr) {
-  for (let i = 0; i < arr.length; i++) {
-    Assert.equal(typeof arr[i], "number", `element ${i} is type "number"`);
-  }
-  for (let i = 0; i < arr.length - 1; i++) {
-    Assert.lessOrEqual(arr[i], arr[i + 1],
-      `element ${i} is less than or equal to element ${i + 1}`);
-  }
-}
-
-/* Close every notification in a target window and notification box */
-function closeAllNotifications(targetWindow, notificationBox) {
-  if (notificationBox.allNotifications.length === 0) {
-    return Promise.resolve();
-  }
-
-
-  return new Promise(resolve => {
-    const notificationSet = new Set(notificationBox.allNotifications);
-
-    const observer = new targetWindow.MutationObserver(mutations => {
-      for (const mutation of mutations) {
-        for (let i = 0; i < mutation.removedNodes.length; i++) {
-          const node = mutation.removedNodes.item(i);
-          if (notificationSet.has(node)) {
-            notificationSet.delete(node);
-          }
-        }
-      }
-      if (notificationSet.size === 0) {
-        Assert.equal(notificationBox.allNotifications.length, 0, "No notifications left");
-        observer.disconnect();
-        resolve();
-      }
-    });
-
-    observer.observe(notificationBox, {childList: true});
-
-    for (const notification of notificationBox.allNotifications) {
-      notification.close();
-    }
-  });
-}
-
-/* Check that the correct telmetry was sent */
-function assertTelemetrySent(hb, eventNames) {
-  return new Promise(resolve => {
-    hb.eventEmitter.once("TelemetrySent", payload => {
-      const events = [0];
-      for (const name of eventNames) {
-        Assert.equal(typeof payload[name], "number", `payload field ${name} is a number`);
-        events.push(payload[name]);
-      }
-      events.push(Date.now());
-
-      assertOrdered(events);
-      resolve();
-    });
-  });
-}
-
-
-const sandboxManager = new SandboxManager();
-const driver = new NormandyDriver(sandboxManager);
-sandboxManager.addHold("test running");
-const sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
-
-
-// Several of the behaviors of heartbeat prompt are mutually exclusive, so checks are broken up
-// into three batches.
-
-/* Batch #1 - General UI, Stars, and telemetry data */
-add_task(function* () {
-  const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
-  const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
-  const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
-
-  const preCount = notificationBox.childElementCount;
-  const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
-    testing: true,
-    flowId: "test",
-    message: "test",
-    engagementButtonLabel: undefined,
-    learnMoreMessage: "Learn More",
-    learnMoreUrl: "https://example.org/learnmore",
-  });
-
-  // Check UI
-  const learnMoreEl = hb.notice.querySelector(".text-link");
-  const messageEl = targetWindow.document.getAnonymousElementByAttribute(hb.notice, "anonid", "messageText");
-  Assert.equal(notificationBox.childElementCount, preCount + 1, "Correct number of notifications open");
-  Assert.equal(hb.notice.querySelectorAll(".star-x").length, 5, "Correct number of stars");
-  Assert.equal(hb.notice.querySelectorAll(".notification-button").length, 0, "Engagement button not shown");
-  Assert.equal(learnMoreEl.href, "https://example.org/learnmore", "Learn more url correct");
-  Assert.equal(learnMoreEl.value, "Learn More", "Learn more label correct");
-  Assert.equal(messageEl.textContent, "test", "Message is correct");
-
-  // Check that when clicking the learn more link, a tab opens with the right URL
-  const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
-  learnMoreEl.click();
-  const tab = yield tabOpenPromise;
-  const tabUrl = yield BrowserTestUtils.browserLoaded(
-    tab.linkedBrowser, true, url => url && url !== "about:blank");
-
-  Assert.equal(tabUrl, "https://example.org/learnmore", "Learn more link opened the right url");
-
-  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "learnMoreTS", "closedTS"]);
-  // Close notification to trigger telemetry to be sent
-  yield closeAllNotifications(targetWindow, notificationBox);
-  yield telemetrySentPromise;
-  yield BrowserTestUtils.removeTab(tab);
-});
-
-
-// Batch #2 - Engagement buttons
-add_task(function* () {
-  const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
-  const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
-  const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
-  const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
-    testing: true,
-    flowId: "test",
-    message: "test",
-    engagementButtonLabel: "Click me!",
-    postAnswerUrl: "https://example.org/postAnswer",
-    learnMoreMessage: "Learn More",
-    learnMoreUrl: "https://example.org/learnMore",
-  });
-  const engagementButton = hb.notice.querySelector(".notification-button");
-
-  Assert.equal(hb.notice.querySelectorAll(".star-x").length, 0, "Stars not shown");
-  Assert.ok(engagementButton, "Engagement button added");
-  Assert.equal(engagementButton.label, "Click me!", "Engagement button has correct label");
-
-  const engagementEl = hb.notice.querySelector(".notification-button");
-  const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
-  engagementEl.click();
-  const tab = yield tabOpenPromise;
-  const tabUrl = yield BrowserTestUtils.browserLoaded(
-        tab.linkedBrowser, true, url => url && url !== "about:blank");
-  // the postAnswer url gets query parameters appended onto the end, so use Assert.startsWith instead of Assert.equal
-  Assert.ok(tabUrl.startsWith("https://example.org/postAnswer"), "Engagement button opened the right url");
-
-  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "engagedTS", "closedTS"]);
-  // Close notification to trigger telemetry to be sent
-  yield closeAllNotifications(targetWindow, notificationBox);
-  yield telemetrySentPromise;
-  yield BrowserTestUtils.removeTab(tab);
-});
-
-// Batch 3 - Closing the window while heartbeat is open
-add_task(function* () {
-  const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
-  const targetWindow = yield BrowserTestUtils.openNewBrowserWindow();
-
-  const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
-    testing: true,
-    flowId: "test",
-    message: "test",
-  });
-
-  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "windowClosedTS"]);
-  // triggers sending ping to normandy
-  yield BrowserTestUtils.closeWindow(targetWindow);
-  yield telemetrySentPromise;
-});
-
-
-// Cleanup
-add_task(function* () {
-  // Make sure the sandbox is clean.
-  sandboxManager.removeHold("test running");
-  yield sandboxManager.isNuked()
-    .then(() => ok(true, "sandbox is nuked"))
-    .catch(e => ok(false, "sandbox is nuked", e));
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser_NormandyApi.js
+++ /dev/null
@@ -1,21 +0,0 @@
-"use strict";
-
-const {utils: Cu} = Components;
-Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
-
-add_task(function* () {
-  // Point the add-on to the test server.
-  yield SpecialPowers.pushPrefEnv({
-    set: [
-      [
-        "extensions.shield-recipe-client.api_url",
-        "http://mochi.test:8888/browser/browser/extensions/shield-recipe-client/test",
-      ]
-    ]
-  })
-
-  // Test that NormandyApi can fetch from the test server.
-  const response = yield NormandyApi.get("test_server.sjs");
-  const data = yield response.json();
-  Assert.deepEqual(data, {test: "data"}, "NormandyApi returned incorrect server data.");
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser_RecipeRunner.js
+++ /dev/null
@@ -1,29 +0,0 @@
-"use strict";
-
-const {utils: Cu} = Components;
-Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
-
-add_task(function*() {
-  // Test that RecipeRunner can execute a basic recipe/action.
-  const recipe = {
-    foo: "bar",
-  };
-  const actionScript = `
-    class TestAction {
-      constructor(driver, recipe) {
-        this.recipe = recipe;
-      }
-
-      execute() {
-        return new Promise(resolve => {
-          resolve(this.recipe.foo);
-        });
-      }
-    }
-
-    registerAction('test-action', TestAction);
-  `;
-
-  const result = yield RecipeRunner.executeAction(recipe, {}, actionScript);
-  is(result, "bar", "Recipe executed correctly");
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser_Storage.js
+++ /dev/null
@@ -1,37 +0,0 @@
-"use strict";
-
-const {utils: Cu} = Components;
-Cu.import("resource://shield-recipe-client/lib/Storage.jsm", this);
-
-const fakeSandbox = {Promise};
-const store1 = Storage.makeStorage("prefix1", fakeSandbox);
-const store2 = Storage.makeStorage("prefix2", fakeSandbox);
-
-add_task(function* () {
-  // Make sure values return null before being set
-  Assert.equal(yield store1.getItem("key"), null);
-  Assert.equal(yield store2.getItem("key"), null);
-
-  // Set values to check
-  yield store1.setItem("key", "value1");
-  yield store2.setItem("key", "value2");
-
-  // Check that they are available
-  Assert.equal(yield store1.getItem("key"), "value1");
-  Assert.equal(yield store2.getItem("key"), "value2");
-
-  // Remove them, and check they are gone
-  yield store1.removeItem("key");
-  yield store2.removeItem("key");
-  Assert.equal(yield store1.getItem("key"), null);
-  Assert.equal(yield store2.getItem("key"), null);
-
-  // Check that numbers are stored as numbers (not strings)
-  yield store1.setItem("number", 42);
-  Assert.equal(yield store1.getItem("number"), 42);
-
-  // Check complex types work
-  const complex = {a: 1, b: [2, 3], c: {d: 4}};
-  yield store1.setItem("complex", complex);
-  Assert.deepEqual(yield store1.getItem("complex"), complex);
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser_driver_uuids.js
+++ /dev/null
@@ -1,26 +0,0 @@
-"use strict";
-
-const {utils: Cu} = Components;
-Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
-Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
-
-add_task(function* () {
-  const sandboxManager = new SandboxManager();
-  sandboxManager.addHold("test running");
-  let driver = new NormandyDriver(sandboxManager);
-
-  // Test that UUID look about right
-  const uuid1 = driver.uuid();
-  ok(/^[a-f0-9-]{36}$/.test(uuid1), "valid uuid format");
-
-  // Test that UUIDs are different each time
-  const uuid2 = driver.uuid();
-  isnot(uuid1, uuid2, "uuids are unique");
-
-  driver = null;
-  sandboxManager.removeHold("test running");
-
-  yield sandboxManager.isNuked()
-    .then(() => ok(true, "sandbox is nuked"))
-    .catch(e => ok(false, "sandbox is nuked", e));
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser_env_expressions.js
+++ /dev/null
@@ -1,56 +0,0 @@
-"use strict";
-
-const {utils: Cu} = Components;
-Cu.import("resource://gre/modules/TelemetryController.jsm", this);
-Cu.import("resource://gre/modules/Task.jsm", this);
-
-Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm", this);
-Cu.import("resource://gre/modules/Log.jsm", this);
-
-add_task(function* () {
-  // setup
-  yield TelemetryController.submitExternalPing("testfoo", {foo: 1});
-  yield TelemetryController.submitExternalPing("testbar", {bar: 2});
-
-  let val;
-  // Test that basic expressions work
-  val = yield EnvExpressions.eval("2+2");
-  is(val, 4, "basic expression works");
-
-  // Test that multiline expressions work
-  val = yield EnvExpressions.eval(`
-    2
-    +
-    2
-  `);
-  is(val, 4, "multiline expression works");
-
-  // Test it can access telemetry
-  val = yield EnvExpressions.eval("telemetry");
-  is(typeof val, "object", "Telemetry is accesible");
-
-  // Test it reads different types of telemetry
-  val = yield EnvExpressions.eval("telemetry");
-  is(val.testfoo.payload.foo, 1, "value 'foo' is in mock telemetry");
-  is(val.testbar.payload.bar, 2, "value 'bar' is in mock telemetry");
-
-  // Test has a date transform
-  val = yield EnvExpressions.eval('"2016-04-22"|date');
-  const d = new Date(Date.UTC(2016, 3, 22)); // months are 0 based
-  is(val.toString(), d.toString(), "Date transform works");
-
-  // Test dates are comparable
-  const context = {someTime: Date.UTC(2016, 0, 1)};
-  val = yield EnvExpressions.eval('"2015-01-01"|date < someTime', context);
-  ok(val, "dates are comparable with less-than");
-  val = yield EnvExpressions.eval('"2017-01-01"|date > someTime', context);
-  ok(val, "dates are comparable with greater-than");
-
-  // Test stable sample returns true for matching samples
-  val = yield EnvExpressions.eval('["test"]|stableSample(1)');
-  is(val, true, "Stable sample returns true for 100% sample");
-
-  // Test stable sample returns true for matching samples
-  val = yield EnvExpressions.eval('["test"]|stableSample(0)');
-  is(val, false, "Stable sample returns false for 0% sample");
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/test_server.sjs
+++ /dev/null
@@ -1,8 +0,0 @@
-function handleRequest(request, response) {
-  // Allow cross-origin, so you can XHR to it!
-  response.setHeader("Access-Control-Allow-Origin", "*", false);
-  // Avoid confusing cache behaviors
-  response.setHeader("Cache-Control", "no-cache", false);
-  response.setHeader("Content-Type", "application/json", false);
-  response.write(JSON.stringify({test: "data"}))
-}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/test_Sampling.js
@@ -0,0 +1,63 @@
+"use strict";
+// Cu is defined in xpc_head.js
+/* globals Cu, equal */
+
+Cu.import("resource://gre/modules/Task.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Sampling.jsm", this);
+
+add_task(function* testStableSample() {
+  // Absolute samples
+  equal(yield Sampling.stableSample("test", 1), true, "stableSample returns true for 100% sample");
+  equal(yield Sampling.stableSample("test", 0), false, "stableSample returns false for 0% sample");
+
+  // Known samples. The numbers are nonces to make the tests pass
+  equal(yield Sampling.stableSample("test-0", 0.5), true, "stableSample returns true for known matching sample");
+  equal(yield Sampling.stableSample("test-1", 0.5), false, "stableSample returns false for known non-matching sample");
+});
+
+add_task(function* testBucketSample() {
+  // Absolute samples
+  equal(yield Sampling.bucketSample("test", 0, 10, 10), true, "bucketSample returns true for 100% sample");
+  equal(yield Sampling.bucketSample("test", 0, 0, 10), false, "bucketSample returns false for 0% sample");
+
+  // Known samples. The numbers are nonces to make the tests pass
+  equal(yield Sampling.bucketSample("test-0", 0, 5, 10), true, "bucketSample returns true for known matching sample");
+  equal(yield Sampling.bucketSample("test-1", 0, 5, 10), false, "bucketSample returns false for known non-matching sample");
+});
+
+add_task(function* testFractionToKey() {
+  // Test that results are always 12 character hexadecimal strings.
+  const expected_regex = /[0-9a-f]{12}/;
+  const count = 100;
+  let successes = 0;
+  for (let i = 0; i < count; i++) {
+    const p = Sampling.fractionToKey(Math.random());
+    if (expected_regex.test(p)) {
+      successes++;
+    }
+  }
+  equal(successes, count, "fractionToKey makes keys the right length");
+});
+
+add_task(function* testTruncatedHash() {
+  const expected_regex = /[0-9a-f]{12}/;
+  const count = 100;
+  let successes = 0;
+  for (let i = 0; i < count; i++) {
+    const h = yield Sampling.truncatedHash(Math.random());
+    if (expected_regex.test(h)) {
+      successes++;
+    }
+  }
+  equal(successes, count, "truncatedHash makes hashes the right length");
+});
+
+add_task(function* testBufferToHex() {
+  const data = new ArrayBuffer(4);
+  const view = new DataView(data);
+  view.setUint8(0, 0xff);
+  view.setUint8(1, 0x7f);
+  view.setUint8(2, 0x3f);
+  view.setUint8(3, 0x1f);
+  equal(Sampling.bufferToHex(data), "ff7f3f1f");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/xpc_head.js
@@ -0,0 +1,19 @@
+"use strict";
+
+const {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Load our bootstrap extension manifest so we can access our chrome/resource URIs.
+// Cargo culted from formautofill system add-on
+const EXTENSION_ID = "shield-recipe-client@mozilla.org";
+let extensionDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+extensionDir.append("browser");
+extensionDir.append("features");
+extensionDir.append(EXTENSION_ID);
+// If the unpacked extension doesn't exist, use the packed version.
+if (!extensionDir.exists()) {
+  extensionDir = extensionDir.parent;
+  extensionDir.append(EXTENSION_ID + ".xpi");
+}
+Components.manager.addBootstrappedManifestLocation(extensionDir);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+head = xpc_head.js
+
+[test_Sampling.js]