diff --git a/browser/experiments/Experiments.jsm b/browser/experiments/Experiments.jsm index 628ac044dc1..055c802c067 100644 --- a/browser/experiments/Experiments.jsm +++ b/browser/experiments/Experiments.jsm @@ -24,6 +24,8 @@ 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", @@ -70,6 +72,9 @@ 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", @@ -103,6 +108,7 @@ const TELEMETRY_LOG = { 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; @@ -200,8 +206,9 @@ function addonInstallForURL(url, hash) { // 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; } @@ -462,9 +469,33 @@ Experiments.Experiments.prototype = { 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); }, @@ -711,6 +742,11 @@ Experiments.Experiments.prototype = { 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. @@ -1754,7 +1790,14 @@ Experiments.ExperimentEntry.prototype = { 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; }, @@ -1957,3 +2000,159 @@ ExperimentsProvider.prototype = Object.freeze({ }); }, }); + +/** + * 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 + +}); diff --git a/browser/experiments/test/xpcshell/head.js b/browser/experiments/test/xpcshell/head.js index f4c78d3e6c6..4bbaee22fa2 100644 --- a/browser/experiments/test/xpcshell/head.js +++ b/browser/experiments/test/xpcshell/head.js @@ -155,10 +155,16 @@ function loadAddonManager() { 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; } diff --git a/browser/experiments/test/xpcshell/test_previous_provider.js b/browser/experiments/test/xpcshell/test_previous_provider.js new file mode 100644 index 00000000000..a5212ab6174 --- /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); + } + } +}); diff --git a/browser/experiments/test/xpcshell/xpcshell.ini b/browser/experiments/test/xpcshell/xpcshell.ini index 622bd1d9643..1e00818d522 100644 --- a/browser/experiments/test/xpcshell/xpcshell.ini +++ b/browser/experiments/test/xpcshell/xpcshell.ini @@ -20,3 +20,4 @@ generated-files = [test_fetch.js] [test_telemetry.js] [test_healthreport.js] +[test_previous_provider.js] diff --git a/toolkit/mozapps/extensions/test/browser/browser_experiments.js b/toolkit/mozapps/extensions/test/browser/browser_experiments.js index e937649ad7a..852366eabff 100644 --- a/toolkit/mozapps/extensions/test/browser/browser_experiments.js +++ b/toolkit/mozapps/extensions/test/browser/browser_experiments.js @@ -260,12 +260,50 @@ add_task(function testDeactivateExperiment() { 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() { @@ -285,6 +323,4 @@ add_task(function* testCleanup() { Assert.equal(addons.length, 0, "No experiment add-ons are installed."); yield close_manager(gManagerWindow); - }); -