Bug 990111 - Add-on provider for historical experiments. r=irving
authorGeorg Fritzsche <georg.fritzsche@googlemail.com>
Wed, 23 Apr 2014 14:34:48 +0200
changeset 199286 3d7485d2bb5cf5c6ce5a84161128569ffe514a15
parent 199285 7bd04d2f29844200251a852ad7e9cfcc443746c3
child 199287 bd993b75b61fddc41a75621ad46f7be36e4ba1a1
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)
reviewersirving
bugs990111
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 990111 - Add-on provider for historical experiments. r=irving
browser/experiments/Experiments.jsm
browser/experiments/test/xpcshell/head.js
browser/experiments/test/xpcshell/test_previous_provider.js
browser/experiments/test/xpcshell/xpcshell.ini
toolkit/mozapps/extensions/test/browser/browser_experiments.js
--- a/browser/experiments/Experiments.jsm
+++ b/browser/experiments/Experiments.jsm
@@ -19,16 +19,18 @@ Cu.import("resource://gre/modules/osfile
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/AsyncShutdown.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
                                   "resource://gre/modules/UpdateChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate",
+                                  "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing",
                                   "resource://gre/modules/TelemetryPing.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryLog",
                                   "resource://gre/modules/TelemetryLog.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
                                   "resource://services-common/utils.js");
 XPCOMUtils.defineLazyModuleGetter(this, "Metrics",
                                   "resource://gre/modules/Metrics.jsm");
@@ -65,16 +67,19 @@ const PREF_MANIFEST_CHECKCERT   = "manif
 const PREF_MANIFEST_REQUIREBUILTIN = "manifest.cert.requireBuiltin"; // experiments.manifest.cert.requireBuiltin
 const PREF_FORCE_SAMPLE = "force-sample-value"; // experiments.force-sample-value
 
 const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled";
 
 const PREF_BRANCH_TELEMETRY     = "toolkit.telemetry.";
 const PREF_TELEMETRY_ENABLED    = "enabled";
 
+const URI_EXTENSION_STRINGS     = "chrome://mozapps/locale/extensions/extensions.properties";
+const STRING_TYPE_NAME          = "type.%ID%.name";
+
 const TELEMETRY_LOG = {
   // log(key, [kind, experimentId, details])
   ACTIVATION_KEY: "EXPERIMENT_ACTIVATION",
   ACTIVATION: {
     // Successfully activated.
     ACTIVATED: "ACTIVATED",
     // Failed to install the add-on.
     INSTALL_FAILURE: "INSTALL_FAILURE",
@@ -98,16 +103,17 @@ const TELEMETRY_LOG = {
     // details will be provided.
     RECHECK: "RECHECK",
   },
 };
 
 const gPrefs = new Preferences(PREF_BRANCH);
 const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY);
 let gExperimentsEnabled = false;
+let gAddonProvider = null;
 let gExperiments = null;
 let gLogAppenderDump = null;
 let gPolicyCounter = 0;
 let gExperimentsCounter = 0;
 let gExperimentEntryCounter = 0;
 
 // Tracks active AddonInstall we know about so we can deny external
 // installs.
