No bug - Update shield-recipe-client from GitHub r=Gijs
authorMythmon <mcooper@mozilla.com>
Fri, 27 Jan 2017 10:22:44 -0800
changeset 331527 acd9236e8f36720124b0a8323f5c4945310bf240
parent 331526 f00b9a00eef5b7a92611e7926d7dac9f8b3ea5fb
child 331528 28a1c024b76808d241a719f20bbd81891a16a9d8
push id31273
push userphilringnalda@gmail.com
push dateSat, 28 Jan 2017 21:09:18 +0000
treeherdermozilla-central@e7b795db8b5b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
milestone54.0a1
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
No bug - Update shield-recipe-client from GitHub r=Gijs
browser/extensions/shield-recipe-client/bootstrap.js
browser/extensions/shield-recipe-client/jar.mn
browser/extensions/shield-recipe-client/lib/EnvExpressions.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/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) {
--- 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);
   },
 };
--- a/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
+++ b/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
@@ -4,24 +4,24 @@
 
 "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/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.
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,42 +7,47 @@
 
 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);
     },
 
--- 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);
@@ -146,34 +146,44 @@ this.RecipeRunner = {
     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);
+
+      // Results are cloned so that they don't become inaccessible when
+      // the sandbox they came from is nuked when the hold is removed.
       sandbox.actionFinished = result => {
+        const clonedResult = Cu.cloneInto(result, {});
         sandboxManager.removeHold("recipeExecution");
-        resolve(result);
+        resolve(clonedResult);
       };
       sandbox.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);
     });
   },
 };
--- 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
@@ -51,15 +51,13 @@ 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,16 @@ module.exports = {
   globals: {
     Assert: false,
     BrowserTestUtils: false,
     add_task: false,
     is: false,
     isnot: false,
     ok: false,
     SpecialPowers: false,
+    SimpleTest: 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,92 @@
+"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));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_Heartbeat.js
@@ -0,0 +1,188 @@
+"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));
+});
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]