Bug 1279012 - implement onUpdateAvailable and runtime.reload() for WebExtensions r?aswan draft
authorRobert Helmer <rhelmer@mozilla.com>
Tue, 02 Aug 2016 09:37:01 -0700
changeset 398504 54894ee2f49192bb2b29d902a3b0789ac5b00385
parent 396953 cc55edd1533df1a5ab8d71596f7965548155f9e3
child 527678 eb14890926e4c683fef3a4969cc854262f840c74
push id25549
push userrhelmer@mozilla.com
push dateTue, 09 Aug 2016 08:08:32 +0000
reviewersaswan
bugs1279012
milestone51.0a1
Bug 1279012 - implement onUpdateAvailable and runtime.reload() for WebExtensions r?aswan MozReview-Commit-ID: KywrVkcRhzp
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/ExtensionXPCShellUtils.jsm
toolkit/components/extensions/ext-runtime.js
toolkit/components/extensions/schemas/runtime.json
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/AddonUpdateChecker.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/addons/test_delay_update_complete_webextension_v2/manifest.json
toolkit/mozapps/extensions/test/addons/test_delay_update_defer_webextension_v2/manifest.json
toolkit/mozapps/extensions/test/addons/test_delay_update_ignore_webextension_v2/manifest.json
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json
toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -5,17 +5,17 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData"];
 
 /* globals Extension ExtensionData */
 
 /*
  * This file is the main entry point for extensions. When an extension
- * loads, its bootstrap.js file creates a Extension instance
+ * loads, its bootstrap.js file creates an Extension instance
  * and calls .startup() on it. It calls .shutdown() when the extension
  * unloads. Extension manages any extension-specific state in
  * the chrome process.
  */
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
@@ -1061,16 +1061,18 @@ this.Extension = function(addonData) {
   this.id = addonData.id;
   this.baseURI = NetUtil.newURI(this.getURL("")).QueryInterface(Ci.nsIURL);
   this.principal = this.createPrincipal();
 
   this.views = new Set();
 
   this.onStartup = null;
 
+  this.upgrade = null;
+
   this.hasShutdown = false;
   this.onShutdown = new Set();
 
   this.uninstallURL = null;
 
   this.permissions = new Set();
   this.whiteListedHosts = null;
   this.webAccessibleResources = null;
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -811,17 +811,17 @@ LocaleData.prototype = {
 //     SomehowUnregisterListener(listener);
 //   };
 // }).api()
 //
 // The result is an object with addListener, removeListener, and
 // hasListener methods. |context| is an add-on scope (either an
 // ExtensionContext in the chrome process or ExtensionContext in a
 // content process). |name| is for debugging. |register| is a function
-// to register the listener. |register| is only called once, event if
+// to register the listener. |register| is only called once, even if
 // multiple listeners are registered. |register| should return an
 // unregister function that will unregister the listener.
 function EventManager(context, name, register) {
   this.context = context;
   this.name = name;
   this.register = register;
   this.unregister = null;
   this.callbacks = new Set();
--- a/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
+++ b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
@@ -123,16 +123,33 @@ class ExtensionWrapper {
 
   unload() {
     if (this.state != "running") {
       throw new Error("Extension not running");
     }
     this.state = "unloading";
 
     this.extension.shutdown();
+
+    this.state = "unloaded";
+
+    return Promise.resolve();
+  }
+
+  /*
+   * This method marks the extension unloading without actually calling
+   * shutdown, since shutting down a MockExtension causes it to be uninstalled.
+   *
+   * Normally you shouldn't need to use this unless you need to test something
+   * that requires a restart, such as updates.
+   */
+  markUnloaded() {
+    if (this.state != "running") {
+      throw new Error("Extension not running");
+    }
     this.state = "unloaded";
 
     return Promise.resolve();
   }
 
   sendMessage(...args) {
     this.extension.testMessage(...args);
   }
--- a/toolkit/components/extensions/ext-runtime.js
+++ b/toolkit/components/extensions/ext-runtime.js
@@ -1,17 +1,24 @@
 "use strict";
 
 var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
+                                  "resource://gre/modules/ExtensionManagement.jsm");
+
 var {
   EventManager,
+  SingletonEventManager,
   ignoreEvent,
 } = ExtensionUtils;
 
 XPCOMUtils.defineLazyModuleGetter(this, "NativeApp",
                                   "resource://gre/modules/NativeMessaging.jsm");
 
 extensions.registerSchemaAPI("runtime", (extension, context) => {
   return {
@@ -24,16 +31,46 @@ extensions.registerSchemaAPI("runtime", 
       }).api(),
 
       onInstalled: ignoreEvent(context, "runtime.onInstalled"),
 
       onMessage: context.messenger.onMessage("runtime.onMessage"),
 
       onConnect: context.messenger.onConnect("runtime.onConnect"),
 
+      onUpdateAvailable: new SingletonEventManager(context, "runtime.onUpdateAvailable", fire => {
+        let instanceID = extension.addonData.instanceID;
+        AddonManager.addUpgradeListener(instanceID, upgrade => {
+          extension.upgrade = upgrade;
+          let details = {
+            version: upgrade.version(),
+          };
+          context.runSafe(fire, details);
+        });
+        return () => {
+          extension.upgrade = null;
+          AddonManager.removeUpgradeListener(instanceID);
+        };
+      }).api(),
+
+      reload: () => {
+        if (extension.upgrade) {
+          // If there is a pending update, install it now.
+          extension.upgrade.install();
+        } else {
+          // Otherwise, reload the current extension.
+          return new Promise(resolve =>
+            AddonManager.getAddonByID(extension.id, addon => {
+              addon.reload();
+              resolve();
+            })
+          );
+        }
+      },
+
       connect: function(extensionId, connectInfo) {
         let name = connectInfo !== null && connectInfo.name || "";
         let recipient = extensionId !== null ? {extensionId} : {extensionId: extension.id};
 
         return context.messenger.connect(Services.cpmm, name, recipient);
       },
 
       sendMessage: function(...args) {
--- a/toolkit/components/extensions/schemas/runtime.json
+++ b/toolkit/components/extensions/schemas/runtime.json
@@ -204,17 +204,16 @@
             "optional": true,
             "description": "Called when the uninstall URL is set. If the given URL is invalid, $(ref:runtime.lastError) will be set.",
             "parameters": []
           }
         ]
       },
       {
         "name": "reload",
-        "unsupported": true,
         "description": "Reloads the app or extension.",
         "type": "function",
         "parameters": []
       },
       {
         "name": "requestUpdateCheck",
         "unsupported": true,
         "type": "function",
@@ -437,17 +436,16 @@
       {
         "name": "onSuspendCanceled",
         "unsupported": true,
         "type": "function",
         "description": "Sent after onSuspend to indicate that the app won't be unloaded after all."
       },
       {
         "name": "onUpdateAvailable",
-        "unsupported": true,
         "type": "function",
         "description": "Fired when an update is available, but isn't installed immediately because the app is currently running. If you do nothing, the update will be installed the next time the background page gets unloaded, if you want it to be installed sooner you can explicitly call $(ref:runtime.reload). If your extension is using a persistent background page, the background page of course never gets unloaded, so unless you call $(ref:runtime.reload) manually in response to this event the update will not get installed until the next time the browser itself restarts. If no handlers are listening for this event, and your extension has a persistent background page, it behaves as if $(ref:runtime.reload) is called in response to this event.",
         "parameters": [
           {
             "type": "object",
             "name": "details",
             "properties": {
               "version": {
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -2256,17 +2256,17 @@ var AddonManagerInternal = {
       else
         pos++;
     }
   },
   /*
    * Adds new or overrides existing UpgradeListener.
    *
    * @param  aInstanceID
-   *         The instance ID of an addon to listen to register a listener for.
+   *         The instance ID of an addon to register a listener for.
    * @param  aCallback
    *         The callback to invoke when updates are available for this addon.
    * @throws if there is no addon matching the instanceID
    */
   addUpgradeListener: function(aInstanceID, aCallback) {
    if (!aInstanceID || typeof aInstanceID != "symbol")
      throw Components.Exception("aInstanceID must be a symbol",
                                 Cr.NS_ERROR_INVALID_ARG);
@@ -2275,17 +2275,17 @@ var AddonManagerInternal = {
     throw Components.Exception("aCallback must be a function",
                                Cr.NS_ERROR_INVALID_ARG);
 
    this.getAddonByInstanceID(aInstanceID).then(wrapper => {
      if (!wrapper) {
        throw Error("No addon matching instanceID:", aInstanceID.toString());
      }
      let addonId = wrapper.addonId();
-     logger.debug(`Registering upgrade listener for ${addonId}`)
+     logger.debug(`Registering upgrade listener for ${addonId}`);
      this.upgradeListeners.set(addonId, aCallback);
    });
   },
 
   /**
    * Removes an UpgradeListener if the listener is registered.
    *
    * @param  aInstanceID
--- a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.jsm
@@ -500,18 +500,20 @@ function parseJSONManifest(aId, aUpdateK
 
     logger.debug(`Found an update entry for ${aId} version ${version}`);
 
     let applications = getProperty(update, "applications", "object",
                                    { gecko: {} });
 
     // "gecko" is currently the only supported application entry. If
     // it's missing, skip this update.
-    if (!("gecko" in applications))
+    if (!("gecko" in applications)) {
+      logger.debug("gecko not in application entry, skipping update of ${addon}")
       continue;
+    }
 
     let app = getProperty(applications, "gecko", "object");
 
     let appEntry = {
       id: TOOLKIT_ID,
       minVersion: getProperty(app, "strict_min_version", "string",
                               AddonManagerPrivate.webExtensionsMinPlatformVersion),
       maxVersion: "*",
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -6103,16 +6103,17 @@ AddonInstall.prototype = {
 
             AddonManagerPrivate.callInstallListeners("onInstallPostponed",
                                                      this.listeners, this.wrapper)
 
             // upgrade has been staged for restart, notify the add-on and give
             // it a way to resume.
             let callback = AddonManagerPrivate.getUpgradeListener(this.addon.id);
             callback({
+              version: () => this.version,
               install: () => {
                 switch (this.state) {
                   case AddonManager.STATE_INSTALLED:
                     // this addon has already been installed, nothing to do
                     logger.warn(`${this.addon.id} tried to resume postponed upgrade, but it's already installed`);
                     break;
                   case AddonManager.STATE_POSTPONED:
                     logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
@@ -7516,35 +7517,38 @@ AddonWrapper.prototype = {
       return false;
     }
     finally {
       zipReader.close();
     }
   },
 
   /**
-   * Reloads the add-on as if one had uninstalled it then reinstalled it.
+   * Reloads the add-on.
    *
-   * Currently, only temporarily installed add-ons can be reloaded. Attempting
-   * to reload other kinds of add-ons will result in a rejected promise.
+   * For temporarily installed add-ons, this uninstalls and re-installs the addon.
+   * Otherwise, the addon is disabled and then re-enabled.
    *
    * @return Promise
    */
   reload: function() {
     return new Promise((resolve) => {
       const addon = addonFor(this);
 
-      if (!this.temporarilyInstalled) {
-        logger.debug(`Cannot reload add-on at ${addon._sourceBundle}`);
-        throw new Error("Only temporary add-ons can be reloaded");
-      }
-
       logger.debug(`reloading add-on ${addon.id}`);
-      // This function supports re-installing an existing add-on.
-      resolve(AddonManager.installTemporaryAddon(addon._sourceBundle));
+      if (this.temporarilyInstalled) {
+        // This function supports re-installing an existing add-on.
+        resolve(AddonManager.installTemporaryAddon(addon._sourceBundle));
+      } else {
+        XPIProvider.updateAddonDisabledState(addon, true);
+        XPIProvider.updateAddonDisabledState(addon, false);
+        resolve();
+      }
+      let addonFile = addon.getResourceURI;
+      Services.obs.notifyObservers(addonFile, "flush-cache-entry", null);
     });
   },
 
   /**
    * Returns a URI to the selected resource or to the add-on bundle if aPath
    * is null. URIs to the bundle will always be file: URIs. URIs to resources
    * will be file: URIs if the add-on is unpacked or jar: URIs if the add-on is
    * still an XPI file.
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_complete_webextension_v2/manifest.json
@@ -0,0 +1,10 @@
+{
+  "manifest_version": 2,
+  "name": "Delay Upgrade",
+  "version": "2.0",
+  "applications": {
+    "gecko": {
+      "id": "test_delay_update_complete_webext@tests.mozilla.org"
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_defer_webextension_v2/manifest.json
@@ -0,0 +1,10 @@
+{
+  "manifest_version": 2,
+  "name": "Delay Upgrade",
+  "version": "2.0",
+  "applications": {
+    "gecko": {
+      "id": "test_delay_update_defer_webext@tests.mozilla.org"
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_ignore_webextension_v2/manifest.json
@@ -0,0 +1,10 @@
+{
+  "manifest_version": 2,
+  "name": "Delay Upgrade",
+  "version": "2.0",
+  "applications": {
+    "gecko": {
+      "id": "test_delay_update_ignore_webext@tests.mozilla.org"
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json
@@ -0,0 +1,11 @@
+{
+  "addons": {
+    "test_delay_update_complete_webext@tests.mozilla.org": {
+      "updates": [
+        { "version": "2.0",
+          "update_link": "http://localhost:%PORT%/addons/test_delay_update_complete_webextension_v2.xpi"
+        }
+      ]
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json
@@ -0,0 +1,11 @@
+{
+  "addons": {
+    "test_delay_update_defer_webext@tests.mozilla.org": {
+      "updates": [
+        { "version": "2.0",
+          "update_link": "http://localhost:%PORT%/addons/test_delay_update_defer_webextension_v2.xpi"
+        }
+      ]
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json
@@ -0,0 +1,11 @@
+{
+  "addons": {
+    "test_delay_update_ignore_webext@tests.mozilla.org": {
+      "updates": [
+        { "version": "2.0",
+          "update_link": "http://localhost:%PORT%/addons/test_delay_update_ignore_webextension_v2.xpi"
+        }
+      ]
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json
@@ -0,0 +1,7 @@
+{
+  "addons": {
+    "test_no_update_webext@tests.mozilla.org": {
+      "updates": []
+    }
+  }
+}
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -36,23 +36,27 @@ Components.utils.import("resource://gre/
 Components.utils.import("resource://gre/modules/NetUtil.jsm");
 Components.utils.import("resource://gre/modules/Promise.jsm");
 Components.utils.import("resource://gre/modules/Task.jsm");
 const { OS } = Components.utils.import("resource://gre/modules/osfile.jsm", {});
 Components.utils.import("resource://gre/modules/AsyncShutdown.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                   "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestUtils",
+                                  "resource://testing-common/ExtensionXPCShellUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "HttpServer",
                                   "resource://testing-common/httpd.js");
 XPCOMUtils.defineLazyModuleGetter(this, "MockRegistrar",
                                   "resource://testing-common/MockRegistrar.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MockRegistry",
                                   "resource://testing-common/MockRegistry.jsm");
 
+// WebExtension wrapper for ease of testing
+ExtensionTestUtils.init(this);
 
 // We need some internal bits of AddonManager
 var AMscope = Components.utils.import("resource://gre/modules/AddonManager.jsm", {});
 var { AddonManager, AddonManagerInternal, AddonManagerPrivate } = AMscope;
 
 // Mock out AddonManager's reference to the AsyncShutdown module so we can shut
 // down AddonManager from the test
 var MockAsyncShutdown = {
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
@@ -0,0 +1,334 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that delaying an update works for WebExtensions.
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const tempdir = gTmpD.clone();
+
+const IGNORE_ID = "test_delay_update_ignore_webext@tests.mozilla.org";
+const COMPLETE_ID = "test_delay_update_complete_webext@tests.mozilla.org";
+const DEFER_ID = "test_delay_update_defer_webext@tests.mozilla.org";
+const NOUPDATE_ID = "test_no_update_webext@tests.mozilla.org";
+
+// Create and configure the HTTP server.
+let testserver = createHttpServer();
+gPort = testserver.identity.primaryPort;
+mapFile("/data/test_delay_updates_complete.json", testserver);
+mapFile("/data/test_delay_updates_ignore.json", testserver);
+mapFile("/data/test_delay_updates_defer.json", testserver);
+mapFile("/data/test_no_update.json", testserver);
+testserver.registerDirectory("/addons/", do_get_file("addons"));
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const { Management } = Components.utils.import("resource://gre/modules/Extension.jsm", {});
+
+function promiseWebExtensionStartup() {
+  return new Promise(resolve => {
+    let listener = (extension) => {
+      Management.off("startup", listener);
+      resolve(extension);
+    };
+
+    Management.on("startup", listener);
+  });
+}
+
+// add-on registers upgrade listener, and ignores update.
+add_task(function* delay_updates_ignore() {
+  startupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": IGNORE_ID,
+          "update_url": `http://localhost:${gPort}/data/test_delay_updates_ignore.json`,
+        },
+      },
+    },
+    background() {
+      browser.runtime.onUpdateAvailable.addListener(details => {
+        if (details) {
+          if (details.version) {
+            // This should be the version of the pending update.
+            browser.test.assertEq("2.0", details.version, "correct version");
+            browser.test.notifyPass("delay");
+          }
+        } else {
+          browser.test.fail("no details object passed");
+        }
+      });
+      browser.test.sendMessage("ready");
+    },
+  }, IGNORE_ID);
+
+  yield Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+  let addon = yield promiseAddonByID(IGNORE_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Generated extension");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  let update = yield promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+
+  yield promiseCompleteAllInstalls([install]);
+
+  do_check_eq(install.state, AddonManager.STATE_POSTPONED);
+
+  // addon upgrade has been delayed
+  let addon_postponed = yield promiseAddonByID(IGNORE_ID);
+  do_check_neq(addon_postponed, null);
+  do_check_eq(addon_postponed.version, "1.0");
+  do_check_eq(addon_postponed.name, "Generated extension");
+  do_check_true(addon_postponed.isCompatible);
+  do_check_false(addon_postponed.appDisabled);
+  do_check_true(addon_postponed.isActive);
+  do_check_eq(addon_postponed.type, "extension");
+
+  yield extension.awaitFinish("delay");
+
+  // restarting allows upgrade to proceed
+  yield extension.markUnloaded();
+  yield promiseRestartManager();
+
+  let addon_upgraded = yield promiseAddonByID(IGNORE_ID);
+  yield promiseWebExtensionStartup();
+
+  do_check_neq(addon_upgraded, null);
+  do_check_eq(addon_upgraded.version, "2.0");
+  do_check_eq(addon_upgraded.name, "Delay Upgrade");
+  do_check_true(addon_upgraded.isCompatible);
+  do_check_false(addon_upgraded.appDisabled);
+  do_check_true(addon_upgraded.isActive);
+  do_check_eq(addon_upgraded.type, "extension");
+
+  yield addon_upgraded.uninstall();
+  yield promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, and allows update.
+add_task(function* delay_updates_complete() {
+  startupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": COMPLETE_ID,
+          "update_url": `http://localhost:${gPort}/data/test_delay_updates_complete.json`,
+        },
+      },
+    },
+    background() {
+      browser.runtime.onUpdateAvailable.addListener(details => {
+        browser.test.notifyPass("reload");
+        browser.runtime.reload();
+      });
+      browser.test.sendMessage("ready");
+    },
+  }, COMPLETE_ID);
+
+  yield Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+  let addon = yield promiseAddonByID(COMPLETE_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Generated extension");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  let update = yield promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+
+  let promiseInstalled = promiseAddonEvent("onInstalled");
+  yield promiseCompleteAllInstalls([install]);
+
+  yield extension.awaitFinish("reload");
+
+  // addon upgrade has been allowed
+  let [addon_allowed] = yield promiseInstalled;
+  yield promiseWebExtensionStartup();
+
+  do_check_neq(addon_allowed, null);
+  do_check_eq(addon_allowed.version, "2.0");
+  do_check_eq(addon_allowed.name, "Delay Upgrade");
+  do_check_true(addon_allowed.isCompatible);
+  do_check_false(addon_allowed.appDisabled);
+  do_check_true(addon_allowed.isActive);
+  do_check_eq(addon_allowed.type, "extension");
+
+  yield extension.markUnloaded();
+  yield addon_allowed.uninstall();
+  yield promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, initially defers update then allows upgrade
+add_task(function* delay_updates_defer() {
+  startupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": DEFER_ID,
+          "update_url": `http://localhost:${gPort}/data/test_delay_updates_defer.json`,
+        },
+      },
+    },
+    background() {
+      browser.runtime.onUpdateAvailable.addListener(details => {
+        // Upgrade will only proceed when "allow" message received.
+        browser.test.onMessage.addListener(msg => {
+          if (msg == "allow") {
+            browser.test.notifyPass("allowed");
+            browser.runtime.reload();
+          } else {
+            browser.test.fail(`wrong message: ${msg}`);
+          }
+        });
+      });
+      browser.test.sendMessage("ready");
+    },
+  }, DEFER_ID);
+
+  yield Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+  let addon = yield promiseAddonByID(DEFER_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Generated extension");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  let update = yield promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+
+  let promiseInstalled = promiseAddonEvent("onInstalled");
+  yield promiseCompleteAllInstalls([install]);
+
+  do_check_eq(install.state, AddonManager.STATE_POSTPONED);
+
+  // upgrade is initially postponed
+  let addon_postponed = yield promiseAddonByID(DEFER_ID);
+  do_check_neq(addon_postponed, null);
+  do_check_eq(addon_postponed.version, "1.0");
+  do_check_eq(addon_postponed.name, "Generated extension");
+  do_check_true(addon_postponed.isCompatible);
+  do_check_false(addon_postponed.appDisabled);
+  do_check_true(addon_postponed.isActive);
+  do_check_eq(addon_postponed.type, "extension");
+
+  // add-on will not allow upgrade until message is received
+  extension.sendMessage("allow");
+  yield extension.awaitFinish("allowed");
+
+  // addon upgrade has been allowed
+  let [addon_allowed] = yield promiseInstalled;
+  yield promiseWebExtensionStartup();
+
+  do_check_neq(addon_allowed, null);
+  do_check_eq(addon_allowed.version, "2.0");
+  do_check_eq(addon_allowed.name, "Delay Upgrade");
+  do_check_true(addon_allowed.isCompatible);
+  do_check_false(addon_allowed.appDisabled);
+  do_check_true(addon_allowed.isActive);
+  do_check_eq(addon_allowed.type, "extension");
+
+  yield extension.markUnloaded();
+  yield promiseRestartManager();
+
+  // restart changes nothing
+  addon_allowed = yield promiseAddonByID(DEFER_ID);
+  yield promiseWebExtensionStartup();
+
+  do_check_neq(addon_allowed, null);
+  do_check_eq(addon_allowed.version, "2.0");
+  do_check_eq(addon_allowed.name, "Delay Upgrade");
+  do_check_true(addon_allowed.isCompatible);
+  do_check_false(addon_allowed.appDisabled);
+  do_check_true(addon_allowed.isActive);
+  do_check_eq(addon_allowed.type, "extension");
+
+  yield addon_allowed.uninstall();
+  yield promiseShutdownManager();
+});
+
+// browser.runtime.reload() without a pending upgrade should just reload.
+add_task(function* runtime_reload() {
+  startupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": NOUPDATE_ID,
+          "update_url": `http://localhost:${gPort}/data/test_no_update.json`,
+        },
+      },
+    },
+    background() {
+      browser.test.onMessage.addListener(msg => {
+        if (msg == "reload") {
+          browser.runtime.reload();
+        } else {
+          browser.test.fail(`wrong message: ${msg}`);
+        }
+      });
+      browser.test.sendMessage("ready");
+    },
+  }, NOUPDATE_ID);
+
+  yield Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+  let addon = yield promiseAddonByID(NOUPDATE_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Generated extension");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  yield promiseFindAddonUpdates(addon);
+
+  extension.sendMessage("reload");
+  // Wait for extension to restart, to make sure reload works.
+  yield promiseWebExtensionStartup();
+
+  addon = yield promiseAddonByID(NOUPDATE_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Generated extension");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  yield extension.markUnloaded();
+  yield addon.uninstall();
+  yield promiseShutdownManager();
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -33,11 +33,12 @@ skip-if = appname != "firefox"
 [test_XPIcancel.js]
 [test_XPIStates.js]
 [test_temporary.js]
 [test_proxies.js]
 [test_proxy.js]
 [test_pass_symbol.js]
 [test_delay_update.js]
 [test_nodisable_hidden.js]
+[test_delay_update_webextension.js]
 
 
 [include:xpcshell-shared.ini]