@@ -195,18 +201,19 @@ function addonInstallForURL(url, hash) {
                                 "application/x-xpinstall", hash);
   return deferred.promise;
 }
 
 // Returns a promise that is resolved with an Array<Addon> of the installed
 // experiment addons.
 function installedExperimentAddons() {
   let deferred = Promise.defer();
-  AddonManager.getAddonsByTypes(["experiment"],
-                                addons => deferred.resolve(addons));
+  AddonManager.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve([a for (a of addons) if (!a.appDisabled)]);
+  });
   return deferred.promise;
 }
 
 // Takes an Array<Addon> and returns a promise that is resolved when the
 // addons are uninstalled.
 function uninstallAddons(addons) {
   let ids = new Set([a.id for (a of addons)]);
   let deferred = Promise.defer();
@@ -457,19 +464,43 @@ Experiments.Experiments.prototype = {
     this._log.info("Completed uninitialization.");
   }),
 
   _registerWithAddonManager: function () {
     this._log.trace("Registering instance with Addon Manager.");
 
     AddonManager.addAddonListener(this);
     AddonManager.addInstallListener(this);
+
+    if (!gAddonProvider) {
+      // The properties of this AddonType should be kept in sync with the
+      // experiment AddonType registered in XPIProvider.
+      this._log.trace("Registering previous experiment add-on provider.");
+      gAddonProvider = new Experiments.PreviousExperimentProvider(this, [
+          new AddonManagerPrivate.AddonType("experiment",
+                                            URI_EXTENSION_STRINGS,
+                                            STRING_TYPE_NAME,
+                                            AddonManager.VIEW_TYPE_LIST,
+                                            11000,
+                                            AddonManager.TYPE_UI_HIDE_EMPTY),
+        ]);
+      AddonManagerPrivate.registerProvider(gAddonProvider);
+    }
+
   },
 
   _unregisterWithAddonManager: function () {
+    this._log.trace("Unregistering instance with Addon Manager.");
+
+    if (gAddonProvider) {
+      this._log.trace("Unregistering previous experiment add-on provider.");
+      AddonManagerPrivate.unregisterProvider(gAddonProvider);
+      gAddonProvider = null;
+    }
+
     AddonManager.removeInstallListener(this);
     AddonManager.removeAddonListener(this);
   },
 
   /**
    * Throws an exception if we've already shut down.
    */
   _checkForShutdown: function() {
@@ -706,16 +737,21 @@ Experiments.Experiments.prototype = {
     this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED);
   },
 
   onInstallStarted: function (install) {
     if (install.addon.type != "experiment") {
       return;
     }
 
+    if (install.addon.appDisabled) {
+      // This is a PreviousExperiment
+      return;
+    }
+
     // We want to be in control of all experiment add-ons: reject installs
     // for add-ons that we don't know about.
 
     // We have a race condition of sorts to worry about here. We have 2
     // onInstallStarted listeners. This one (the global one) and the one
     // created as part of ExperimentEntry._installAddon. Because of the order
     // they are registered in, this one likely executes first. Unfortunately,
     // this means that the add-on ID is not yet set on the ExperimentEntry.
@@ -1749,17 +1785,24 @@ Experiments.ExperimentEntry.prototype = 
    */
   _getAddon: function () {
     if (!this._addonId) {
       return Promise.resolve(null);
     }
 
     let deferred = Promise.defer();
 
-    AddonManager.getAddonByID(this._addonId, deferred.resolve);
+    AddonManager.getAddonByID(this._addonId, (addon) => {
+      if (addon && addon.appDisabled) {
+        // Don't return PreviousExperiments.
+        addon = null;
+      }
+
+      deferred.resolve(addon);
+    });
 
     return deferred.promise;
   },
 
   _logTermination: function (terminationKind, terminationReason) {
     if (terminationKind === undefined) {
       return;
     }
@@ -1952,8 +1995,164 @@ ExperimentsProvider.prototype = Object.f
 
         this._log.info("Recording last active experiment: " + todayActive.id);
         yield m.setDailyLastText("lastActive", todayActive.id,
                                  this._experiments._policy.now());
       }.bind(this));
     });
   },
 });
