Bug 1252871 - Add support for runtime.onInstalled r?aswan draft
authorMatthew Wein <mwein@mozilla.com>
Wed, 19 Oct 2016 10:38:29 +0100
changeset 431288 2cab1d210c941bc41a5116dca94990071dcbcefd
parent 430955 91d415f9703be44fc44664e2c6d02a0bae7bc2d9
child 535386 e5a1597f1bb294c9e779e24d2726e3f1f02c35bc
push id34025
push usermwein@mozilla.com
push dateFri, 28 Oct 2016 23:49:04 +0000
reviewersaswan
bugs1252871
milestone52.0a1
Bug 1252871 - Add support for runtime.onInstalled r?aswan MozReview-Commit-ID: 3wDtv0g0BpO
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionXPCShellUtils.jsm
toolkit/components/extensions/ext-backgroundPage.js
toolkit/components/extensions/ext-runtime.js
toolkit/components/extensions/schemas/runtime.json
toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
toolkit/components/extensions/test/xpcshell/head.js
toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1195,31 +1195,35 @@ class MockExtension {
   }
 
   cleanupGeneratedFile() {
     flushJarCache(this.file);
     return OS.File.remove(this.file.path);
   }
 }
 
+let _browserUpdated = false;
+
 // We create one instance of this class per extension. |addonData|
 // comes directly from bootstrap.js when initializing.
 this.Extension = class extends ExtensionData {
-  constructor(addonData) {
+  constructor(addonData, startupReason) {
     super(addonData.resourceURI);
 
     this.uuid = UUIDMap.get(addonData.id);
 
     if (addonData.cleanupFile) {
       Services.obs.addObserver(this, "xpcom-shutdown", false);
       this.cleanupFile = addonData.cleanupFile || null;
       delete addonData.cleanupFile;
     }
 
     this.addonData = addonData;
+    this.startupReason = startupReason;
+
     this.id = addonData.id;
     this.baseURI = NetUtil.newURI(this.getURL("")).QueryInterface(Ci.nsIURL);
     this.principal = this.createPrincipal();
 
     this.onStartup = null;
 
     this.hasShutdown = false;
     this.onShutdown = new Set();
@@ -1228,16 +1232,24 @@ this.Extension = class extends Extension
 
     this.apis = [];
     this.whiteListedHosts = null;
     this.webAccessibleResources = null;
 
     this.emitter = new EventEmitter();
   }
 
+  static set browserUpdated(updated) {
+    _browserUpdated = updated;
+  }
+
+  static get browserUpdated() {
+    return _browserUpdated;
+  }
+
   /**
    * This code is designed to make it easy to test a WebExtension
    * without creating a bunch of files. Everything is contained in a
    * single JSON blob.
    *
    * Properties:
    *   "background": "<JS code>"
    *     A script to be loaded as the background script.
--- a/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
+++ b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
@@ -15,16 +15,21 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
+XPCOMUtils.defineLazyGetter(this, "Management", () => {
+  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
+  return Management;
+});
+
 /* exported ExtensionTestUtils */
 
 let BASE_MANIFEST = Object.freeze({
   "applications": Object.freeze({
     "gecko": Object.freeze({
       "id": "test@web.ext",
     }),
   }),
@@ -45,65 +50,69 @@ class ExtensionWrapper {
     this.testResolve = null;
     this.testDone = new Promise(resolve => { this.testResolve = resolve; });
 
     this.messageHandler = new Map();
     this.messageAwaiter = new Map();
 
     this.messageQueue = new Set();
 
+    this.attachListeners();
+
     this.testScope.do_register_cleanup(() => {
       if (this.messageQueue.size) {
         let names = Array.from(this.messageQueue, ([msg]) => msg);
         this.testScope.equal(JSON.stringify(names), "[]", "message queue is empty");
       }
       if (this.messageAwaiter.size) {
         let names = Array.from(this.messageAwaiter.keys());
         this.testScope.equal(JSON.stringify(names), "[]", "no tasks awaiting on messages");
       }
     });
 
-    /* eslint-disable mozilla/balanced-listeners */
-    extension.on("test-eq", (kind, pass, msg, expected, actual) => {
-      this.testScope.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`);
-    });
-    extension.on("test-log", (kind, pass, msg) => {
-      this.testScope.do_print(msg);
-    });
-    extension.on("test-result", (kind, pass, msg) => {
-      this.testScope.ok(pass, msg);
-    });
-    extension.on("test-done", (kind, pass, msg, expected, actual) => {
-      this.testScope.ok(pass, msg);
-      this.testResolve(msg);
-    });
-
-    extension.on("test-message", (kind, msg, ...args) => {
-      let handler = this.messageHandler.get(msg);
-      if (handler) {
-        handler(...args);
-      } else {
-        this.messageQueue.add([msg, ...args]);
-        this.checkMessages();
-      }
-    });
-    /* eslint-enable mozilla/balanced-listeners */
-
     this.testScope.do_register_cleanup(() => {
       if (this.state == "pending" || this.state == "running") {
         this.testScope.equal(this.state, "unloaded", "Extension left running at test shutdown");
         return this.unload();
       } else if (extension.state == "unloading") {
         this.testScope.equal(this.state, "unloaded", "Extension not fully unloaded at test shutdown");
       }
     });
 
     this.testScope.do_print(`Extension loaded`);
   }
 
+  attachListeners() {
+    /* eslint-disable mozilla/balanced-listeners */
+    this.extension.on("test-eq", (kind, pass, msg, expected, actual) => {
+      this.testScope.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`);
+    });
+    this.extension.on("test-log", (kind, pass, msg) => {
+      this.testScope.do_print(msg);
+    });
+    this.extension.on("test-result", (kind, pass, msg) => {
+      this.testScope.ok(pass, msg);
+    });
+    this.extension.on("test-done", (kind, pass, msg, expected, actual) => {
+      this.testScope.ok(pass, msg);
+      this.testResolve(msg);
+    });
+
+    this.extension.on("test-message", (kind, msg, ...args) => {
+      let handler = this.messageHandler.get(msg);
+      if (handler) {
+        handler(...args);
+      } else {
+        this.messageQueue.add([msg, ...args]);
+        this.checkMessages();
+      }
+    });
+    /* eslint-enable mozilla/balanced-listeners */
+  }
+
   startup() {
     if (this.state != "uninitialized") {
       throw new Error("Extension already started");
     }
     this.state = "pending";
 
     return this.extension.startup().then(
       result => {
@@ -195,18 +204,16 @@ class ExtensionWrapper {
     this.messageHandler.set(msg, callback);
   }
 }
 
 var ExtensionTestUtils = {
   BASE_MANIFEST,
 
   normalizeManifest: Task.async(function* (manifest, baseManifest = BASE_MANIFEST) {
-    const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
-
     yield Management.lazyInit();
 
     let errors = [];
     let context = {
       url: null,
 
       logError: error => {
         errors.push(error);
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -96,21 +96,17 @@ BackgroundPage.prototype = {
 
     // Set the add-on's main debugger global, for use in the debugger
     // console.
     if (this.extension.addonData.instanceID) {
       AddonManager.getAddonByInstanceID(this.extension.addonData.instanceID)
                   .then(addon => addon.setDebugGlobal(window));
     }
 
-    // TODO(robwu): This implementation of onStartup is wrong, see
-    // https://bugzil.la/1247435#c1
-    if (this.extension.onStartup) {
-      this.extension.onStartup();
-    }
+    this.extension.emit("startup");
   }),
 
   shutdown() {
     if (this.extension.addonData.instanceID) {
       AddonManager.getAddonByInstanceID(this.extension.addonData.instanceID)
                   .then(addon => addon.setDebugGlobal(null));
     }
 
--- a/toolkit/components/extensions/ext-runtime.js
+++ b/toolkit/components/extensions/ext-runtime.js
@@ -3,41 +3,57 @@
 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, "Extension",
+                                  "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
                                   "resource://gre/modules/ExtensionManagement.jsm");
 
 var {
-  EventManager,
+  ignoreEvent,
   SingletonEventManager,
-  ignoreEvent,
 } = ExtensionUtils;
 
 XPCOMUtils.defineLazyModuleGetter(this, "NativeApp",
                                   "resource://gre/modules/NativeMessaging.jsm");
 
 extensions.registerSchemaAPI("runtime", "addon_parent", context => {
   let {extension} = context;
   return {
     runtime: {
-      onStartup: new EventManager(context, "runtime.onStartup", fire => {
-        extension.onStartup = fire;
+      onStartup: ignoreEvent(context, "runtime.onStartup"),
+
+      onInstalled: new SingletonEventManager(context, "runtime.onInstalled", fire => {
+        let listener = () => {
+          switch (extension.startupReason) {
+            case "APP_STARTUP":
+              if (Extension.browserUpdated) {
+                fire({reason: "browser_update"});
+              }
+              break;
+            case "ADDON_INSTALL":
+              fire({reason: "install"});
+              break;
+            case "ADDON_UPGRADE":
+              fire({reason: "update"});
+              break;
+          }
+        };
+        extension.on("startup", listener);
         return () => {
-          extension.onStartup = null;
+          extension.off("startup", listener);
         };
       }).api(),
 
-      onInstalled: ignoreEvent(context, "runtime.onInstalled"),
-
       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);
--- a/toolkit/components/extensions/schemas/runtime.json
+++ b/toolkit/components/extensions/schemas/runtime.json
@@ -117,17 +117,17 @@
         "type": "string",
         "enum": ["throttled", "no_update", "update_available"],
         "allowedContexts": ["content"],
         "description": "Result of the update check."
       },
       {
         "id": "OnInstalledReason",
         "type": "string",
-        "enum": ["install", "update", "chrome_update", "shared_module_update"],
+        "enum": ["install", "update", "browser_update"],
         "allowedContexts": ["content"],
         "description": "The reason that this event is being dispatched."
       },
       {
         "id": "OnRestartRequiredReason",
         "type": "string",
         "allowedContexts": ["content"],
         "description": "The reason that the event is being dispatched. 'app_update' is used when the restart is needed because the application is updated to a newer version. 'os_update' is used when the restart is needed because the browser/OS is updated to a newer version. 'periodic' is used when the system runs for more than the permitted uptime set in the enterprise policy.",
@@ -454,36 +454,37 @@
       {
         "name": "onStartup",
         "unsupported": true,
         "type": "function",
         "description": "Fired when a profile that has this extension installed first starts up. This event is not fired when an incognito profile is started, even if this extension is operating in 'split' incognito mode."
       },
       {
         "name": "onInstalled",
-        "unsupported": true,
         "type": "function",
         "description": "Fired when the extension is first installed, when the extension is updated to a new version, and when the browser is updated to a new version.",
         "parameters": [
           {
             "type": "object",
             "name": "details",
             "properties": {
               "reason": {
                 "$ref": "OnInstalledReason",
                 "description": "The reason that this event is being dispatched."
               },
               "previousVersion": {
                 "type": "string",
                 "optional": true,
+                "unsupported": true,
                 "description": "Indicates the previous version of the extension, which has just been updated. This is present only if 'reason' is 'update'."
               },
               "id": {
                 "type": "string",
                 "optional": true,
+                "unsupported": true,
                 "description": "Indicates the ID of the imported shared module extension which updated. This is present only if 'reason' is 'shared_module_update'."
               }
             }
           }
         ]
       },
       {
         "name": "onSuspend",
--- a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
+++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
@@ -68,16 +68,17 @@ let expectedBackgroundApis = [
   "management.ExtensionDisabledReason",
   "management.ExtensionInstallType",
   "management.ExtensionType",
   "management.getSelf",
   "management.uninstallSelf",
   "runtime.getBackgroundPage",
   "runtime.getBrowserInfo",
   "runtime.getPlatformInfo",
+  "runtime.onInstalled",
   "runtime.onUpdateAvailable",
   "runtime.openOptionsPage",
   "runtime.reload",
   "runtime.setUninstallURL",
 ];
 
 function sendAllApis() {
   function isEvent(key, val) {
--- a/toolkit/components/extensions/test/xpcshell/head.js
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -2,16 +2,17 @@
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 /* exported createHttpServer, promiseConsoleOutput, cleanupDir */
 
 Components.utils.import("resource://gre/modules/Task.jsm");
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 Components.utils.import("resource://gre/modules/Timer.jsm");
+Components.utils.import("resource://testing-common/AddonTestUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled.js
@@ -0,0 +1,324 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyGetter(this, "Management", () => {
+  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
+  return Management;
+});
+
+const {
+  createAppInfo,
+  createTempWebExtensionFile,
+  promiseAddonByID,
+  promiseAddonEvent,
+  promiseCompleteAllInstalls,
+  promiseFindAddonUpdates,
+  promiseRestartManager,
+  promiseShutdownManager,
+  promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+function awaitEvent(eventName) {
+  return new Promise(resolve => {
+    let listener = (_eventName, ...args) => {
+      if (_eventName === eventName) {
+        Management.off(eventName, listener);
+        resolve(...args);
+      }
+    };
+
+    Management.on(eventName, listener);
+  });
+}
+
+add_task(function* test_should_fire_on_addon_update() {
+  const EXTENSION_ID = "test_runtime_on_installed_addon_update@tests.mozilla.org";
+
+  const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+
+  // The test extension uses an insecure update url.
+  Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+  const testServer = createHttpServer();
+  const port = testServer.identity.primaryPort;
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": EXTENSION_ID,
+          "update_url": `http://localhost:${port}/test_update.json`,
+        },
+      },
+    },
+    background() {
+      browser.runtime.onUpdateAvailable.addListener(details => {
+        browser.test.sendMessage("reloading");
+        browser.runtime.reload();
+      });
+
+      browser.runtime.onInstalled.addListener(details => {
+        browser.test.sendMessage("installed", details);
+      });
+    },
+  });
+
+  testServer.registerPathHandler("/test_update.json", (request, response) => {
+    response.write(`{
+      "addons": {
+        "${EXTENSION_ID}": {
+          "updates": [
+            {
+              "version": "2.0",
+              "update_link": "http://localhost:${port}/addons/test_runtime_on_installed-2.0.xpi"
+            }
+          ]
+        }
+      }
+    }`);
+  });
+
+  let webExtensionFile = createTempWebExtensionFile({
+    manifest: {
+      version: "2.0",
+      applications: {
+        gecko: {
+          id: EXTENSION_ID,
+        },
+      },
+    },
+    background() {
+      browser.runtime.onInstalled.addListener(details => {
+        browser.test.sendMessage("installed", details);
+      });
+    },
+  });
+
+  testServer.registerFile("/addons/test_runtime_on_installed-2.0.xpi", webExtensionFile);
+
+  yield promiseStartupManager();
+
+  yield extension.startup();
+  let details = yield extension.awaitMessage("installed");
+  equal(details.reason, "install", "runtime.onInstalled fired with the correct reason");
+
+  let addon = yield promiseAddonByID(EXTENSION_ID);
+  equal(addon.version, "1.0", "The installed addon has the correct version");
+
+  let update = yield promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+
+  let promiseInstalled = promiseAddonEvent("onInstalled");
+  yield promiseCompleteAllInstalls([install]);
+
+  yield extension.awaitMessage("reloading");
+
+  let startupPromise = awaitEvent("ready");
+
+  let [updated_addon] = yield promiseInstalled;
+  equal(updated_addon.version, "2.0", "The updated addon has the correct version");
+
+  extension.extension = yield startupPromise;
+  extension.attachListeners();
+
+  details = yield extension.awaitMessage("installed");
+  equal(details.reason, "update", "runtime.onInstalled fired with the correct reason");
+
+  yield extension.unload();
+
+  yield updated_addon.uninstall();
+  yield promiseShutdownManager();
+});
+
+add_task(function* test_should_fire_on_browser_update() {
+  const EXTENSION_ID = "test_runtime_on_installed_browser_update@tests.mozilla.org";
+
+  yield promiseStartupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": EXTENSION_ID,
+        },
+      },
+    },
+    background() {
+      let onInstalledDetails = null;
+
+      browser.runtime.onInstalled.addListener(details => {
+        onInstalledDetails = details;
+      });
+
+      browser.test.onMessage.addListener(message => {
+        if (message == "get-on-installed-details") {
+          browser.test.sendMessage("on-installed-details", onInstalledDetails);
+        }
+      });
+    },
+  });
+
+  yield extension.startup();
+
+  extension.sendMessage("get-on-installed-details");
+  let details = yield extension.awaitMessage("on-installed-details");
+  equal(details.reason, "install", "runtime.onInstalled fired with the correct reason");
+
+  let startupPromise = awaitEvent("ready");
+  yield promiseRestartManager("1");
+  extension.extension = yield startupPromise;
+  extension.attachListeners();
+
+  extension.sendMessage("get-on-installed-details");
+  details = yield extension.awaitMessage("on-installed-details");
+  equal(details, null, "runtime.onInstalled should not have fired");
+
+  // Update the browser.
+  startupPromise = awaitEvent("ready");
+  yield promiseRestartManager("2");
+  extension.extension = yield startupPromise;
+  extension.attachListeners();
+
+  extension.sendMessage("get-on-installed-details");
+  details = yield extension.awaitMessage("on-installed-details");
+  equal(details.reason, "browser_update", "runtime.onInstalled fired with the correct reason");
+
+  // Restart the browser.
+  startupPromise = awaitEvent("ready");
+  yield promiseRestartManager("2");
+  extension.extension = yield startupPromise;
+  extension.attachListeners();
+
+  extension.sendMessage("get-on-installed-details");
+  details = yield extension.awaitMessage("on-installed-details");
+  equal(details, null, "runtime.onInstalled should not have fired");
+
+  // Update the browser again.
+  startupPromise = awaitEvent("ready");
+  yield promiseRestartManager("3");
+  extension.extension = yield startupPromise;
+  extension.attachListeners();
+
+  extension.sendMessage("get-on-installed-details");
+  details = yield extension.awaitMessage("on-installed-details");
+  equal(details.reason, "browser_update", "runtime.onInstalled fired with the correct reason");
+
+  yield extension.unload();
+
+  yield promiseShutdownManager();
+});
+
+add_task(function* test_should_not_fire_on_reload() {
+  const EXTENSION_ID = "test_runtime_on_installed_reload@tests.mozilla.org";
+
+  yield promiseStartupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": EXTENSION_ID,
+        },
+      },
+    },
+    background() {
+      let onInstalledDetails = null;
+
+      browser.runtime.onInstalled.addListener(details => {
+        onInstalledDetails = details;
+      });
+
+      browser.test.onMessage.addListener(message => {
+        if (message == "reload-extension") {
+          browser.runtime.reload();
+        } else if (message == "get-on-installed-details") {
+          browser.test.sendMessage("on-installed-details", onInstalledDetails);
+        }
+      });
+    },
+  });
+
+  yield extension.startup();
+
+  extension.sendMessage("get-on-installed-details");
+  let details = yield extension.awaitMessage("on-installed-details");
+  equal(details.reason, "install", "runtime.onInstalled fired with the correct reason");
+
+  let startupPromise = awaitEvent("ready");
+  extension.sendMessage("reload-extension");
+  extension.extension = yield startupPromise;
+  extension.attachListeners();
+
+  extension.sendMessage("get-on-installed-details");
+  details = yield extension.awaitMessage("on-installed-details");
+  equal(details, null, "runtime.onInstalled should not have fired");
+
+  yield extension.unload();
+  yield promiseShutdownManager();
+});
+
+add_task(function* test_should_not_fire_on_restart() {
+  const EXTENSION_ID = "test_runtime_on_installed_restart@tests.mozilla.org";
+
+  yield promiseStartupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": EXTENSION_ID,
+        },
+      },
+    },
+    background() {
+      let onInstalledDetails = null;
+
+      browser.runtime.onInstalled.addListener(details => {
+        onInstalledDetails = details;
+      });
+
+      browser.test.onMessage.addListener(message => {
+        if (message == "get-on-installed-details") {
+          browser.test.sendMessage("on-installed-details", onInstalledDetails);
+        }
+      });
+    },
+  });
+
+  yield extension.startup();
+
+  extension.sendMessage("get-on-installed-details");
+  let details = yield extension.awaitMessage("on-installed-details");
+  equal(details.reason, "install", "runtime.onInstalled fired with the correct reason");
+
+  let addon = yield promiseAddonByID(EXTENSION_ID);
+  addon.userDisabled = true;
+
+  let startupPromise = awaitEvent("ready");
+  addon.userDisabled = false;
+  extension.extension = yield startupPromise;
+  extension.attachListeners();
+
+  extension.sendMessage("get-on-installed-details");
+  details = yield extension.awaitMessage("on-installed-details");
+  equal(details, null, "runtime.onInstalled should not have fired");
+
+  yield extension.markUnloaded();
+  yield promiseShutdownManager();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -41,16 +41,17 @@ skip-if = release_or_beta
 [test_ext_management_uninstall_self.js]
 [test_ext_manifest_content_security_policy.js]
 [test_ext_manifest_incognito.js]
 [test_ext_manifest_minimum_chrome_version.js]
 [test_ext_onmessage_removelistener.js]
 [test_ext_runtime_connect_no_receiver.js]
 [test_ext_runtime_getBrowserInfo.js]
 [test_ext_runtime_getPlatformInfo.js]
+[test_ext_runtime_onInstalled.js]
 [test_ext_runtime_sendMessage.js]
 [test_ext_runtime_sendMessage_errors.js]
 [test_ext_runtime_sendMessage_no_receiver.js]
 [test_ext_runtime_sendMessage_self.js]
 [test_ext_schemas.js]
 [test_ext_schemas_api_injection.js]
 [test_ext_schemas_async.js]
 [test_ext_schemas_allowed_contexts.js]
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -80,16 +80,18 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/AsyncShutdown.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                   "resource://gre/modules/Promise.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository",
                                   "resource://gre/modules/addons/AddonRepository.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Extension",
+                                  "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "CertUtils", function() {
   let certUtils = {};
   Components.utils.import("resource://gre/modules/CertUtils.jsm", certUtils);
   return certUtils;
 });
