Bug 974009 - Telemetry experiments: test experiment conditions and enable experiments. r=felipe,unfocused
authorGeorg Fritzsche <georg.fritzsche@googlemail.com>
Tue, 18 Mar 2014 22:52:28 +0100
changeset 193756 4d5b463ee7d90d045f8a0a174329e48e0a007dd1
parent 193755 65448ed8c05cfde9e5784a0c5bb093cb7a5b6913
child 193757 52c567b1a46916e87313aa0429545561301c37ce
push id486
push userasasaki@mozilla.com
push dateMon, 14 Jul 2014 18:39:42 +0000
treeherdermozilla-release@d33428174ff1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersfelipe, unfocused
bugs974009
milestone31.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
Bug 974009 - Telemetry experiments: test experiment conditions and enable experiments. r=felipe,unfocused
browser/app/profile/firefox.js
browser/experiments/Experiments.jsm
browser/experiments/Experiments.manifest
browser/experiments/ExperimentsService.js
browser/experiments/moz.build
browser/experiments/test/experiment-1.xpi
browser/experiments/test/experiment-2.xpi
browser/experiments/test/xpcshell/experiments_1.manifest
browser/experiments/test/xpcshell/head.js
browser/experiments/test/xpcshell/xpcshell.ini
browser/moz.build
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1398,8 +1398,12 @@ pref("identity.fxaccounts.settings.uri",
 // On GTK, we now default to showing the menubar only when alt is pressed:
 #ifdef MOZ_WIDGET_GTK
 pref("ui.key.menuAccessKeyFocuses", true);
 #endif
 
 
 // Delete HTTP cache v2 data of users that didn't opt-in manually
 pref("browser.cache.auto_delete_cache_version", 1);
+
+// Telemetry experiments settings.
+pref("experiments.enabled", false);
+pref("experiments.manifest.fetchIntervalSeconds", 86400);
new file mode 100644
--- /dev/null
+++ b/browser/experiments/Experiments.jsm
@@ -0,0 +1,1346 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = [
+  "Experiments",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://services-common/utils.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
+                                  "resource://gre/modules/UpdateChannel.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+
+
+const FILE_CACHE                = "experiments.json";
+const OBSERVER_TOPIC            = "experiments-changed";
+const MANIFEST_VERSION          = 1;
+const CACHE_VERSION             = 1;
+
+const KEEP_HISTORY_N_DAYS       = 180;
+const MIN_EXPERIMENT_ACTIVE_SECONDS = 60;
+
+const PREF_BRANCH               = "experiments.";
+const PREF_ENABLED              = "enabled"; // experiments.enabled
+const PREF_LOGGING              = "logging";
+const PREF_LOGGING_LEVEL        = PREF_LOGGING + ".level"; // experiments.logging.level
+const PREF_LOGGING_DUMP         = PREF_LOGGING + ".dump"; // experiments.logging.dump
+const PREF_MANIFEST_URI         = "manifest.uri"; // experiments.logging.manifest.uri
+
+const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled";
+
+const PREF_BRANCH_TELEMETRY     = "toolkit.telemetry.";
+const PREF_TELEMETRY_ENABLED    = "enabled";
+const PREF_TELEMETRY_PRERELEASE = "enabledPreRelease";
+
+const gPrefs = new Preferences(PREF_BRANCH);
+const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY);
+let gExperimentsEnabled = false;
+let gExperiments = null;
+let gLogAppenderDump = null;
+
+XPCOMUtils.defineLazyGetter(this, "gLogger", function() {
+  let logger = Log.repository.getLogger("Browser.Experiments");
+  logger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+  return logger;
+});
+
+
+function configureLogging() {
+  gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, 50);
+
+  if (gPrefs.get(PREF_LOGGING_DUMP, false)) {
+    gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter());
+    gLogger.addAppender(gLogAppenderDump);
+  } else {
+    gLogger.removeAppender(gLogAppenderDump);
+    gLogAppenderDump = null;
+  }
+}
+
+// Takes an array of promises and returns a promise that is resolved once all of
+// them are rejected or resolved.
+function allResolvedOrRejected(promises) {
+  if (!promises.length) {
+    return Promise.resolve([]);
+  }
+
+  let countdown = promises.length;
+  let deferred = Promise.defer();
+
+  for (let p of promises) {
+    let helper = () => {
+      if (--countdown == 0) {
+        deferred.resolve();
+      }
+    };
+    Promise.resolve(p).then(helper, helper);
+  }
+
+  return deferred.promise;
+}
+
+// Loads a JSON file using OS.file. file is a string representing the path
+// of the file to be read, options contains additional options to pass to
+// OS.File.read.
+// Returns a Promise resolved with the json payload or rejected with
+// OS.File.Error or JSON.parse() errors.
+function loadJSONAsync(file, options) {
+  return Task.spawn(function() {
+    let rawData = yield OS.File.read(file, options);
+    // Read json file into a string
+    let data;
+    try {
+      // Obtain a converter to read from a UTF-8 encoded input stream.
+      let converter = new TextDecoder();
+      data = JSON.parse(converter.decode(rawData));
+    } catch (ex) {
+      gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex);
+      throw ex;
+    }
+    throw new Task.Result(data);
+  });
+}
+
+function telemetryEnabled() {
+  return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false) ||
+         gPrefsTelemetry.get(PREF_TELEMETRY_PRERELEASE, false);
+}
+
+/**
+ * The experiments module.
+ */
+
+let Experiments = {
+  /**
+   * Provides access to the global `Experiments.Experiments` instance.
+   */
+  instance: function () {
+    if (!gExperiments) {
+      gExperiments = new Experiments.Experiments();
+    }
+
+    return gExperiments;
+  },
+};
+
+/*
+ * The policy object allows us to inject fake enviroment data from the
+ * outside by monkey-patching.
+ */
+
+Experiments.Policy = function () {
+};
+
+Experiments.Policy.prototype = {
+  now: function () {
+    return new Date();
+  },
+
+  random: function () {
+    return Math.random();
+  },
+
+  futureDate: function (offset) {
+    return new Date(this.now().getTime() + offset);
+  },
+
+  oneshotTimer: function (callback, timeout, thisObj, name) {
+    return CommonUtils.namedTimer(callback, timeout, thisObj, name);
+  },
+
+  updatechannel: function () {
+    return UpdateChannel.get();
+  },
+
+  /*
+   * @return Promise<> Resolved with the payload data.
+   */
+  healthReportPayload: function () {
+    return Task.spawn(function*() {
+      let reporter = Cc["@mozilla.org/datareporting/service;1"]
+            .getService(Ci.nsISupports)
+            .wrappedJSObject
+            .healthReporter;
+      yield reporter.onInit();
+      let payload = yield reporter.collectAndObtainJSONPayload();
+      throw new Task.Result(payload);
+    });
+  },
+
+  telemetryPayload: function () {
+    return Promise.resolve({});
+  },
+};
+
+/**
+ * Manages the experiments and provides an interface to control them.
+ */
+
+Experiments.Experiments = function (policy=new Experiments.Policy()) {
+  this._policy = policy;
+
+  // This is a Map of (string -> ExperimentEntry), keyed with the experiment id.
+  // It holds both the current experiments and history.
+  // Map() preserves insertion order, which means we preserve the manifest order.
+  this._experiments = null;
+  // Holds tasks that hit the experiments list and files.
+  // We need to serialize them, so only one is running at a time.
+  this._pendingTasks = {
+    updateManifest: null,
+    loadFromCache: null,
+    saveToCache: null,
+    evaluateExperiments: null,
+    disableExperiment: null,
+  };
+  // Timer for re-evaluating experiment status.
+  this._timer = null;
+
+  this.init();
+};
+
+Experiments.Experiments.prototype = {
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]),
+
+  init: function () {
+    configureLogging();
+
+    gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false);
+    gLogger.trace("enabled="+gExperimentsEnabled+", "+this.enabled);
+
+    gPrefs.observe(PREF_LOGGING, configureLogging);
+    gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this);
+    gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this);
+
+    gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
+    gPrefsTelemetry.observe(PREF_TELEMETRY_PRERELEASE, this._telemetryStatusChanged, this);
+
+    AddonManager.addAddonListener(this);
+
+    this._experiments = new Map();
+    this._loadFromCache();
+  },
+
+  /**
+   * @return Promise<>
+   *         The promise is fulfilled when all pending tasks are finished.
+   */
+  uninit: function () {
+    AddonManager.removeAddonListener(this);
+
+    gPrefs.ignore(PREF_LOGGING, configureLogging);
+    gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this);
+    gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this);
+
+    gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
+    gPrefsTelemetry.ignore(PREF_TELEMETRY_PRERELEASE, this._telemetryStatusChanged, this);
+
+    if (this._timer) {
+      this._timer.clear();
+    }
+
+    let tasks = this._pendingTasks;
+    return this._pendingTasksDone();
+  },
+
+  /**
+   * Whether the experiments feature is enabled.
+   */
+  get enabled() {
+    return gExperimentsEnabled;
+  },
+
+  /**
+   * Toggle whether the experiments feature is enabled or not.
+   */
+  set enabled(enabled) {
+    gLogger.trace("Experiments::set enabled(" + enabled + ")");
+    gPrefs.set(PREF_ENABLED, enabled);
+  },
+
+  _toggleExperimentsEnabled: function (enabled) {
+    gLogger.trace("Experiments::_toggleExperimentsEnabled(" + enabled + ")");
+    let wasEnabled = gExperimentsEnabled;
+    gExperimentsEnabled = enabled && telemetryEnabled();
+
+    if (wasEnabled == gExperimentsEnabled) {
+      return Promise.resolve();
+    }
+
+    if (gExperimentsEnabled) {
+      return this.updateManifest();
+    }
+
+    let promise = this._disableExperiments();
+    if (wasEnabled) {
+      Services.obs.notifyObservers(null, OBSERVER_TOPIC, null);
+    }
+    return promise;
+  },
+
+  _telemetryStatusChanged: function () {
+    _toggleExperimentsEnabled(gExperimentsEnabled);
+  },
+
+  /**
+   * Returns a promise that is resolved with an array of `ExperimentInfo` objects,
+   * which provide info on the currently and recently active experiments.
+   * The array is in chronological order.
+   *
+   * The experiment info is of the form:
+   * {
+   *   id: <string>,
+   *   name: <string>,
+   *   description: <string>,
+   *   active: <boolean>,
+   *   endDate: <integer>, // epoch ms
+   *   detailURL: <string>,
+   *   ... // possibly extended later
+   * }
+   *
+   * @return Promise<Array<ExperimentInfo>> Array of experiment info objects.
+   */
+  getExperiments: function () {
+    return Promise.resolve(this._experiments || this._loadFromCache()).then(
+      () => {
+        let list = [];
+
+        for (let [id, experiment] of this._experiments) {
+          if (!experiment.startDate) {
+            // We only collect experiments that are or were active.
+            continue;
+          }
+
+          list.push({
+            id: id,
+            name: experiment._name,
+            description: experiment._description,
+            active: experiment.enabled,
+            endDate: experiment.endDate.getTime(),
+            detailURL: experiment._homepageURL,
+          });
+        }
+
+        // Sort chronologically, descending.
+        list.sort((a, b) => b.endDate - a.endDate);
+
+        return list;
+      },
+      () => []
+    );
+  },
+
+  /**
+   * Fetch an updated list of experiments and trigger experiment updates.
+   * Do only use when experiments are enabled.
+   *
+   * @return Promise<>
+   *         The promise is resolved when the manifest and experiment list is updated.
+   */
+  updateManifest: function () {
+    gLogger.trace("Experiments::updateManifest()");
+
+    if (!gExperimentsEnabled) {
+      return Promise.reject(new Error("experiments are disabled"));
+    }
+
+    if (this._pendingTasks.updateManifest) {
+      return this._pendingTasks.updateManifest;
+    }
+
+    let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI);
+
+    this._pendingTasks.updateManifest = Task.spawn(function () {
+      // Don't interfere while we're saving or loading the cache.
+      try {
+        yield this._pendingTasksDone([this._pendingTasks.updateManifest]);
+      } catch (e) {
+        // Ignore a failed cache load/save.
+      }
+
+      try {
+        let responseText = yield this._httpGetRequest(uri);
+        gLogger.trace("Experiments::updateManifest::updateTask() - responseText=\"" + responseText + "\"");
+
+        let data = JSON.parse(responseText);
+        this._updateExperiments(data);
+        yield this._evaluateExperiments();
+        this._scheduleExperimentEvaluation();
+      } catch (e if e instanceof SyntaxError) {
+        gLogger.error("Experiments::updateManifest::updateTask() - failed to parse manifest - " + e);
+        throw e;
+      } finally {
+        this._pendingTasks.updateManifest = null;
+      }
+
+      yield this._saveToCache(this._experiments);
+    }.bind(this));
+
+    return this._pendingTasks.updateManifest;
+  },
+
+  notify: function (timer) {
+    gLogger.trace("Experiments::notify()");
+
+    if (this._pendingTasks.evaluateExperiments) {
+      return;
+    }
+
+    this._pendingTasks.evaluateExperiments = new Task.spawn(function scheduledEvaluateTask() {
+      gLogger.trace("Experiments::notify::scheduledEvaluateTask()");
+      yield this._pendingTasksDone([this._pendingTasks.evaluateExperiments]);
+      yield this._evaluateExperiments();
+      this._pendingTasks.evaluateExperiments = null;
+      this._scheduleExperimentEvaluation();
+    }.bind(this));
+  },
+
+  onDisabled: function (addon) {
+    let experiment = this._experiments.get(addon.id);
+    if (!experiment) {
+      return;
+    }
+
+    this.disableExperiment(addon.id);
+  },
+
+  onUninstalled: function (addon) {
+    let experiment = this._experiments.get(addon.id);
+    if (!experiment) {
+      return;
+    }
+
+    this.disableExperiment(addon.id);
+  },
+
+  /*
+   * Helper function to make HTTP GET requests. Returns a promise that is resolved with
+   * the responseText when the request is complete.
+   */
+  _httpGetRequest: function (url) {
+    gLogger.trace("Experiments::httpGetRequest(" + url + ")");
+    let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
+    try {
+      xhr.open("GET", url);
+    } catch (e) {
+      gLogger.error("Experiments::httpGetRequest() - Error opening request to " + url + ": " + e);
+      return Promise.reject(new Error("Experiments - Error opening XHR for " + url));
+    }
+
+    let deferred = Promise.defer();
+
+    xhr.onerror = function (e) {
+      gLogger.error("Experiments::httpGetRequest::onError() - Error making request to " + url + ": " + e.error);
+      deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error));
+    }
+
+    xhr.onload = function (event) {
+      if (xhr.status !== 200 && xhr.state !== 0) {
+        gLogger.error("Experiments::httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status);
+        deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status));
+        return;
+      }
+
+      deferred.resolve(xhr.responseText);
+    }
+
+    if (xhr.channel instanceof Ci.nsISupportsPriority) {
+      xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST;
+    }
+
+    xhr.send(null);
+    return deferred.promise;
+  },
+
+  /*
+   * Path of the cache file we use in the profile.
+   */
+  get _cacheFilePath() {
+    return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE);
+  },
+
+  /*
+   * Returns a promise that is resolved when all _pendingTasks are resolved
+   * or rejected.
+   * exceptThese is optional and allows to specify exceptions of tasks not to
+   * wait on.
+   */
+  _pendingTasksDone: function (exceptThese) {
+    exceptThese = exceptThese || [];
+    let ts = this._pendingTasks;
+    let list = [ts[k] for (k of Object.keys(ts))
+                  if (ts.hasOwnProperty(k) && exceptThese.indexOf(ts[k]) == -1)]
+    return allResolvedOrRejected(list);
+  },
+
+  /*
+   * Save the experiment data on disk. Returns a promise that
+   * is resolved when the operation is done.
+   */
+  _saveToCache: function (data) {
+    if (this._pendingTasks.saveToCache) {
+      return this._pendingTasks.saveToCache;
+    }
+
+    let path = this._cacheFilePath;
+    let textData = JSON.stringify({
+      version: CACHE_VERSION,
+      data: [e[1].toJSON() for (e of data.entries())],
+    });
+
+    this._pendingTasks.saveToCache = Task.spawn(function Experiments_saveToCache_fileTask() {
+      try {
+        yield this._pendingTasksDone([this._pendingTasks.saveToCache]);
+        let encoder = new TextEncoder();
+        let data = encoder.encode(textData);
+        let options = { tmpPath: path + ".tmp", compression: "lz4" };
+        yield OS.File.writeAtomic(path, data, options);
+      } finally {
+        this._pendingTasks.saveToCache = null;
+      }
+    }.bind(this)).then(
+      () => gLogger.debug("Experiments::saveToCache::fileTask() saved to: " + path),
+       e => gLogger.error("Experiments::saveToCache::fileTask() failed: " + e));
+
+    return this._pendingTasks.saveToCache;
+  },
+
+  /*
+   * Load the cached experiments manifest file from disk.
+   * Returns a promise that is resolved when the experiments are loaded and updated.
+   */
+  _loadFromCache: function () {
+    if (this._pendingTasks.loadFromCache) {
+      return this._pendingTasks.loadFromCache;
+    }
+
+    if (this._pendingTasks.updateManifest) {
+      // We're already updating the manifest, no need to load the cached version.
+      return this._pendingTasks.updateManifest;
+    }
+
+    let path = this._cacheFilePath;
+    this._pendingTasks.loadFromCache = Task.spawn(function () {
+      try {
+        yield this._pendingTasksDone([this._pendingTasks.loadFromCache]);
+        let result = yield loadJSONAsync(path, { compression: "lz4" });
+
+        this._populateFromCache(result);
+        yield this._evaluateExperiments();
+        this._scheduleExperimentEvaluation();
+      } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
+        // No cached manifest yet.
+      } finally {
+        this._pendingTasks.loadFromCache = null;
+      }
+    }.bind(this)).then(null, error => {
+      gLogger.error("Experiments::loadFromCache::fileTask() failed: " + error);
+    });
+
+    return this._pendingTasks.loadFromCache;
+  },
+
+  _populateFromCache: function (data) {
+    gLogger.trace("Experiments::populateFromCache() - data: " + JSON.stringify(data));
+
+    if (CACHE_VERSION !== data.version) {
+      gLogger.warn("Experiments::populateFromCache() - invalid cache version");
+      return false;
+    }
+
+    let experiments = new Map();
+    for (let item of data.data) {
+      let entry = new Experiments.ExperimentEntry(this._policy);
+      if (!entry.initFromCacheData(item)) {
+        continue;
+      }
+      experiments.set(item.id, entry);
+    }
+
+    this._experiments = experiments;
+    return true;
+  },
+
+  /*
+   * Update the experiment entries from the experiments
+   * array in the manifest
+   */
+  _updateExperiments: function (manifestObject) {
+    gLogger.trace("Experiments::updateExperiments() - experiments: " + JSON.stringify(manifestObject));
+
+    if (manifestObject.version !== MANIFEST_VERSION) {
+      gLogger.warning("Experiments::updateExperiments() - unsupported version " + manifestObject.version);
+      return false;
+    }
+
+    let experiments = new Map(); // The new experiments map
+
+    // Collect new and updated experiments.
+    for (let data of manifestObject.experiments) {
+      let entry = this._experiments.get(data.id);
+
+      if (entry) {
+        if (!entry.updateFromManifestData(data)) {
+          gLogger.error("Experiments::updateExperiments() - Invalid manifest data for " + data.id);
+          continue;
+        }
+      } else {
+        entry = new Experiments.ExperimentEntry(this._policy);
+        if (!entry.initFromManifestData(data)) {
+          continue;
+        }
+      }
+
+      if (entry.shouldDiscard()) {
+        continue;
+      }
+
+      experiments.set(data.id, entry);
+    }
+
+    // Make sure we keep experiments that are or were running.
+    // We remove them after KEEP_HISTORY_N_DAYS.
+    for (let [id, entry] of this._experiments) {
+      if (experiments.has(id) || !entry.startDate || !entry.shouldDiscard()) {
+        continue;
+      }
+
+      experiments.set(id, experiment);
+    }
+
+    this._experiments = experiments;
+    return true;
+  },
+
+  _getActiveExperiment: function () {
+    let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)];
+
+    if (enabled.length == 1) {
+      return enabled[0];
+    }
+
+    if (enabled.length > 1) {
+      gLogger.error("Experiments::getActiveExperimentId() - should not have more than 1 active experiment");
+      throw new Error("have more than 1 active experiment");
+    }
+
+    return null;
+  },
+
+  /*
+   * Stop running experiments and disable further activations.
+   */
+  _disableExperiments: function () {
+    gLogger.trace("Experiments::disableExperiments()");
+
+    let active = this._getActiveExperiment();
+    let promise = Promise.resolve();
+    if (active) {
+      promise = active.stop();
+    }
+
+    if (this._timer) {
+      this._timer.clear();
+    }
+
+    return promise;
+  },
+
+  /**
+   * Disable an experiment by id.
+   * @param experimentId The id of the experiment.
+   * @param userDisabled (optional) Whether this is disabled as a result of a user action.
+   * @return Promise<> Promise that will get resolved once the task is done or failed.
+   */
+  disableExperiment: function (experimentId, userDisabled=true) {
+    gLogger.trace("Experiments::disableExperiment() - " + experimentId);
+
+    let experiment = this._experiments.get(experimentId);
+    if (!experiment) {
+      let message = "no experiment with this id";
+      gLogger.warning("Experiments::disableExperiment() - " + message);
+      return Promise.reject(new Error(message));
+    }
+
+    if (!experiment.enabled) {
+      return Promise.reject();
+    }
+
+    return Task.spawn(function* Experiments_disableExperimentTaskOuter() {
+      // We need to wait on possible previous disable tasks.
+      yield this._pendingTasks.disableExperiment;
+
+      let disableTask = Task.spawn(function* Experiments_disableExperimentTaskInner() {
+        yield this._pendingTasksDone([this._pendingTasks.disableExperiment]);
+        yield experiment.stop(userDisabled);
+        Services.obs.notifyObservers(null, OBSERVER_TOPIC, null);
+        this._pendingTasks.disableExperiment = null;
+      }.bind(this));
+
+      this._pendingTasks.disableExperiment = disableTask;
+      yield disableTask;
+    }.bind(this));
+  },
+
+  /*
+   * Check applicability of experiments, disable the active one if needed and
+   * activate the first applicable candidate.
+   * @return Promise<boolean> Resolved when done, the value indicates whether
+   *                          the activity status of any experiment changed.
+   */
+  _evaluateExperiments: function () {
+    gLogger.trace("Experiments::evaluateExperiments()");
+
+    return Task.spawn(function Experiments_evaluateExperiments_evaluateTask() {
+      let activeExperiment = this._getActiveExperiment();
+      let activeChanged = false;
+
+      if (activeExperiment) {
+        let wasStopped = yield activeExperiment.maybeStop();
+        if (wasStopped) {
+          gLogger.debug("Experiments::evaluateExperiments() - stopped experiment "
+                        + activeExperiment.id);
+          activeExperiment = null;
+          activeChanged = true;
+        } else if (activeExperiment.needsUpdate) {
+          gLogger.debug("Experiments::evaluateExperiments() - updating experiment "
+                        + activeExperiment.id);
+          try {
+            yield activeExperiment.stop();
+            yield activeExperiment.start();
+          } catch (e) {
+            // On failure try the next experiment.
+            activeExperiment = null;
+          }
+
+          activeChanged = true;
+        }
+      }
+
+      if (!activeExperiment) {
+        for (let [id, experiment] of this._experiments) {
+          let applicable;
+          yield experiment.isApplicable().then(
+            result => applicable = result,
+            reason => {
+              applicable = false;
+              // TODO telemetry: experiment rejection reason
+            }
+          );
+
+          if (applicable) {
+            gLogger.debug("Experiments::evaluateExperiments() - activating experiment " + id);
+            try {
+              yield experiment.start();
+              activeChanged = true;
+              break;
+            } catch (e) {
+              // On failure try the next experiment.
+            }
+          }
+        }
+      }
+
+      if (activeChanged) {
+        Services.obs.notifyObservers(null, OBSERVER_TOPIC, null);
+      }
+
+      throw new Task.Result(activeChanged);
+    }.bind(this));
+  },
+
+  /*
+   * Schedule the soonest re-check of experiment applicability that is needed.
+   */
+  _scheduleExperimentEvaluation: function () {
+    if (!gExperimentsEnabled || this._experiments.length == 0) {
+      return;
+    }
+
+    let time = new Date(90000, 0, 1, 12).getTime();
+    let now = this._policy.now().getTime();
+
+    for (let [id, experiment] of this._experiments) {
+      let scheduleTime = experiment.getScheduleTime();
+      if (scheduleTime > now) {
+        time = Math.min(time, scheduleTime);
+      }
+    }
+
+    if (this._timer) {
+      this._timer.clear();
+    }
+
+    gLogger.trace("Experiments::scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now);
+    this._policy.oneshotTimer(this.notify, time - now, this, "_timer");
+  },
+};
+
+
+/*
+ * Represents a single experiment.
+ */
+
+Experiments.ExperimentEntry = function (policy) {
+  this._policy = policy || new Experiments.Policy();
+
+  // Is this experiment running?
+  this._enabled = false;
+  // When this experiment was started, if ever.
+  this._startDate = null;
+  // When this experiment was ended, if ever.
+  this._endDate = null;
+  // The condition data from the manifest.
+  this._manifestData = null;
+  // For an active experiment, signifies whether we need to update the xpi.
+  this._needsUpdate = false;
+  // A random sample value for comparison against the manifest conditions.
+  this._randomValue = null;
+  // When this entry was last changed for respecting history retention duration.
+  this._lastChangedDate = null;
+  // Has this experiment failed to activate before?
+  this._failedStart = false;
+
+  // We grab these from the addon after download.
+  this._name = null;
+  this._description = null;
+  this._homepageURL = null;
+};
+
+Experiments.ExperimentEntry.prototype = {
+  MANIFEST_REQUIRED_FIELDS: new Set([
+    "id",
+    "xpiURL",
+    "xpiHash",
+    "startTime",
+    "endTime",
+    "maxActiveSeconds",
+    "appName",
+    "channel",
+  ]),
+
+  MANIFEST_OPTIONAL_FIELDS: new Set([
+    "maxStartTime",
+    "minVersion",
+    "maxVersion",
+    "version",
+    "minBuildID",
+    "maxBuildID",
+    "buildIDs",
+    "os",
+    "locale",
+    "sample",
+    "disabled",
+    "frozen",
+    "jsfilter",
+  ]),
+
+  SERIALIZE_KEYS: new Set([
+    "_enabled",
+    "_manifestData",
+    "_needsUpdate",
+    "_randomValue",
+    "_failedStart",
+    "_name",
+    "_description",
+    "_homepageURL",
+    "_startDate",
+    "_endDate",
+  ]),
+
+  DATE_KEYS: new Set([
+    "_startDate",
+    "_endDate",
+  ]),
+
+  /*
+   * Initialize entry from the manifest.
+   * @param data The experiment data from the manifest.
+   * @return boolean Whether initialization succeeded.
+   */
+  initFromManifestData: function (data) {
+    if (!this._isManifestDataValid(data)) {
+      return false;
+    }
+
+    this._manifestData = data;
+
+    this._randomValue = this._policy.random();
+    this._lastChangedDate = this._policy.now();
+
+    return true;
+  },
+
+  get enabled() {
+    return this._enabled;
+  },
+
+  get id() {
+    return this._manifestData.id;
+  },
+
+  get startDate() {
+    return this._startDate;
+  },
+
+  get endDate() {
+    if (!this._startDate) {
+      return null;
+    }
+
+    let endTime = 0;
+
+    if (!this._enabled) {
+      return this._endDate;
+    }
+
+    let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds;
+    endTime = Math.min(1000 * this._manifestData.endTime,
+                       this._startDate.getTime() + maxActiveMs);
+
+    return new Date(endTime);
+  },
+
+  get needsUpdate() {
+    return this._needsUpdate;
+  },
+
+  /*
+   * Initialize entry from the cache.
+   * @param data The entry data from the cache.
+   * @return boolean Whether initialization succeeded.
+   */
+  initFromCacheData: function (data) {
+    for (let key of this.SERIALIZE_KEYS) {
+      if (!(key in data)) {
+        return false;
+      }
+    };
+
+    if (!this._isManifestDataValid(data._manifestData)) {
+      return false;
+    }
+
+    this._lastChangedDate = this._policy.now();
+    this.SERIALIZE_KEYS.forEach(key => {
+      this[key] = data[key];
+    });
+
+    this.DATE_KEYS.forEach(key => {
+      if (key in this) {
+        let date = new Date();
+        date.setTime(this[key]);
+        this[key] = date;
+      }
+    });
+
+    return true;
+  },
+
+  /*
+   * Returns a JSON representation of this object.
+   */
+  toJSON: function () {
+    let obj = {};
+
+    this.SERIALIZE_KEYS.forEach(key => {
+      if (!this.DATE_KEYS.has(key)) {
+        obj[key] = this[key];
+      }
+    });
+
+    this.DATE_KEYS.forEach(key => {
+      obj[key] = this[key] ? this[key].getTime() : null;
+    });
+
+    return obj;
+  },
+
+  /*
+   * Update from the experiment data from the manifest.
+   * @param data The experiment data from the manifest.
+   * @return boolean Whether updating succeeded.
+   */
+  updateFromManifestData: function (data) {
+    let old = this._manifestData;
+
+    if (!this._isManifestDataValid(data)) {
+      return false;
+    }
+
+    if (this._enabled) {
+      if (old.xpiHash !== data.xpiHash) {
+        // A changed hash means we need to update active experiments.
+        this._needsUpdate = true;
+      }
+    } else if (this._failedStart &&
+               (old.xpiHash !== data.xpiHash) ||
+               (old.xpiURL !== data.xpiURL)) {
+      // Retry installation of previously invalid experiments
+      // if hash or url changed.
+      this._failedStart = false;
+    }
+
+    this._manifestData = data;
+    this._lastChangedDate = this._policy.now();
+
+    return true;
+  },
+
+  /*
+   * Is this experiment applicable?
+   * @return Promise<> Resolved if the experiment is applicable.
+   *                   If it is not applicable it is rejected with
+   *                   a Promise<string> which contains the reason.
+   */
+  isApplicable: function () {
+    let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"]
+                              .getService(Ci.nsIVersionComparator);
+    let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
+    let runtime = Cc["@mozilla.org/xre/app-info;1"]
+                    .getService(Ci.nsIXULRuntime);
+    let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry);
+
+    let locale = chrome.getSelectedLocale("global");
+    let channel = this._policy.updatechannel();
+    let data = this._manifestData;
+
+    let now = this._policy.now() / 1000; // The manifest times are in seconds.
+    let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS;
+    let maxActive = data.maxActiveSeconds || 0;
+    let startSec = (this.startDate || 0) / 1000;
+
+    gLogger.trace("ExperimentEntry::isApplicable() - now=" + now
+                  + ", data=" + JSON.stringify(this._manifestData));
+
+    // Not applicable if it already ran.
+
+    if (!this.enabled && this._endDate) {
+      return Promise.reject("was already active");
+    }
+
+    // Define and run the condition checks.
+
+    let simpleChecks = [
+      { name: "failedStart",
+        condition: () => !this._failedStart },
+      { name: "disabled",
+        condition: () => !data.disabled },
+      { name: "frozen",
+        condition: () => !data.frozen || this._enabled },
+      { name: "startTime",
+        condition: () => now >= data.startTime },
+      { name: "endTime",
+        condition: () => now < data.endTime },
+      { name: "maxStartTime",
+        condition: () => !data.maxStartTime || now <= (data.maxStartTime - minActive) },
+      { name: "maxActiveSeconds",
+        condition: () => !this._startDate || now <= (startSec + maxActive) },
+      { name: "appName",
+        condition: () => !data.name || data.appName.indexOf(app.name) != -1 },
+      { name: "minBuildID",
+        condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID },
+      { name: "maxBuildID",
+        condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID },
+      { name: "buildIDs",
+        condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 },
+      { name: "os",
+        condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 },
+      { name: "channel",
+        condition: () => !data.channel || data.channel.indexOf(channel) != -1 },
+      { name: "locale",
+        condition: () => !data.locale || data.locale.indexOf(locale) != -1 },
+      { name: "sample",
+        condition: () => !data.sample || this._randomValue <= data.sample },
+      { name: "version",
+        condition: () => !data.version || data.appVersion.indexOf(app.version) != -1 },
+      { name: "minVersion",
+        condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 },
+      { name: "maxVersion",
+        condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 },
+    ];
+
+    for (let check of simpleChecks) {
+      let result = check.condition();
+      if (!result) {
+        gLogger.debug("ExperimentEntry::isApplicable() - id="
+                      + data.id + " - test '" + check.name + "' failed");
+        return Promise.reject(check.name);
+      }
+    }
+
+    if (data.jsfilter) {
+      return this._runFilterFunction(data.jsfilter);
+    }
+
+    return Promise.resolve(true);
+  },
+
+  /*
+   * Run the jsfilter function from the manifest in a sandbox and return the
+   * result (forced to boolean).
+   */
+  _runFilterFunction: function (jsfilter) {
+    gLogger.trace("ExperimentEntry::runFilterFunction() - filter: " + jsfilter);
+
+    return Task.spawn(function ExperimentEntry_runFilterFunction_task() {
+      const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal);
+      let options = {
+        sandboxName: "telemetry experiments jsfilter sandbox",
+        wantComponents: false,
+      };
+
+      let sandbox = Cu.Sandbox(nullprincipal);
+      let context = {};
+      context.healthReportPayload = yield this._policy.healthReportPayload();
+      context.telemetryPayload    = yield this._policy.telemetryPayload();
+
+      try {
+        Cu.evalInSandbox(jsfilter, sandbox);
+      } catch (e) {
+        gLogger.error("ExperimentEntry::runFilterFunction() - failed to eval jsfilter: " + e.message);
+        throw "jsfilter:evalFailure";
+      }
+
+      let result;
+      sandbox.context = context;
+      try {
+        result = !!Cu.evalInSandbox("filter(context)", sandbox);
+        gLogger.trace("!!evalInSandbox() = " + result);
+      } catch (e) {
+        gLogger.debug("ExperimentEntry::runFilterFunction() - eval call to filter has failed: " + e.message);
+        throw "jsfilter:rejected " + e.message;
+      } finally {
+        Cu.nukeSandbox(sandbox);
+      }
+
+      throw new Task.Result(result);
+    }.bind(this));
+  },
+
+  /*
+   * Start running the experiment.
+   * @return Promise<> Resolved when the operation is complete.
+   */
+  start: function () {
+    gLogger.trace("ExperimentEntry::start() for " + this.id);
+    let deferred = Promise.defer();
+
+    let installCallback = install => {
+      let failureHandler = (install, handler) => {
+        let message = "AddonInstall " + handler + " for " + this.id + ", state=" +
+                      (install.state || "?") + ", error=" + install.error;
+        gLogger.error("ExperimentEntry::start() - " + message);
+        this._failedStart = true;
+
+        // TODO telemetry: install failure
+
+        deferred.reject(new Error(message));
+      };
+
+      let listener = {
+        onDownloadEnded: install => {
+          gLogger.trace("ExperimentEntry::start() - onDownloadEnded for " + this.id);
+          let addon = install.addon;
+
+          if (addon.id !== this.id) {
+            let message = "id mismatch: '" + this.id + "' vs. '" + addon.id + "'";
+            gLogger.error("ExperimentEntry::start() - " + message);
+            install.cancel();
+          }
+        },
+
+        onInstallStarted: install => {
+          gLogger.trace("ExperimentEntry::start() - onInstallStarted for " + this.id);
+          // TODO: this check still needs changes in the addon manager
+          //if (install.addon.type !== "experiment") {
+          //  gLogger.error("ExperimentEntry::start() - wrong addon type");
+          //  failureHandler({state: -1, error: -1}, "onInstallStarted");
+          //}
+
+          let addon = install.addon;
+          this._name = addon.name;
+          this._description = addon.description || "";
+          this._homepageURL = addon.homepageURL || "";
+        },
+
+        onInstallEnded: install => {
+          gLogger.trace("ExperimentEntry::start() - install ended for " + this.id);
+          this._lastChangedDate = this._policy.now();
+          this._startDate = this._policy.now();
+          this._enabled = true;
+
+          // TODO telemetry: install success
+
+          deferred.resolve();
+        },
+      };
+
+      ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"]
+        .forEach(what => listener[what] = install => failureHandler(install, what));
+
+      install.addListener(listener);
+      install.install();
+    };
+
+    AddonManager.getInstallForURL(this._manifestData.xpiURL,
+                                  installCallback,
+                                  "application/x-xpinstall",
+                                  this._manifestData.xpiHash);
+    return deferred.promise;
+  },
+
+  /*
+   * Stop running the experiment if it is active.
+   * @param userDisabled (optional) Whether this is disabled by user action, defaults to false.
+   * @return Promise<> Resolved when the operation is complete.
+   */
+  stop: function (userDisabled=false) {
+    gLogger.trace("ExperimentEntry::stop() - id=" + this.id + ", userDisabled=" + userDisabled);
+    if (!this._enabled) {
+      gLogger.warning("ExperimentEntry::stop() - experiment not enabled: " + id);
+      return Promise.reject();
+    }
+
+    this._enabled = false;
+    let deferred = Promise.defer();
+    let updateDates = () => {
+      let now = this._policy.now();
+      this._lastChangedDate = now;
+      this._endDate = now;
+    };
+
+    AddonManager.getAddonByID(this.id, addon => {
+      if (!addon) {
+        let message = "could not get Addon for " + this.id;
+        gLogger.warn("ExperimentEntry::stop() - " + message);
+        updateDates();
+        deferred.resolve();
+        return;
+      }
+
+      let listener = {};
+      let handler = addon => {
+        if (addon.id !== this.id) {
+          return;
+        }
+
+        updateDates();
+
+        AddonManager.removeAddonListener(listener);
+        deferred.resolve();
+      };
+
+      listener.onUninstalled = handler;
+      listener.onDisabled = handler;
+
+      AddonManager.addAddonListener(listener);
+
+      addon.uninstall();
+
+      // TODO telemetry: experiment disabling, differentiate by userDisabled
+    });
+
+    return deferred.promise;
+  },
+
+  /*
+   * Stop if experiment stop criteria are met.
+   * @return Promise<boolean> Resolved when done stopping or checking,
+   *                          the value indicates whether it was stopped.
+   */
+  maybeStop: function () {
+    gLogger.trace("ExperimentEntry::maybeStop()");
+
+    return Task.spawn(function ExperimentEntry_maybeStop_task() {
+      let shouldStop = yield this._shouldStop();
+      if (shouldStop) {
+        yield this.stop();
+      }
+      throw new Task.Result(shouldStop);
+    }.bind(this));
+  },
+
+  _shouldStop: function () {
+    let data = this._manifestData;
+    let now = this._policy.now() / 1000; // The manifest times are in seconds.
+    let maxActiveSec = data.maxActiveSeconds || 0;
+
+    if (!this._enabled) {
+      return Promise.resolve(false);
+    }
+
+    let deferred = Promise.defer();
+    this.isApplicable().then(
+      () => deferred.resolve(false),
+      () => deferred.resolve(true)
+    );
+
+    return deferred.promise;
+  },
+
+  /*
+   * Should this be discarded from the cache due to age?
+   */
+  shouldDiscard: function () {
+    let limit = this._policy.now();
+    limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS);
+    return (this._lastChangedDate < limit);
+  },
+
+  /*
+   * Get next date (in epoch-ms) to schedule a re-evaluation for this.
+   * Returns 0 if it doesn't need one.
+   */
+  getScheduleTime: function () {
+    if (this._enabled) {
+      let now = this._policy.now();
+      let startTime = this._startDate.getTime();
+      let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds;
+      return Math.min(1000 * this._manifestData.endTime,  maxActiveTime);
+    }
+
+    if (this._endDate) {
+      return this._endDate.getTime();
+    }
+
+    return 1000 * this._manifestData.startTime;
+  },
+
+  /*
+   * Perform sanity checks on the experiment data.
+   */
+  _isManifestDataValid: function (data) {
+    gLogger.trace("ExperimentEntry::isManifestDataValid() - data: " + JSON.stringify(data));
+
+    for (let key of this.MANIFEST_REQUIRED_FIELDS) {
+      if (!(key in data)) {
+        gLogger.error("ExperimentEntry::isManifestDataValid() - missing required key: " + key);
+        return false;
+      }
+    }
+
+    for (let key in data) {
+      if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) &&
+          !this.MANIFEST_REQUIRED_FIELDS.has(key)) {
+        gLogger.error("ExperimentEntry::isManifestDataValid() - unknown key: " + key);
+        return false;
+      }
+    }
+
+    return true;
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/experiments/Experiments.manifest
@@ -0,0 +1,4 @@
+component {f7800463-3b97-47f9-9341-b7617e6d8d49} ExperimentsService.js
+contract @mozilla.org/browser/experiments-service;1 {f7800463-3b97-47f9-9341-b7617e6d8d49}
+category update-timer ExperimentsService @mozilla.org/browser/experiments-service;1,getService,experiments-update-timer,experiments.manifest.fetchIntervalSeconds,86400
+category profile-after-change ExperimentsService @mozilla.org/browser/experiments-service;1
new file mode 100644
--- /dev/null
+++ b/browser/experiments/ExperimentsService.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource:///modules/experiments/Experiments.jsm");
+
+function ExperimentsService() {
+}
+
+ExperimentsService.prototype = {
+  _experiments: null,
+  _pendingManifestUpdate: false,
+
+  classID: Components.ID("{f7800463-3b97-47f9-9341-b7617e6d8d49}"),
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]),
+
+  notify: function (timer) {
+    if (this._experiments) {
+      this._experiments.updateManifest();
+    } else {
+      this._pendingManifestUpdate = true;
+    }
+  },
+
+  observe: function (subject, topic, data) {
+    switch(topic) {
+    case "profile-after-change":
+      Services.obs.addObserver(this, "xpcom-shutdown", false);
+      this._experiments = Experiments.instance();
+      if (this._pendingManifestUpdate) {
+        this._experiments.updateManifest();
+        this._pendingManifestUpdate = false;
+      }
+      break;
+    case "xpcom-shutdown":
+      Services.obs.removeObserver(this, "xpcom-shutdown");
+      this._experiments.uninit();
+      break;
+    }
+  },
+
+  get experiments() {
+    return this._experiments;
+  },
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([ExperimentsService]);
new file mode 100644
--- /dev/null
+++ b/browser/experiments/moz.build
@@ -0,0 +1,16 @@
+# 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/.
+
+EXTRA_COMPONENTS += [
+    'Experiments.manifest',
+    'ExperimentsService.js',
+]
+
+JS_MODULES_PATH = 'modules/experiments'
+
+EXTRA_JS_MODULES += [
+  'Experiments.jsm',
+]
+
+XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ede358c25878f81e6f7bdb9830873a9f6f2a3b8e
GIT binary patch
literal 511
zc$^FHW@Zs#U|`^22%nVa@>-yvKbVn$;W`ro12=;VLuOuaNn%cpUQtR~Xb2|*^H)pd
zU`;FK;L-|i21b^zK$T$4r9m6>nhiwm{N$RRGqtC&j8jQv>ZP36RjY#B)%U1`ZR~wB
z-RbvNQ$LOcTYL)Mzq>oPa9>WU>%IdK3$H5qEV76ZbDXe$o%s>pDX}f?N34?9WX@Vq
z*|EiPLyFz}%@(PdUcNh*e~VVkU%6?sfx_L#t&%4?!(Pb;hThTlc>H5x<C`m+FWeNI
zwEp0`o)s65D%7m@-@)PUmMR{qHpMkAG^9xNTf)VaR?B5l)J`$UIxSp#kb{#ebJHsU
z>u+an&a9dmYbCyJ)BLaZ4^C~oBf~KN)h;3HzPe|xeGh%RF~RlqtKbK(?BCDRYOUHI
zF^{>YL384E+q!x+J_q@ZyDFc~*h|f6FP}20_afKH%~~h972=DZv|M^vBQiZ;&(36(
z!v`lFS^ssh_C}e=dIq6&CUOURQY)T3HZA+|uI^R&lkL;YMP+temC}`p6VLE_P|5s6
z|J;_Ub9c__HpK^cGcw6B<BAyx1`q&p8J0AHSSazt3W+DQI12D)WrJv9WC#S(GeEi-
E017tBb^rhX
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ff2a262687a1210438853db1406f97a4cb289f58
GIT binary patch
literal 512
zc$^FHW@Zs#U|`^2n6n_yWucwk{18S4hTBXG4BQMd44HYwC5bsXdPON|p&^_M%r}CR
zgF(2of}4Sn<ttDnSaWI6!J;Mukv*S9HSbvoH*FD@beX&HUe(KzD*vY|XHIJIoX7h6
zE3YHVv1!6@ZrARXp0rjfa#FrQSjJYK=YcZoJ}I2Ix6S&I+9|!m!bww&!mgKw*t_16
zxmmL@VuMAZr;~5tf^Ul^n1|eql$h}C;~|kJF4|xFHN4-6s}%k@<FM^j<cl{PC$}Yh
zcMExuB=B$L`5ipx-Ij)jYE5yCn-)@}%9n6)Wz=$>6s;~9S*L|74{&gDWp2t7wElJ`
z!hP?_b#sfuZ=V14{=qSayuOCntF(^JTRi_~)#MGfW+@kU{Sn&n@BHrNUJLEkO^a<1
zG*JDt?6|!3BZe=HH)55}|5@y4UbA4zo1QR_xDdxU#-G!a)u-@1U%aE`&s|w%Nxo?+
zb!+ACb{suX^>|w>182CDf5NdKn~xv0tgFiHzX|<3do-4py)LU)jCXyJN&1I9jGv~b
zN7*O8`}XXhet<V4lN>XysF7d*0U(!QNh64b5>c#>h(e2`0B=?{h$cpcKp;I6q?-W%
D<`l^#
new file mode 100644
--- /dev/null
+++ b/browser/experiments/test/xpcshell/experiments_1.manifest
@@ -0,0 +1,19 @@
+{
+  "version": 1,
+  "experiments": [
+    {
+      "id": "test-experiment-1@tests.mozilla.org",
+      "xpiURL": "https://experiments.mozilla.org/foo.xpi",
+      "xpiHash": "sha1:cb1eb32b89d86d78b7326f416cf404548c5e0099",
+      "startTime": 1393000000,
+      "endTime": 1394000000,
+      "appName": ["Firefox", "Fennec"],
+      "minVersion": "28",
+      "maxVersion": "30",
+      "maxActiveSeconds": 60,
+      "os": ["windows", "linux", "osx"],
+      "channel": ["daily", "weekly", "nightly"],
+      "jsfilter": "function filter(context) { return true; }"
+    }
+  ]
+}
new file mode 100644
--- /dev/null
+++ b/browser/experiments/test/xpcshell/head.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://services-sync/healthreport.jsm", this);
+Cu.import("resource://testing-common/services/healthreport/utils.jsm", this);
+Cu.import("resource://gre/modules/services/healthreport/providers.jsm");
+
+const EXPERIMENT1_ID       = "test-experiment-1@tests.mozilla.org";
+const EXPERIMENT1_XPI_SHA1 = "sha1:08c4d3ef1d0fc74faa455e85106ef0bc8cf8ca90";
+const EXPERIMENT1_XPI_NAME = "experiment-1.xpi";
+
+const EXPERIMENT2_ID       = "test-experiment-2@tests.mozilla.org"
+const EXPERIMENT2_XPI_SHA1 = "sha1:81877991ec70360fb48db84c34a9b2da7aa41d6a";
+const EXPERIMENT2_XPI_NAME = "experiment-2.xpi";
+
+let gAppInfo = null;
+
+function getReporter(name, uri, inspected) {
+  return Task.spawn(function init() {
+    let reporter = getHealthReporter(name, uri, inspected);
+    yield reporter.init();
+
+    yield reporter._providerManager.registerProviderFromType(
+      HealthReportProvider);
+
+    throw new Task.Result(reporter);
+  });
+}
+
+function patchPolicy(policy, data) {
+  for (let key of Object.keys(data)) {
+    Object.defineProperty(policy, key, {
+      value: data[key],
+      writable: true,
+    });
+  }
+}
+
+function defineNow(policy, time) {
+  patchPolicy(policy, { now: () => new Date(time) });
+}
+
+function futureDate(date, offset) {
+  return new Date(date.getTime() + offset);
+}
+
+function dateToSeconds(date) {
+  return date.getTime() / 1000;
+}
+
+function createAppInfo(options) {
+  const XULAPPINFO_CONTRACTID = "@mozilla.org/xre/app-info;1";
+  const XULAPPINFO_CID = Components.ID("{c763b610-9d49-455a-bbd2-ede71682a1ac}");
+
+  let options = options || {};
+  let id = options.id || "xpcshell@tests.mozilla.org";
+  let name = options.name || "XPCShell";
+  let version = options.version || "1.0";
+  let platformVersion = options.platformVersion || "1.0";
+  let date = options.date || new Date();
+
+  let buildID = "" + date.getYear() + date.getMonth() + date.getDate() + "01";
+
+  gAppInfo = {
+    // nsIXULAppInfo
+    vendor: "Mozilla",
+    name: name,
+    ID: id,
+    version: version,
+    appBuildID: buildID,
+    platformVersion: platformVersion ? platformVersion : "1.0",
+    platformBuildID: buildID,
+
+    // nsIXULRuntime
+    inSafeMode: false,
+    logConsoleErrors: true,
+    OS: "XPCShell",
+    XPCOMABI: "noarch-spidermonkey",
+    invalidateCachesOnRestart: function invalidateCachesOnRestart() {
+      // Do nothing
+    },
+
+    // nsICrashReporter
+    annotations: {},
+
+    annotateCrashReport: function(key, data) {
+      this.annotations[key] = data;
+    },
+
+    QueryInterface: XPCOMUtils.generateQI([Ci.nsIXULAppInfo,
+                                           Ci.nsIXULRuntime,
+                                           Ci.nsICrashReporter,
+                                           Ci.nsISupports])
+  };
+
+  let XULAppInfoFactory = {
+    createInstance: function (outer, iid) {
+      if (outer != null) {
+        throw Cr.NS_ERROR_NO_AGGREGATION;
+      }
+      return gAppInfo.QueryInterface(iid);
+    }
+  };
+
+  let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+  registrar.registerFactory(XULAPPINFO_CID, "XULAppInfo",
+                            XULAPPINFO_CONTRACTID, XULAppInfoFactory);
+}
new file mode 100644
--- /dev/null
+++ b/browser/experiments/test/xpcshell/xpcshell.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+head = head.js
+tail =
+firefox-appdir = browser
+support-files =
+  experiments_1.manifest
+  ../experiment-1.xpi
--- a/browser/moz.build
+++ b/browser/moz.build
@@ -4,16 +4,17 @@
 # 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/.
 
 CONFIGURE_SUBST_FILES += ['installer/Makefile']
 
 PARALLEL_DIRS += [
     'base',
     'components',
+    'experiments',
     'fuel',
     'locales',
     'modules',
     'themes',
     'extensions',
 ]
 
 DIRS += [