+
+/**
+ * An Add-ons Manager provider that knows about old experiments.
+ *
+ * This provider exposes read-only add-ons corresponding to previously-active
+ * experiments. The existence of this provider (and the add-ons it knows about)
+ * facilitates the display of old experiments in the Add-ons Manager UI with
+ * very little custom code in that component.
+ */
+this.Experiments.PreviousExperimentProvider = function (experiments) {
+  this._experiments = experiments;
+}
+
+this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({
+  startup: function () {},
+  shutdown: function () {},
+
+  getAddonByID: function (id, cb) {
+    this._getPreviousExperiments().then((experiments) => {
+      for (let experiment of experiments) {
+        if (experiment.id == id) {
+          cb(new PreviousExperimentAddon(experiment));
+          return;
+        }
+      }
+
+      cb(null);
+    },
+    (error) => {
+      cb(null);
+    });
+  },
+
+  getAddonsByTypes: function (types, cb) {
+    if (types && types.length > 0 && types.indexOf("experiment") == -1) {
+      cb([]);
+      return;
+    }
+
+    this._getPreviousExperiments().then((experiments) => {
+      cb([new PreviousExperimentAddon(e) for (e of experiments)]);
+    },
+    (error) => {
+      cb([]);
+    });
+  },
+
+  _getPreviousExperiments: function () {
+    return this._experiments.getExperiments().then((experiments) => {
+      return Promise.resolve([e for (e of experiments) if (!e.active)]);
+    });
+  },
+});
+
+/**
+ * An add-on that represents a previously-installed experiment.
+ */
+function PreviousExperimentAddon(experiment) {
+  this._id = experiment.id;
+  this._name = experiment.name;
+  this._endDate = experiment.endDate;
+}
+
+PreviousExperimentAddon.prototype = Object.freeze({
+  // BEGIN REQUIRED ADDON PROPERTIES
+
+  get appDisabled() {
+    return true;
+  },
+
+  get blocklistState() {
+    Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+  },
+
+  get creator() {
+    return new AddonManagerPrivate.AddonAuthor("");
+  },
+
+  get foreignInstall() {
+    return false;
+  },
+
+  get id() {
+    return this._id;
+  },
+
+  get isActive() {
+    return false;
+  },
+
+  get isCompatible() {
+    return true;
+  },
+
+  get isPlatformCompatible() {
+    return true;
+  },
+
+  get name() {
+    return this._name;
+  },
+
+  get pendingOperations() {
+    return AddonManager.PENDING_NONE;
+  },
+
+  get permissions() {
+    return 0;
+  },
+
+  get providesUpdatesSecurely() {
+    return true;
+  },
+
+  get scope() {
+    return AddonManager.SCOPE_PROFILE;
+  },
+
+  get type() {
+    return "experiment";
+  },
+
+  get userDisabled() {
+    return true;
+  },
+
+  get version() {
+    return null;
+  },
+
+  // END REQUIRED PROPERTIES
+
+  // BEGIN OPTIONAL PROPERTIES
+
+  // TODO description
+
+  get updateDate() {
+    return new Date(this._endDate);
+  },
+
+  // END OPTIONAL PROPERTIES
+
+  // BEGIN REQUIRED METHODS
+
+  isCompatibleWith: function (appVersion, platformVersion) {
+    return true;
+  },
+
+  findUpdates: function (listener, reason, appVersion, platformVersion) {
+    AddonManagerPrivate.callNoUpdateListeners(this, listener, reason,
+                                              appVersion, platformVersion);
+  },
+
+  // END REQUIRED METHODS
+
+});
--- a/browser/experiments/test/xpcshell/head.js
+++ b/browser/experiments/test/xpcshell/head.js
@@ -150,20 +150,26 @@ function loadAddonManager() {
   let head = "../../../../toolkit/mozapps/extensions/test/xpcshell/head_addons.js";
   let file = do_get_file(head);
   let uri = ns.Services.io.newFileURI(file);
   ns.Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
   createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
   startupManager();
 }
 