@@ -845,16 +847,18 @@ var AddonManagerInternal = {
 
       let oldAppVersion = null;
       try {
         oldAppVersion = Services.prefs.getCharPref(PREF_EM_LAST_APP_VERSION);
         appChanged = Services.appinfo.version != oldAppVersion;
       }
       catch (e) { }
 
+      Extension.browserUpdated = appChanged;
+
       let oldPlatformVersion = null;
       try {
         oldPlatformVersion = Services.prefs.getCharPref(PREF_EM_LAST_PLATFORM_VERSION);
       }
       catch (e) { }
 
       if (appChanged !== false) {
         logger.debug("Application has been upgraded");
--- a/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
@@ -1086,16 +1086,20 @@ var AddonTestUtils = {
   promiseAddonByID(id) {
     return new Promise(resolve => AddonManager.getAddonByID(id, resolve));
   },
 
   /**
    * Returns a promise that will be resolved when an add-on update check is
    * complete. The value resolved will be an AddonInstall if a new version was
    * found.
+   *
+   * @param {object} addon The add-on to find updates for.
+   * @param {integer} reason The type of update to find.
+   * @return {Promise<object>} an object containing information about the update.
    */
   promiseFindAddonUpdates(addon, reason = AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) {
     let equal = this.testScope.equal;
     return new Promise((resolve, reject) => {
       let result = {};
       addon.findUpdates({
         onNoCompatibilityUpdateAvailable: function(addon2) {
           if ("compatibilityUpdate" in result) {
--- a/toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
+++ b/toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
@@ -3,23 +3,34 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 Components.utils.import("resource://gre/modules/Extension.jsm");
 
 var extension;
 
+const BOOTSTRAP_REASON_TO_STRING_MAP = {
+  1: "APP_STARTUP",
+  2: "APP_SHUTDOWN",
+  3: "ADDON_ENABLE",
+  4: "ADDON_DISABLE",
+  5: "ADDON_INSTALL",
+  6: "ADDON_UNINSTALL",
+  7: "ADDON_UPGRADE",
+  8: "ADDON_DOWNGRADE",
+}
+
 function install(data, reason)
 {
 }
 
 function startup(data, reason)
 {
-  extension = new Extension(data);
+  extension = new Extension(data, BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
   extension.startup();
 }
 
 function shutdown(data, reason)
 {
   extension.shutdown();
 }