-function getExperimentAddons() {
+function getExperimentAddons(previous=false) {
   let deferred = Promise.defer();
 
-  AddonManager.getAddonsByTypes(["experiment"], deferred.resolve);
+  AddonManager.getAddonsByTypes(["experiment"], (addons) => {
+    if (previous) {
+      deferred.resolve(addons);
+    } else {
+      deferred.resolve([a for (a of addons) if (!a.appDisabled)]);
+    }
+  });
 
   return deferred.promise;
 }
 
 function createAppInfo(optionsIn) {
   const XULAPPINFO_CONTRACTID = "@mozilla.org/xre/app-info;1";
   const XULAPPINFO_CID = Components.ID("{c763b610-9d49-455a-bbd2-ede71682a1ac}");
 
new file mode 100644
--- /dev/null
+++ b/browser/experiments/test/xpcshell/test_previous_provider.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource:///modules/experiments/Experiments.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+let gDataRoot;
+let gHttpServer;
+let gManifestObject;
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function test_setup() {
+  loadAddonManager();
+  do_get_profile();
+
+  gHttpServer = new HttpServer();
+  gHttpServer.start(-1);
+  let httpRoot = "http://localhost:" + gHttpServer.identity.primaryPort + "/";
+  gDataRoot = httpRoot + "data/";
+  gHttpServer.registerDirectory("/data/", do_get_cwd());
+  gHttpServer.registerPathHandler("/manifests/handler", (req, res) => {
+    res.setStatusLine(null, 200, "OK");
+    res.write(JSON.stringify(gManifestObject));
+    res.processAsync();
+    res.finish();
+  });
+  do_register_cleanup(() => gHttpServer.stop(() => {}));
+
+  Services.prefs.setBoolPref("experiments.enabled", true);
+  Services.prefs.setCharPref("experiments.manifest.uri",
+                             httpRoot + "manifests/handler");
+  Services.prefs.setBoolPref("experiments.logging.dump", true);
+  Services.prefs.setCharPref("experiments.logging.level", "Trace");
+  disableCertificateChecks();
+});
+
+add_task(function* test_provider_basic() {
+  let e = Experiments.instance();
+
+  let provider = new Experiments.PreviousExperimentProvider(e);
+  let deferred = Promise.defer();
+  provider.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve(addons);
+  });
+  let addons = yield deferred.promise;
+  Assert.ok(Array.isArray(addons), "getAddonsByTypes returns an Array.");
+  Assert.equal(addons.length, 0, "No previous add-ons returned.");
+
+  gManifestObject = {
+    version: 1,
+    experiments: [
+      {
+        id: EXPERIMENT1_ID,
+        xpiURL: gDataRoot + EXPERIMENT1_XPI_NAME,
+        xpiHash: EXPERIMENT1_XPI_SHA1,
+        startTime: Date.now() / 1000 - 60,
+        endTime: Date.now() / 1000 + 60,
+        maxActiveSeconds: 60,
+        appName: ["XPCShell"],
+        channel: [e._policy.updatechannel()],
+      },
+    ],
+  };
+
+  yield e.updateManifest();
+
+  deferred = Promise.defer();
+  provider.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve(addons);
+  });
+  addons = yield deferred.promise;
+  Assert.equal(addons.length, 0, "Still no previous experiment.");
+
+  let experiments = yield e.getExperiments();
+  Assert.equal(experiments.length, 1, "1 experiment present.");
+  Assert.ok(experiments[0].active, "It is active.");
+
+  // Deactivate it.
+  defineNow(e._policy, new Date(gManifestObject.experiments[0].endTime * 1000 + 1000));
+  yield e.updateManifest();
+
+  experiments = yield e.getExperiments();
+  Assert.equal(experiments.length, 1, "1 experiment present.");
+  Assert.equal(experiments[0].active, false, "It isn't active.");
+
+  deferred = Promise.defer();
+  provider.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve(addons);
+  });
+  addons = yield deferred.promise;
+  Assert.equal(addons.length, 1, "1 previous add-on known.");
+  Assert.equal(addons[0].id, EXPERIMENT1_ID, "ID matches expected.");
+
+  deferred = Promise.defer();
+  provider.getAddonByID(EXPERIMENT1_ID, (addon) => {
+    deferred.resolve(addon);
+  });
+  let addon = yield deferred.promise;
+  Assert.ok(addon, "We got an add-on from its ID.");
+  Assert.equal(addon.id, EXPERIMENT1_ID, "ID matches expected.");
+  Assert.ok(addon.appDisabled, "Add-on is a previous experiment.");
+  Assert.ok(addon.userDisabled, "Add-on is disabled.");
+  Assert.equal(addon.type, "experiment", "Add-on is an experiment.");
+  Assert.equal(addon.isActive, false, "Add-on is not active.");
+  Assert.equal(addon.permissions, 0, "Add-on has no permissions.");
+
+  deferred = Promise.defer();
+  AddonManager.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve(addons);
+  });
+  addons = yield deferred.promise;
+  Assert.equal(addons.length, 1, "Got 1 experiment from add-on manager.");
+  Assert.equal(addons[0].id, EXPERIMENT1_ID, "ID matches expected.");
+  Assert.ok(addons[0].appDisabled, "It is a previous experiment add-on.");
+});
+
+add_task(function* test_active_and_previous() {
+  // Building on the previous test, activate experiment 2.
+  let e = Experiments.instance();
+  let provider = new Experiments.PreviousExperimentProvider(e);
+
+  gManifestObject = {
+    version: 1,
+    experiments: [
+      {
+        id: EXPERIMENT2_ID,
+        xpiURL: gDataRoot + EXPERIMENT2_XPI_NAME,
+        xpiHash: EXPERIMENT2_XPI_SHA1,
+        startTime: Date.now() / 1000 - 60,
+        endTime: Date.now() / 1000 + 60,
+        maxActiveSeconds: 60,
+        appName: ["XPCShell"],
+        channel: [e._policy.updatechannel()],
+      },
+    ],
+  };
+
+  defineNow(e._policy, new Date());
+  yield e.updateManifest();
+
+  let experiments = yield e.getExperiments();
+  Assert.equal(experiments.length, 2, "2 experiments known.");
+
+  let deferred = Promise.defer();
+  provider.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve(addons);
+  });
+  let addons = yield deferred.promise;
+  Assert.equal(addons.length, 1, "1 previous experiment.");
+
+  deferred = Promise.defer();
+  AddonManager.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve(addons);
+  });
+  addons = yield deferred.promise;
+  Assert.equal(addons.length, 2, "2 experiment add-ons known.");
+
+  for (let addon of addons) {
+    if (addon.id == EXPERIMENT1_ID) {
+      Assert.equal(addon.isActive, false, "Add-on is not active.");
+      Assert.ok(addon.appDisabled, "Should be a previous experiment.");
+    }
+    else if (addon.id == EXPERIMENT2_ID) {
+      Assert.ok(addon.isActive, "Add-on is active.");
+      Assert.ok(!addon.appDisabled, "Should not be a previous experiment.");
+    }
+    else {
+      throw new Error("Unexpected add-on ID: " + addon.id);
+    }
+  }
+});
--- a/browser/experiments/test/xpcshell/xpcshell.ini
+++ b/browser/experiments/test/xpcshell/xpcshell.ini
@@ -15,8 +15,9 @@ generated-files =
 [test_activate.js]
 [test_api.js]
 [test_cache.js]
 [test_conditions.js]
 [test_disableExperiments.js]
 [test_fetch.js]
 [test_telemetry.js]
 [test_healthreport.js]
+[test_previous_provider.js]
--- a/toolkit/mozapps/extensions/test/browser/browser_experiments.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_experiments.js
@@ -255,22 +255,60 @@ add_task(function* testActivateExperimen
   is_element_visible(el, "Experiment info is visible on experiment tab.");
 });
 
 add_task(function testDeactivateExperiment() {
   if (!gExperiments) {
     return;
   }
 
+  // Fake an empty manifest to purge data from previous manifest.
   yield gExperiments._updateExperiments({
     "version": 1,
     "experiments": [],
   });
 
   yield gExperiments.disableExperiment("testing");
+
+  // We should have a record of the previously-active experiment.
+  let experiments = yield gExperiments.getExperiments();
+  Assert.equal(experiments.length, 1, "1 experiment is known.");
+  Assert.equal(experiments[0].active, false, "Experiment is not active.");
+
+  // We should have a previous experiment in the add-ons manager.
+  let deferred = Promise.defer();
+  AddonManager.getAddonsByTypes(["experiment"], (addons) => {
+    deferred.resolve(addons);
+  });
+  let addons = yield deferred.promise;
+  Assert.equal(addons.length, 1, "1 experiment add-on known.");
+  Assert.ok(addons[0].appDisabled, "It is a previous experiment.");
+  Assert.equal(addons[0].id, "experiment-1", "Add-on ID matches expected.");
+
+  // Verify the UI looks sane.
+  // TODO remove the pane cycle once the UI refreshes automatically.
+  yield gCategoryUtilities.openType("extension");
+
+  Assert.ok(gCategoryUtilities.isTypeVisible("experiment"), "Experiment tab visible.");
+  yield gCategoryUtilities.openType("experiment");
+
+  let item = get_addon_element(gManagerWindow, "experiment-1");
+  Assert.ok(item, "Got add-on element.");
+  Assert.ok(!item.active, "Element should not be active.");
+
+  // User control buttons should not be present because previous experiments
+  // should have no permissions.
+  let el = item.ownerDocument.getAnonymousElementByAttribute(item, "anonid", "remove-btn");
+  is_element_hidden(el, "Remove button is not visible.");
+  el = item.ownerDocument.getAnonymousElementByAttribute(item, "anonid", "disable-btn");
+  is_element_hidden(el, "Disable button is not visible.");
+  el = item.ownerDocument.getAnonymousElementByAttribute(item, "anonid", "enable-btn");
+  is_element_hidden(el, "Enable button is not visible.");
+  el = item.ownerDocument.getAnonymousElementByAttribute(item, "anonid", "preferences-btn");
+  is_element_hidden(el, "Preferences button is not visible.");
 });
 
 add_task(function* testCleanup() {
   if (gExperiments) {
     Services.prefs.clearUserPref("experiments.enabled");
     Services.prefs.clearUserPref("experiments.manifest.cert.checkAttributes");
     Services.prefs.setCharPref("experiments.manifest.uri", gSavedManifestURI);
 
@@ -280,11 +318,9 @@ add_task(function* testCleanup() {
     yield gExperiments.init();
   }
 
   // Check post-conditions.
   let addons = yield getExperimentAddons();
   Assert.equal(addons.length, 0, "No experiment add-ons are installed.");
 
   yield close_manager(gManagerWindow);
-
 });
-