Bug 1510097 - Move bootstrapped extension and install.rdf handling to comm-central. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Fri, 30 Nov 2018 11:16:51 +1300
changeset 33823 9a6732b236dae53ed8b7cf4da8851755c7498ba8
parent 33822 95ff25a433f1ccd0edad426c5b966459c0f9f085
child 33824 23e639499671242b915d89b4cc4144410365e699
push id388
push userclokep@gmail.com
push dateMon, 28 Jan 2019 20:54:56 +0000
reviewersmkmelin
bugs1510097
Bug 1510097 - Move bootstrapped extension and install.rdf handling to comm-central. r=mkmelin
common/moz.build
common/src/BootstrapLoader.jsm
common/src/RDFManifestConverter.jsm
common/src/moz.build
common/test/xpcshell/.eslintrc.js
common/test/xpcshell/data/BootstrapMonitor.jsm
common/test/xpcshell/head_addons.js
common/test/xpcshell/test_bootstrap.js
common/test/xpcshell/test_bootstrap_const.js
common/test/xpcshell/test_bootstrap_globals.js
common/test/xpcshell/test_bootstrapped_chrome_manifest.js
common/test/xpcshell/test_invalid_install_rdf.js
common/test/xpcshell/test_manifest.js
common/test/xpcshell/test_manifest_locales.js
common/test/xpcshell/xpcshell.ini
mail/components/mailGlue.js
--- a/common/moz.build
+++ b/common/moz.build
@@ -4,8 +4,12 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += [
     'public',
     'src'
 ]
 
 JAR_MANIFESTS += ['jar.mn']
+
+XPCSHELL_TESTS_MANIFESTS += [
+    'test/xpcshell/xpcshell.ini',
+]
new file mode 100644
--- /dev/null
+++ b/common/src/BootstrapLoader.jsm
@@ -0,0 +1,366 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["BootstrapLoader"];
+
+ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AddonInternal: "resource://gre/modules/addons/XPIDatabase.jsm",
+  Blocklist: "resource://gre/modules/Blocklist.jsm",
+  ConsoleAPI: "resource://gre/modules/Console.jsm",
+  InstallRDF: "resource:///modules/RDFManifestConverter.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "BOOTSTRAP_REASONS", () => {
+  const {XPIProvider} = ChromeUtils.import("resource://gre/modules/addons/XPIProvider.jsm", {});
+  return XPIProvider.BOOTSTRAP_REASONS;
+});
+
+ChromeUtils.import("resource://gre/modules/Log.jsm");
+var logger = Log.repository.getLogger("addons.bootstrap");
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest = /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+// Properties that exist in the install manifest
+const PROP_METADATA      = ["id", "version", "type", "internalName", "updateURL",
+                            "optionsURL", "optionsType", "aboutURL", "iconURL"];
+const PROP_LOCALE_SINGLE = ["name", "description", "creator", "homepageURL"];
+const PROP_LOCALE_MULTI  = ["developers", "translators", "contributors"];
+
+// Map new string type identifiers to old style nsIUpdateItem types.
+// Retired values:
+// 32 = multipackage xpi file
+// 8 = locale
+// 256 = apiextension
+// 128 = experiment
+// theme = 4
+const TYPES = {
+  extension: 2,
+  dictionary: 64,
+};
+
+const COMPATIBLE_BY_DEFAULT_TYPES = {
+  extension: true,
+  dictionary: true,
+};
+
+const hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty);
+
+function isXPI(filename) {
+  let ext = filename.slice(-4).toLowerCase();
+  return ext === ".xpi" || ext === ".zip";
+}
+
+/**
+ * Gets an nsIURI for a file within another file, either a directory or an XPI
+ * file. If aFile is a directory then this will return a file: URI, if it is an
+ * XPI file then it will return a jar: URI.
+ *
+ * @param {nsIFile} aFile
+ *        The file containing the resources, must be either a directory or an
+ *        XPI file
+ * @param {string} aPath
+ *        The path to find the resource at, "/" separated. If aPath is empty
+ *        then the uri to the root of the contained files will be returned
+ * @returns {nsIURI}
+ *        An nsIURI pointing at the resource
+ */
+function getURIForResourceInFile(aFile, aPath) {
+  if (!isXPI(aFile.leafName)) {
+    let resource = aFile.clone();
+    if (aPath)
+      aPath.split("/").forEach(part => resource.append(part));
+
+    return Services.io.newFileURI(resource);
+  }
+
+  return buildJarURI(aFile, aPath);
+}
+
+/**
+ * Creates a jar: URI for a file inside a ZIP file.
+ *
+ * @param {nsIFile} aJarfile
+ *        The ZIP file as an nsIFile
+ * @param {string} aPath
+ *        The path inside the ZIP file
+ * @returns {nsIURI}
+ *        An nsIURI for the file
+ */
+function buildJarURI(aJarfile, aPath) {
+  let uri = Services.io.newFileURI(aJarfile);
+  uri = "jar:" + uri.spec + "!/" + aPath;
+  return Services.io.newURI(uri);
+}
+
+var BootstrapLoader = {
+  name: "bootstrap",
+  manifestFile: "install.rdf",
+  async loadManifest(pkg) {
+    /**
+     * Reads locale properties from either the main install manifest root or
+     * an em:localized section in the install manifest.
+     *
+     * @param {Object} aSource
+     *        The resource to read the properties from.
+     * @param {boolean} isDefault
+     *        True if the locale is to be read from the main install manifest
+     *        root
+     * @param {string[]} aSeenLocales
+     *        An array of locale names already seen for this install manifest.
+     *        Any locale names seen as a part of this function will be added to
+     *        this array
+     * @returns {Object}
+     *        an object containing the locale properties
+     */
+    function readLocale(aSource, isDefault, aSeenLocales) {
+      let locale = {};
+      if (!isDefault) {
+        locale.locales = [];
+        for (let localeName of aSource.locales || []) {
+          if (!localeName) {
+            logger.warn("Ignoring empty locale in localized properties");
+            continue;
+          }
+          if (aSeenLocales.includes(localeName)) {
+            logger.warn("Ignoring duplicate locale in localized properties");
+            continue;
+          }
+          aSeenLocales.push(localeName);
+          locale.locales.push(localeName);
+        }
+
+        if (locale.locales.length == 0) {
+          logger.warn("Ignoring localized properties with no listed locales");
+          return null;
+        }
+      }
+
+      for (let prop of [...PROP_LOCALE_SINGLE, ...PROP_LOCALE_MULTI]) {
+        if (hasOwnProperty(aSource, prop)) {
+          locale[prop] = aSource[prop];
+        }
+      }
+
+      return locale;
+    }
+
+    let manifestData = await pkg.readString("install.rdf");
+    let manifest = InstallRDF.loadFromString(manifestData).decode();
+
+    let addon = new AddonInternal();
+    for (let prop of PROP_METADATA) {
+      if (hasOwnProperty(manifest, prop)) {
+        addon[prop] = manifest[prop];
+      }
+    }
+
+    if (!addon.type) {
+      addon.type = "extension";
+    } else {
+      let type = addon.type;
+      addon.type = null;
+      for (let name in TYPES) {
+        if (TYPES[name] == type) {
+          addon.type = name;
+          break;
+        }
+      }
+    }
+
+    if (!(addon.type in TYPES))
+      throw new Error("Install manifest specifies unknown type: " + addon.type);
+
+    if (!addon.id)
+      throw new Error("No ID in install manifest");
+    if (!gIDTest.test(addon.id))
+      throw new Error("Illegal add-on ID " + addon.id);
+    if (!addon.version)
+      throw new Error("No version in install manifest");
+
+    addon.strictCompatibility = (!(addon.type in COMPATIBLE_BY_DEFAULT_TYPES) ||
+                                 manifest.strictCompatibility == "true");
+
+    // Only read these properties for extensions.
+    if (addon.type == "extension") {
+      if (manifest.bootstrap != "true") {
+        throw new Error("Non-restartless extensions no longer supported");
+      }
+
+      if (addon.optionsType &&
+          addon.optionsType != AddonManager.OPTIONS_TYPE_INLINE_BROWSER &&
+          addon.optionsType != AddonManager.OPTIONS_TYPE_TAB) {
+            throw new Error("Install manifest specifies unknown optionsType: " + addon.optionsType);
+      }
+    } else {
+      // Convert legacy dictionaries into a format the WebExtension
+      // dictionary loader can process.
+      if (addon.type === "dictionary") {
+        addon.loader = null;
+        let dictionaries = {};
+        await pkg.iterFiles(({path}) => {
+          let match = /^dictionaries\/([^\/]+)\.dic$/.exec(path);
+          if (match) {
+            let lang = match[1].replace(/_/g, "-");
+            dictionaries[lang] = match[0];
+          }
+        });
+        addon.startupData = {dictionaries};
+      }
+
+      // Only extensions are allowed to provide an optionsURL, optionsType,
+      // optionsBrowserStyle, or aboutURL. For all other types they are silently ignored
+      addon.aboutURL = null;
+      addon.optionsBrowserStyle = null;
+      addon.optionsType = null;
+      addon.optionsURL = null;
+    }
+
+    addon.defaultLocale = readLocale(manifest, true);
+
+    let seenLocales = [];
+    addon.locales = [];
+    for (let localeData of manifest.localized || []) {
+      let locale = readLocale(localeData, false, seenLocales);
+      if (locale)
+        addon.locales.push(locale);
+    }
+
+    let dependencies = new Set(manifest.dependencies);
+    addon.dependencies = Object.freeze(Array.from(dependencies));
+
+    let seenApplications = [];
+    addon.targetApplications = [];
+    for (let targetApp of manifest.targetApplications || []) {
+      if (!targetApp.id || !targetApp.minVersion ||
+          !targetApp.maxVersion) {
+            logger.warn("Ignoring invalid targetApplication entry in install manifest");
+            continue;
+      }
+      if (seenApplications.includes(targetApp.id)) {
+        logger.warn("Ignoring duplicate targetApplication entry for " + targetApp.id +
+                    " in install manifest");
+        continue;
+      }
+      seenApplications.push(targetApp.id);
+      addon.targetApplications.push(targetApp);
+    }
+
+    // Note that we don't need to check for duplicate targetPlatform entries since
+    // the RDF service coalesces them for us.
+    addon.targetPlatforms = [];
+    for (let targetPlatform of manifest.targetPlatforms || []) {
+      let platform = {
+        os: null,
+        abi: null,
+      };
+
+      let pos = targetPlatform.indexOf("_");
+      if (pos != -1) {
+        platform.os = targetPlatform.substring(0, pos);
+        platform.abi = targetPlatform.substring(pos + 1);
+      } else {
+        platform.os = targetPlatform;
+      }
+
+      addon.targetPlatforms.push(platform);
+    }
+
+    addon.userDisabled = false;
+    addon.softDisabled = addon.blocklistState == Blocklist.STATE_SOFTBLOCKED;
+    addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+
+    addon.userPermissions = null;
+
+    addon.icons = {};
+    if (await pkg.hasResource("icon.png")) {
+      addon.icons[32] = "icon.png";
+      addon.icons[48] = "icon.png";
+    }
+
+    if (await pkg.hasResource("icon64.png")) {
+      addon.icons[64] = "icon64.png";
+    }
+
+    return addon;
+  },
+
+  loadScope(addon, file) {
+    let uri = getURIForResourceInFile(file, "bootstrap.js").spec;
+    let principal = Services.scriptSecurityManager.getSystemPrincipal();
+
+    let sandbox = new Cu.Sandbox(principal, {
+      sandboxName: uri,
+      addonId: addon.id,
+      wantGlobalProperties: ["ChromeUtils"],
+      metadata: { addonID: addon.id, URI: uri },
+    });
+
+    try {
+      Object.assign(sandbox, BOOTSTRAP_REASONS);
+
+      XPCOMUtils.defineLazyGetter(sandbox, "console", () =>
+        new ConsoleAPI({ consoleID: `addon/${addon.id}` }));
+
+      Services.scriptloader.loadSubScript(uri, sandbox);
+    } catch (e) {
+      logger.warn(`Error loading bootstrap.js for ${addon.id}`, e);
+    }
+
+    function findMethod(name) {
+      if (sandbox.name) {
+        return sandbox.name;
+      }
+
+      try {
+        let method = Cu.evalInSandbox(name, sandbox);
+        return method;
+      } catch (err) { }
+
+      return () => {
+        logger.warn(`Add-on ${addon.id} is missing bootstrap method ${name}`);
+      };
+    }
+
+    let install = findMethod("install");
+    let uninstall = findMethod("uninstall");
+    let startup = findMethod("startup");
+    let shutdown = findMethod("shutdown");
+
+    return {
+      install: (...args) => install(...args),
+      uninstall: (...args) => uninstall(...args),
+
+      startup(...args) {
+        if (addon.type == "extension") {
+          logger.debug(`Registering manifest for ${file.path}\n`);
+          Components.manager.addBootstrappedManifestLocation(file);
+        }
+        return startup(...args);
+      },
+
+      shutdown(data, reason) {
+        try {
+          return shutdown(data, reason);
+        } catch (err) {
+          throw err;
+        } finally {
+          if (reason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
+            logger.debug(`Removing manifest for ${file.path}\n`);
+            Components.manager.removeBootstrappedManifestLocation(file);
+          }
+        }
+      },
+    };
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/common/src/RDFManifestConverter.jsm
@@ -0,0 +1,110 @@
+ /* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["InstallRDF"];
+
+ChromeUtils.defineModuleGetter(this, "RDFDataSource",
+                               "resource://gre/modules/addons/RDFDataSource.jsm");
+
+const RDFURI_INSTALL_MANIFEST_ROOT = "urn:mozilla:install-manifest";
+
+function EM_R(aProperty) {
+  return `http://www.mozilla.org/2004/em-rdf#${aProperty}`;
+}
+
+function getValue(literal) {
+  return literal && literal.getValue();
+}
+
+function getProperty(resource, property) {
+  return getValue(resource.getProperty(EM_R(property)));
+}
+
+class Manifest {
+  constructor(ds) {
+    this.ds = ds;
+  }
+
+  static loadFromString(text) {
+    return new this(RDFDataSource.loadFromString(text));
+  }
+
+  static loadFromBuffer(buffer) {
+    return new this(RDFDataSource.loadFromBuffer(buffer));
+  }
+
+  static async loadFromFile(uri) {
+    return new this(await RDFDataSource.loadFromFile(uri));
+  }
+}
+
+class InstallRDF extends Manifest {
+  _readProps(source, obj, props) {
+    for (let prop of props) {
+      let val = getProperty(source, prop);
+      if (val != null) {
+        obj[prop] = val;
+      }
+    }
+  }
+
+  _readArrayProp(source, obj, prop, target, decode = getValue) {
+    let result = Array.from(source.getObjects(EM_R(prop)),
+                            target => decode(target));
+    if (result.length) {
+      obj[target] = result;
+    }
+  }
+
+  _readArrayProps(source, obj, props, decode = getValue) {
+    for (let [prop, target] of Object.entries(props)) {
+      this._readArrayProp(source, obj, prop, target, decode);
+    }
+  }
+
+  _readLocaleStrings(source, obj) {
+    this._readProps(source, obj, ["name", "description", "creator", "homepageURL"]);
+    this._readArrayProps(source, obj, {
+      locale: "locales",
+      developer: "developers",
+      translator: "translators",
+      contributor: "contributors",
+    });
+  }
+
+  decode() {
+    let root = this.ds.getResource(RDFURI_INSTALL_MANIFEST_ROOT);
+    let result = {};
+
+    let props = ["id", "version", "type", "updateURL", "optionsURL",
+                 "optionsType", "aboutURL", "iconURL",
+                 "bootstrap", "unpack", "strictCompatibility"];
+    this._readProps(root, result, props);
+
+    let decodeTargetApplication = source => {
+      let app = {};
+      this._readProps(source, app, ["id", "minVersion", "maxVersion"]);
+      return app;
+    };
+
+    let decodeLocale = source => {
+      let localized = {};
+      this._readLocaleStrings(source, localized);
+      return localized;
+    };
+
+    this._readLocaleStrings(root, result);
+
+    this._readArrayProps(root, result, {"targetPlatform": "targetPlatforms"});
+    this._readArrayProps(root, result, {"targetApplication": "targetApplications"},
+                         decodeTargetApplication);
+    this._readArrayProps(root, result, {"localized": "localized"},
+                         decodeLocale);
+    this._readArrayProps(root, result, {"dependency": "dependencies"},
+                         source => getProperty(source, "id"));
+
+    return result;
+  }
+}
--- a/common/src/moz.build
+++ b/common/src/moz.build
@@ -1,17 +1,19 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES += [
+    'BootstrapLoader.jsm',
     'ChromeManifest.jsm',
     'extensionSupport.jsm',
-    'Overlays.jsm'
+    'Overlays.jsm',
+    'RDFManifestConverter.jsm',
 ]
 
 SOURCES += [
     'nsCommonModule.cpp',
     'nsComponentManagerExtra.cpp',
 ]
 
 LOCAL_INCLUDES += [
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/.eslintrc.js
@@ -0,0 +1,9 @@
+"use strict";
+
+module.exports = {
+  "extends": "plugin:mozilla/xpcshell-test",
+
+  "rules": {
+    "func-names": "off",
+  },
+};
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/data/BootstrapMonitor.jsm
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = [ "monitor" ];
+
+function notify(event, originalMethod, data, reason) {
+  let info = {
+    event,
+    data: Object.assign({}, data, {
+      installPath: data.installPath.path,
+      resourceURI: data.resourceURI.spec,
+    }),
+    reason,
+  };
+
+  let subject = {wrappedJSObject: {data}};
+
+  Services.obs.notifyObservers(subject, "bootstrapmonitor-event", JSON.stringify(info));
+
+  // If the bootstrap scope already declares a method call it
+  if (originalMethod)
+    originalMethod(data, reason);
+}
+
+// Allows a simple one-line bootstrap script:
+// Components.utils.import("resource://xpcshelldata/bootstrapmonitor.jsm").monitor(this);
+var monitor = function(scope, methods = ["install", "startup", "shutdown", "uninstall"]) {
+  for (let event of methods) {
+    scope[event] = notify.bind(null, event, scope[event]);
+  }
+};
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/head_addons.js
@@ -0,0 +1,1381 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */
+
+Cu.importGlobalProperties(["TextEncoder"]);
+
+const PREF_EM_CHECK_UPDATE_SECURITY   = "extensions.checkUpdateSecurity";
+const PREF_EM_STRICT_COMPATIBILITY    = "extensions.strictCompatibility";
+const PREF_GETADDONS_BYIDS               = "extensions.getAddons.get.url";
+const PREF_COMPAT_OVERRIDES              = "extensions.getAddons.compatOverides.url";
+const PREF_XPI_SIGNATURES_REQUIRED    = "xpinstall.signatures.required";
+
+const PREF_DISABLE_SECURITY = ("security.turn_off_all_security_so_that_" +
+                               "viruses_can_take_over_this_computer");
+
+// Maximum error in file modification times. Some file systems don't store
+// modification times exactly. As long as we are closer than this then it
+// still passes.
+const MAX_TIME_DIFFERENCE = 3000;
+
+// Time to reset file modified time relative to Date.now() so we can test that
+// times are modified (10 hours old).
+const MAKE_FILE_OLD_DIFFERENCE = 10 * 3600 * 1000;
+
+ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
+ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
+ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://gre/modules/addons/AddonRepository.jsm");
+ChromeUtils.import("resource://gre/modules/osfile.jsm");
+
+ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
+ChromeUtils.defineModuleGetter(this, "Blocklist",
+                               "resource://gre/modules/Blocklist.jsm");
+ChromeUtils.defineModuleGetter(this, "Extension",
+                               "resource://gre/modules/Extension.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionTestUtils",
+                               "resource://testing-common/ExtensionXPCShellUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionTestCommon",
+                               "resource://testing-common/ExtensionTestCommon.jsm");
+ChromeUtils.defineModuleGetter(this, "HttpServer",
+                               "resource://testing-common/httpd.js");
+ChromeUtils.defineModuleGetter(this, "MockAsyncShutdown",
+                               "resource://testing-common/AddonTestUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "MockRegistrar",
+                               "resource://testing-common/MockRegistrar.jsm");
+ChromeUtils.defineModuleGetter(this, "MockRegistry",
+                               "resource://testing-common/MockRegistry.jsm");
+ChromeUtils.defineModuleGetter(this, "PromiseTestUtils",
+                               "resource://testing-common/PromiseTestUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "TestUtils",
+                               "resource://testing-common/TestUtils.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "aomStartup",
+                                   "@mozilla.org/addons/addon-manager-startup;1",
+                                   "amIAddonManagerStartup");
+
+const {
+  createAppInfo,
+  createHttpServer,
+  createInstallRDF,
+  createTempWebExtensionFile,
+  createUpdateRDF,
+  getFileForAddon,
+  manuallyInstall,
+  manuallyUninstall,
+  overrideBuiltIns,
+  promiseAddonEvent,
+  promiseCompleteAllInstalls,
+  promiseCompleteInstall,
+  promiseConsoleOutput,
+  promiseFindAddonUpdates,
+  promiseInstallAllFiles,
+  promiseInstallFile,
+  promiseSetExtensionModifiedTime,
+  promiseShutdownManager,
+  promiseWebExtensionStartup,
+  promiseWriteProxyFileToDir,
+  registerDirectory,
+  setExtensionModifiedTime,
+  writeFilesToZip,
+} = AddonTestUtils;
+
+// WebExtension wrapper for ease of testing
+ExtensionTestUtils.init(this);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+XPCOMUtils.defineLazyGetter(this, "BOOTSTRAP_REASONS",
+                            () => AddonManagerPrivate.BOOTSTRAP_REASONS);
+
+function getReasonName(reason) {
+  for (let key of Object.keys(BOOTSTRAP_REASONS)) {
+    if (BOOTSTRAP_REASONS[key] == reason) {
+      return key;
+    }
+  }
+  throw new Error("This shouldn't happen.");
+}
+
+
+Object.defineProperty(this, "gAppInfo", {
+  get() {
+    return AddonTestUtils.appInfo;
+  },
+});
+
+Object.defineProperty(this, "gAddonStartup", {
+  get() {
+    return AddonTestUtils.addonStartup.clone();
+  },
+});
+
+Object.defineProperty(this, "gInternalManager", {
+  get() {
+    return AddonTestUtils.addonIntegrationService.QueryInterface(Ci.nsITimerCallback);
+  },
+});
+
+Object.defineProperty(this, "gProfD", {
+  get() {
+    return AddonTestUtils.profileDir.clone();
+  },
+});
+
+Object.defineProperty(this, "gTmpD", {
+  get() {
+    return AddonTestUtils.tempDir.clone();
+  },
+});
+
+Object.defineProperty(this, "gUseRealCertChecks", {
+  get() {
+    return AddonTestUtils.useRealCertChecks;
+  },
+  set(val) {
+   return AddonTestUtils.useRealCertChecks = val;
+  },
+});
+
+Object.defineProperty(this, "TEST_UNPACKED", {
+  get() {
+    return AddonTestUtils.testUnpacked;
+  },
+  set(val) {
+   return AddonTestUtils.testUnpacked = val;
+  },
+});
+
+// We need some internal bits of AddonManager
+var AMscope = ChromeUtils.import("resource://gre/modules/AddonManager.jsm", {});
+var { AddonManager, AddonManagerInternal, AddonManagerPrivate } = AMscope;
+
+// Wrap the startup functions to ensure the bootstrap loader is added.
+function promiseStartupManager(newVersion) {
+  ChromeUtils.import("resource:///modules/BootstrapLoader.jsm");
+  AddonManager.addExternalExtensionLoader(BootstrapLoader);
+  return AddonTestUtils.promiseStartupManager(newVersion);
+}
+
+async function promiseRestartManager(newVersion) {
+  await promiseShutdownManager(false);
+  await promiseStartupManager(newVersion);
+}
+
+const promiseAddonByID = AddonManager.getAddonByID;
+const promiseAddonsByIDs = AddonManager.getAddonsByIDs;
+
+var gPort = null;
+var gUrlToFileMap = {};
+
+// Map resource://xpcshell-data/ to the data directory
+var resHandler = Services.io.getProtocolHandler("resource")
+                         .QueryInterface(Ci.nsISubstitutingProtocolHandler);
+// Allow non-existent files because of bug 1207735
+var dataURI = NetUtil.newURI(do_get_file("data", true));
+resHandler.setSubstitution("xpcshell-data", dataURI);
+
+function isManifestRegistered(file) {
+  let manifests = Components.manager.getManifestLocations();
+  for (let i = 0; i < manifests.length; i++) {
+    let manifest = manifests.queryElementAt(i, Ci.nsIURI);
+
+    // manifest is the url to the manifest file either in an XPI or a directory.
+    // We want the location of the XPI or directory itself.
+    if (manifest instanceof Ci.nsIJARURI) {
+      manifest = manifest.JARFile.QueryInterface(Ci.nsIFileURL).file;
+    } else if (manifest instanceof Ci.nsIFileURL) {
+      manifest = manifest.file.parent;
+    } else {
+      continue;
+    }
+
+    if (manifest.equals(file))
+      return true;
+  }
+  return false;
+}
+
+const BOOTSTRAP_MONITOR_BOOTSTRAP_JS = `
+  ChromeUtils.import("resource://xpcshell-data/BootstrapMonitor.jsm").monitor(this);
+`;
+
+// Listens to messages from bootstrap.js telling us what add-ons were started
+// and stopped etc. and performs some sanity checks that only installed add-ons
+// are started etc.
+this.BootstrapMonitor = {
+  inited: false,
+
+  // Contain the current state of add-ons in the system
+  installed: new Map(),
+  started: new Map(),
+
+  // Contain the last state of shutdown and uninstall calls for an add-on
+  stopped: new Map(),
+  uninstalled: new Map(),
+
+  startupPromises: [],
+  installPromises: [],
+
+  restartfulIds: new Set(),
+
+  init() {
+    this.inited = true;
+    Services.obs.addObserver(this, "bootstrapmonitor-event");
+  },
+
+  shutdownCheck() {
+    if (!this.inited)
+      return;
+
+    Assert.equal(this.started.size, 0);
+  },
+
+  clear(id) {
+    this.installed.delete(id);
+    this.started.delete(id);
+    this.stopped.delete(id);
+    this.uninstalled.delete(id);
+  },
+
+  promiseAddonStartup(id) {
+    return new Promise(resolve => {
+      this.startupPromises.push(resolve);
+    });
+  },
+
+  promiseAddonInstall(id) {
+    return new Promise(resolve => {
+      this.installPromises.push(resolve);
+    });
+  },
+
+  checkMatches(cached, current) {
+    Assert.notEqual(cached, undefined);
+    Assert.equal(current.data.version, cached.data.version);
+    Assert.equal(current.data.installPath, cached.data.installPath);
+    Assert.ok(Services.io.newURI(current.data.resourceURI).equals(Services.io.newURI(cached.data.resourceURI)),
+              `Resource URIs match: "${current.data.resourceURI}" == "${cached.data.resourceURI}"`);
+  },
+
+  checkAddonStarted(id, version = undefined) {
+    let started = this.started.get(id);
+    Assert.notEqual(started, undefined);
+    if (version != undefined)
+      Assert.equal(started.data.version, version);
+
+    // Chrome should be registered by now
+    let installPath = new FileUtils.File(started.data.installPath);
+    let isRegistered = isManifestRegistered(installPath);
+    Assert.ok(isRegistered);
+  },
+
+  checkAddonNotStarted(id) {
+    Assert.ok(!this.started.has(id));
+  },
+
+  checkAddonInstalled(id, version = undefined) {
+    const installed = this.installed.get(id);
+    notEqual(installed, undefined);
+    if (version !== undefined) {
+      equal(installed.data.version, version);
+    }
+    return installed;
+  },
+
+  checkAddonNotInstalled(id) {
+    Assert.ok(!this.installed.has(id));
+  },
+
+  observe(subject, topic, data) {
+    let info = JSON.parse(data);
+    let id = info.data.id;
+    let installPath = new FileUtils.File(info.data.installPath);
+
+    if (subject && subject.wrappedJSObject) {
+      // NOTE: in some of the new tests, we need to received the real objects instead of
+      // their JSON representations, but most of the current tests expect intallPath
+      // and resourceURI to have been converted to strings.
+      info.data = Object.assign({}, subject.wrappedJSObject.data, {
+        installPath: info.data.installPath,
+        resourceURI: info.data.resourceURI,
+      });
+    }
+
+    // If this is the install event the add-ons shouldn't already be installed
+    if (info.event == "install") {
+      this.checkAddonNotInstalled(id);
+
+      this.installed.set(id, info);
+
+      for (let resolve of this.installPromises)
+        resolve();
+      this.installPromises = [];
+    } else {
+      this.checkMatches(this.installed.get(id), info);
+    }
+
+    // If this is the shutdown event than the add-on should already be started
+    if (info.event == "shutdown") {
+      this.checkMatches(this.started.get(id), info);
+
+      this.started.delete(id);
+      this.stopped.set(id, info);
+
+      // Chrome should still be registered at this point
+      let isRegistered = isManifestRegistered(installPath);
+      Assert.ok(isRegistered);
+
+      // XPIProvider doesn't bother unregistering chrome on app shutdown but
+      // since we simulate restarts we must do so manually to keep the registry
+      // consistent.
+      if (info.reason == 2 /* APP_SHUTDOWN */)
+        Components.manager.removeBootstrappedManifestLocation(installPath);
+    } else {
+      this.checkAddonNotStarted(id);
+    }
+
+    if (info.event == "uninstall") {
+      // We currently support registering, but not unregistering,
+      // restartful add-on manifests during xpcshell AOM "restarts".
+      if (!this.restartfulIds.has(id)) {
+        // Chrome should be unregistered at this point
+        let isRegistered = isManifestRegistered(installPath);
+        Assert.ok(!isRegistered);
+      }
+
+      this.installed.delete(id);
+      this.uninstalled.set(id, info);
+    } else if (info.event == "startup") {
+      this.started.set(id, info);
+
+      // Chrome should be registered at this point
+      let isRegistered = isManifestRegistered(installPath);
+      Assert.ok(isRegistered);
+
+      for (let resolve of this.startupPromises)
+        resolve();
+      this.startupPromises = [];
+    }
+  },
+};
+
+AddonTestUtils.on("addon-manager-shutdown", () => {
+  BootstrapMonitor.shutdownCheck();
+  Cu.unload("resource:///modules/BootstrapLoader.jsm");
+});
+
+var SlightlyLessDodgyBootstrapMonitor = {
+  started: new Map(),
+  stopped: new Map(),
+  installed: new Map(),
+  uninstalled: new Map(),
+
+  init() {
+    this.onEvent = this.onEvent.bind(this);
+
+    AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
+    AddonTestUtils.on("bootstrap-method", this.onEvent);
+  },
+
+  shutdownCheck() {
+    equal(this.started.size, 0,
+          "Should have no add-ons that were started but not shutdown");
+  },
+
+  onEvent(msg, data) {
+    switch (msg) {
+      case "addon-manager-shutdown":
+        this.shutdownCheck();
+        break;
+      case "bootstrap-method":
+        this.onBootstrapMethod(data.method, data.params, data.reason);
+        break;
+    }
+  },
+
+  onBootstrapMethod(method, params, reason) {
+    let {id} = params;
+
+    info(`Bootstrap method ${method} for ${params.id} version ${params.version}`);
+
+    if (method !== "install") {
+      this.checkInstalled(id);
+    }
+
+    switch (method) {
+      case "install":
+        this.checkNotInstalled(id);
+        this.installed.set(id, {reason, params});
+        this.uninstalled.delete(id);
+        break;
+      case "startup":
+        this.checkNotStarted(id);
+        this.started.set(id, {reason, params});
+        this.stopped.delete(id);
+        break;
+      case "shutdown":
+        this.checkMatches("shutdown", "startup", params, this.started.get(id));
+        this.checkStarted(id);
+        this.stopped.set(id, {reason, params});
+        this.started.delete(id);
+        break;
+      case "uninstall":
+        this.checkMatches("uninstall", "install", params, this.installed.get(id));
+        this.uninstalled.set(id, {reason, params});
+        this.installed.delete(id);
+        break;
+      case "update":
+        this.checkMatches("update", "install", params, this.installed.get(id));
+        this.installed.set(id, {reason, params});
+        break;
+    }
+  },
+
+  clear(id) {
+    this.installed.delete(id);
+    this.started.delete(id);
+    this.stopped.delete(id);
+    this.uninstalled.delete(id);
+  },
+
+  checkMatches(method, lastMethod, params, {params: lastParams} = {}) {
+    ok(lastParams,
+       `Expecting matching ${lastMethod} call for add-on ${params.id} ${method} call`);
+
+    if (method == "update") {
+      equal(params.oldVersion, lastParams.version,
+            "params.version should match last call");
+    } else {
+      equal(params.version, lastParams.version,
+            "params.version should match last call");
+    }
+
+    if (method !== "update" && method !== "uninstall") {
+      equal(params.installPath.path, lastParams.installPath.path,
+            `params.installPath should match last call`);
+
+      ok(params.resourceURI.equals(lastParams.resourceURI),
+         `params.resourceURI should match: "${params.resourceURI.spec}" == "${lastParams.resourceURI.spec}"`);
+    }
+  },
+
+  checkStarted(id, version = undefined) {
+    let started = this.started.get(id);
+    ok(started, `Should have seen startup method call for ${id}`);
+
+    if (version !== undefined)
+      equal(started.params.version, version,
+            "Expected version number");
+  },
+
+  checkNotStarted(id) {
+    ok(!this.started.has(id),
+       `Should not have seen startup method call for ${id}`);
+  },
+
+  checkInstalled(id, version = undefined) {
+    const installed = this.installed.get(id);
+    ok(installed, `Should have seen install call for ${id}`);
+
+    if (version !== undefined)
+      equal(installed.params.version, version,
+            "Expected version number");
+
+    return installed;
+  },
+
+  checkNotInstalled(id) {
+    ok(!this.installed.has(id),
+       `Should not have seen install method call for ${id}`);
+  },
+};
+
+function isNightlyChannel() {
+  var channel = Services.prefs.getCharPref("app.update.channel", "default");
+
+  return channel != "aurora" && channel != "beta" && channel != "release" && channel != "esr";
+}
+
+
+async function restartWithLocales(locales) {
+  Services.locale.requestedLocales = locales;
+  await promiseRestartManager();
+}
+
+/**
+ * Returns a map of Addon objects for installed add-ons with the given
+ * IDs. The returned map contains a key for the ID of each add-on that
+ * is found. IDs for add-ons which do not exist are not present in the
+ * map.
+ *
+ * @param {sequence<string>} ids
+ *        The list of add-on IDs to get.
+ * @returns {Promise<string, Addon>}
+ *        Map of add-ons that were found.
+ */
+async function getAddons(ids) {
+  let addons = new Map();
+  for (let addon of await AddonManager.getAddonsByIDs(ids)) {
+    if (addon) {
+      addons.set(addon.id, addon);
+    }
+  }
+  return addons;
+}
+
+/**
+ * Checks that the given add-on has the given expected properties.
+ *
+ * @param {string} id
+ *        The id of the add-on.
+ * @param {Addon?} addon
+ *        The add-on object, or null if the add-on does not exist.
+ * @param {object?} expected
+ *        An object containing the expected values for properties of the
+ *        add-on, or null if the add-on is expected not to exist.
+ */
+function checkAddon(id, addon, expected) {
+  info(`Checking state of addon ${id}`);
+
+  if (expected === null) {
+    ok(!addon, `Addon ${id} should not exist`);
+  } else {
+    ok(addon, `Addon ${id} should exist`);
+    for (let [key, value] of Object.entries(expected)) {
+      if (value instanceof Ci.nsIURI) {
+        equal(addon[key] && addon[key].spec, value.spec, `Expected value of addon.${key}`);
+      } else {
+        deepEqual(addon[key], value, `Expected value of addon.${key}`);
+      }
+    }
+  }
+}
+
+/**
+ * Tests that an add-on does appear in the crash report annotations, if
+ * crash reporting is enabled. The test will fail if the add-on is not in the
+ * annotation.
+ * @param  aId
+ *         The ID of the add-on
+ * @param  aVersion
+ *         The version of the add-on
+ */
+function do_check_in_crash_annotation(aId, aVersion) {
+  if (!AppConstants.MOZ_CRASHREPORTER) {
+    return;
+  }
+
+  if (!("Add-ons" in gAppInfo.annotations)) {
+    Assert.ok(false, "Cannot find Add-ons entry in crash annotations");
+    return;
+  }
+
+  let addons = gAppInfo.annotations["Add-ons"].split(",");
+  Assert.ok(addons.includes(`${encodeURIComponent(aId)}:${encodeURIComponent(aVersion)}`));
+}
+
+/**
+ * Tests that an add-on does not appear in the crash report annotations, if
+ * crash reporting is enabled. The test will fail if the add-on is in the
+ * annotation.
+ * @param  aId
+ *         The ID of the add-on
+ * @param  aVersion
+ *         The version of the add-on
+ */
+function do_check_not_in_crash_annotation(aId, aVersion) {
+  if (!AppConstants.MOZ_CRASHREPORTER) {
+    return;
+  }
+
+  if (!("Add-ons" in gAppInfo.annotations)) {
+    Assert.ok(true);
+    return;
+  }
+
+  let addons = gAppInfo.annotations["Add-ons"].split(",");
+  Assert.ok(!addons.includes(`${encodeURIComponent(aId)}:${encodeURIComponent(aVersion)}`));
+}
+
+function do_get_file_hash(aFile, aAlgorithm) {
+  if (!aAlgorithm)
+    aAlgorithm = "sha1";
+
+  let crypto = Cc["@mozilla.org/security/hash;1"].
+               createInstance(Ci.nsICryptoHash);
+  crypto.initWithString(aAlgorithm);
+  let fis = Cc["@mozilla.org/network/file-input-stream;1"].
+            createInstance(Ci.nsIFileInputStream);
+  fis.init(aFile, -1, -1, false);
+  crypto.updateFromStream(fis, aFile.fileSize);
+
+  // return the two-digit hexadecimal code for a byte
+  let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+  let binary = crypto.finish(false);
+  let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+  return aAlgorithm + ":" + hash.join("");
+}
+
+/**
+ * Returns an extension uri spec
+ *
+ * @param  aProfileDir
+ *         The extension install directory
+ * @return a uri spec pointing to the root of the extension
+ */
+function do_get_addon_root_uri(aProfileDir, aId) {
+  let path = aProfileDir.clone();
+  path.append(aId);
+  if (!path.exists()) {
+    path.leafName += ".xpi";
+    return "jar:" + Services.io.newFileURI(path).spec + "!/";
+  }
+  return Services.io.newFileURI(path).spec;
+}
+
+function do_get_expected_addon_name(aId) {
+  if (TEST_UNPACKED)
+    return aId;
+  return aId + ".xpi";
+}
+
+/**
+ * Check that an array of actual add-ons is the same as an array of
+ * expected add-ons.
+ *
+ * @param  aActualAddons
+ *         The array of actual add-ons to check.
+ * @param  aExpectedAddons
+ *         The array of expected add-ons to check against.
+ * @param  aProperties
+ *         An array of properties to check.
+ */
+function do_check_addons(aActualAddons, aExpectedAddons, aProperties) {
+  Assert.notEqual(aActualAddons, null);
+  Assert.equal(aActualAddons.length, aExpectedAddons.length);
+  for (let i = 0; i < aActualAddons.length; i++)
+    do_check_addon(aActualAddons[i], aExpectedAddons[i], aProperties);
+}
+
+/**
+ * Check that the actual add-on is the same as the expected add-on.
+ *
+ * @param  aActualAddon
+ *         The actual add-on to check.
+ * @param  aExpectedAddon
+ *         The expected add-on to check against.
+ * @param  aProperties
+ *         An array of properties to check.
+ */
+function do_check_addon(aActualAddon, aExpectedAddon, aProperties) {
+  Assert.notEqual(aActualAddon, null);
+
+  aProperties.forEach(function(aProperty) {
+    let actualValue = aActualAddon[aProperty];
+    let expectedValue = aExpectedAddon[aProperty];
+
+    // Check that all undefined expected properties are null on actual add-on
+    if (!(aProperty in aExpectedAddon)) {
+      if (actualValue !== undefined && actualValue !== null) {
+        do_throw("Unexpected defined/non-null property for add-on " +
+                 aExpectedAddon.id + " (addon[" + aProperty + "] = " +
+                 actualValue.toSource() + ")");
+      }
+
+      return;
+    }
+    if (expectedValue && !actualValue) {
+      do_throw("Missing property for add-on " + aExpectedAddon.id +
+        ": expected addon[" + aProperty + "] = " + expectedValue);
+      return;
+    }
+
+    switch (aProperty) {
+      case "creator":
+        do_check_author(actualValue, expectedValue);
+        break;
+
+      case "developers":
+        Assert.equal(actualValue.length, expectedValue.length);
+        for (let i = 0; i < actualValue.length; i++)
+          do_check_author(actualValue[i], expectedValue[i]);
+        break;
+
+      case "screenshots":
+        Assert.equal(actualValue.length, expectedValue.length);
+        for (let i = 0; i < actualValue.length; i++)
+          do_check_screenshot(actualValue[i], expectedValue[i]);
+        break;
+
+      case "sourceURI":
+        Assert.equal(actualValue.spec, expectedValue);
+        break;
+
+      case "updateDate":
+        Assert.equal(actualValue.getTime(), expectedValue.getTime());
+        break;
+
+      case "compatibilityOverrides":
+        Assert.equal(actualValue.length, expectedValue.length);
+        for (let i = 0; i < actualValue.length; i++)
+          do_check_compatibilityoverride(actualValue[i], expectedValue[i]);
+        break;
+
+      case "icons":
+        do_check_icons(actualValue, expectedValue);
+        break;
+
+      default:
+        if (actualValue !== expectedValue)
+          do_throw("Failed for " + aProperty + " for add-on " + aExpectedAddon.id +
+                   " (" + actualValue + " === " + expectedValue + ")");
+    }
+  });
+}
+
+/**
+ * Check that the actual author is the same as the expected author.
+ *
+ * @param  aActual
+ *         The actual author to check.
+ * @param  aExpected
+ *         The expected author to check against.
+ */
+function do_check_author(aActual, aExpected) {
+  Assert.equal(aActual.toString(), aExpected.name);
+  Assert.equal(aActual.name, aExpected.name);
+  Assert.equal(aActual.url, aExpected.url);
+}
+
+/**
+ * Check that the actual screenshot is the same as the expected screenshot.
+ *
+ * @param  aActual
+ *         The actual screenshot to check.
+ * @param  aExpected
+ *         The expected screenshot to check against.
+ */
+function do_check_screenshot(aActual, aExpected) {
+  Assert.equal(aActual.toString(), aExpected.url);
+  Assert.equal(aActual.url, aExpected.url);
+  Assert.equal(aActual.width, aExpected.width);
+  Assert.equal(aActual.height, aExpected.height);
+  Assert.equal(aActual.thumbnailURL, aExpected.thumbnailURL);
+  Assert.equal(aActual.thumbnailWidth, aExpected.thumbnailWidth);
+  Assert.equal(aActual.thumbnailHeight, aExpected.thumbnailHeight);
+  Assert.equal(aActual.caption, aExpected.caption);
+}
+
+/**
+ * Check that the actual compatibility override is the same as the expected
+ * compatibility override.
+ *
+ * @param  aAction
+ *         The actual compatibility override to check.
+ * @param  aExpected
+ *         The expected compatibility override to check against.
+ */
+function do_check_compatibilityoverride(aActual, aExpected) {
+  Assert.equal(aActual.type, aExpected.type);
+  Assert.equal(aActual.minVersion, aExpected.minVersion);
+  Assert.equal(aActual.maxVersion, aExpected.maxVersion);
+  Assert.equal(aActual.appID, aExpected.appID);
+  Assert.equal(aActual.appMinVersion, aExpected.appMinVersion);
+  Assert.equal(aActual.appMaxVersion, aExpected.appMaxVersion);
+}
+
+function do_check_icons(aActual, aExpected) {
+  for (var size in aExpected) {
+    Assert.equal(aActual[size], aExpected[size]);
+  }
+}
+
+function isThemeInAddonsList(aDir, aId) {
+  return AddonTestUtils.addonsList.hasTheme(aDir, aId);
+}
+
+function isExtensionInBootstrappedList(aDir, aId) {
+  return AddonTestUtils.addonsList.hasExtension(aDir, aId);
+}
+
+/**
+ * Writes an install.rdf manifest into a directory using the properties passed
+ * in a JS object. The objects should contain a property for each property to
+ * appear in the RDF. The object may contain an array of objects with id,
+ * minVersion and maxVersion in the targetApplications property to give target
+ * application compatibility.
+ *
+ * @param   aData
+ *          The object holding data about the add-on
+ * @param   aDir
+ *          The directory to add the install.rdf to
+ * @param   aId
+ *          An optional string to override the default installation aId
+ * @param   aExtraFile
+ *          An optional dummy file to create in the directory
+ * @return  An nsIFile for the directory in which the add-on is installed.
+ */
+async function promiseWriteInstallRDFToDir(aData, aDir, aId = aData.id, aExtraFile = null) {
+  let files = {
+    "install.rdf": AddonTestUtils.createInstallRDF(aData),
+  };
+  if (typeof aExtraFile === "object")
+    Object.assign(files, aExtraFile);
+  else
+    files[aExtraFile] = "";
+
+  let dir = aDir.clone();
+  dir.append(aId);
+
+  await AddonTestUtils.promiseWriteFilesToDir(dir.path, files);
+  return dir;
+}
+
+/**
+ * Writes an install.rdf manifest into a packed extension using the properties passed
+ * in a JS object. The objects should contain a property for each property to
+ * appear in the RDF. The object may contain an array of objects with id,
+ * minVersion and maxVersion in the targetApplications property to give target
+ * application compatibility.
+ *
+ * @param   aData
+ *          The object holding data about the add-on
+ * @param   aDir
+ *          The install directory to add the extension to
+ * @param   aId
+ *          An optional string to override the default installation aId
+ * @param   aExtraFile
+ *          An optional dummy file to create in the extension
+ * @return  A file pointing to where the extension was installed
+ */
+async function promiseWriteInstallRDFToXPI(aData, aDir, aId = aData.id, aExtraFile = null) {
+  let files = {
+    "install.rdf": AddonTestUtils.createInstallRDF(aData),
+  };
+  if (typeof aExtraFile === "object")
+    Object.assign(files, aExtraFile);
+  else
+  if (aExtraFile)
+    files[aExtraFile] = "";
+
+  if (!aDir.exists())
+    aDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+  var file = aDir.clone();
+  file.append(`${aId}.xpi`);
+
+  AddonTestUtils.writeFilesToZip(file.path, files);
+
+  return file;
+}
+
+/**
+ * Writes an install.rdf manifest into an extension using the properties passed
+ * in a JS object. The objects should contain a property for each property to
+ * appear in the RDF. The object may contain an array of objects with id,
+ * minVersion and maxVersion in the targetApplications property to give target
+ * application compatibility.
+ *
+ * @param   aData
+ *          The object holding data about the add-on
+ * @param   aDir
+ *          The install directory to add the extension to
+ * @param   aId
+ *          An optional string to override the default installation aId
+ * @param   aExtraFile
+ *          An optional dummy file to create in the extension
+ * @return  A file pointing to where the extension was installed
+ */
+function promiseWriteInstallRDFForExtension(aData, aDir, aId, aExtraFile) {
+  if (TEST_UNPACKED) {
+    return promiseWriteInstallRDFToDir(aData, aDir, aId, aExtraFile);
+  }
+  return promiseWriteInstallRDFToXPI(aData, aDir, aId, aExtraFile);
+}
+
+/**
+ * Writes a manifest.json manifest into an extension using the properties passed
+ * in a JS object.
+ *
+ * @param   aManifest
+ *          The data to write
+ * @param   aDir
+ *          The install directory to add the extension to
+ * @param   aId
+ *          An optional string to override the default installation aId
+ * @return  A file pointing to where the extension was installed
+ */
+function promiseWriteWebManifestForExtension(aData, aDir, aId = aData.applications.gecko.id) {
+  let files = {
+    "manifest.json": JSON.stringify(aData),
+  };
+  return AddonTestUtils.promiseWriteFilesToExtension(aDir.path, aId, files);
+}
+
+/**
+ * Creates an XPI file for some manifest data in the temporary directory and
+ * returns the nsIFile for it. The file will be deleted when the test completes.
+ *
+ * @param   aData
+ *          The object holding data about the add-on
+ * @return  A file pointing to the created XPI file
+ */
+function createTempXPIFile(aData, aExtraFile) {
+  let files = {
+    "install.rdf": aData,
+  };
+  if (typeof aExtraFile == "object")
+    Object.assign(files, aExtraFile);
+  else if (aExtraFile)
+    files[aExtraFile] = "";
+
+  return AddonTestUtils.createTempXPIFile(files);
+}
+
+function promiseInstallXPI(installRDF) {
+  return AddonTestUtils.promiseInstallXPI({"install.rdf": installRDF});
+}
+
+var gExpectedEvents = {};
+var gExpectedInstalls = [];
+var gNext = null;
+
+function getExpectedEvent(aId) {
+  if (!(aId in gExpectedEvents))
+    do_throw("Wasn't expecting events for " + aId);
+  if (gExpectedEvents[aId].length == 0)
+    do_throw("Too many events for " + aId);
+  let event = gExpectedEvents[aId].shift();
+  if (event instanceof Array)
+    return event;
+  return [event, true];
+}
+
+function getExpectedInstall(aAddon) {
+  if (gExpectedInstalls instanceof Array)
+    return gExpectedInstalls.shift();
+  if (!aAddon || !aAddon.id)
+    return gExpectedInstalls.NO_ID.shift();
+  let id = aAddon.id;
+  if (!(id in gExpectedInstalls) || !(gExpectedInstalls[id] instanceof Array))
+    do_throw("Wasn't expecting events for " + id);
+  if (gExpectedInstalls[id].length == 0)
+    do_throw("Too many events for " + id);
+  return gExpectedInstalls[id].shift();
+}
+
+const AddonListener = {
+  onPropertyChanged(aAddon, aProperties) {
+    info(`Got onPropertyChanged event for ${aAddon.id}`);
+    let [event, properties] = getExpectedEvent(aAddon.id);
+    Assert.equal("onPropertyChanged", event);
+    Assert.equal(aProperties.length, properties.length);
+    properties.forEach(function(aProperty) {
+      // Only test that the expected properties are listed, having additional
+      // properties listed is not necessary a problem
+      if (!aProperties.includes(aProperty))
+        do_throw("Did not see property change for " + aProperty);
+    });
+    return check_test_completed(arguments);
+  },
+
+  onEnabling(aAddon, aRequiresRestart) {
+    info(`Got onEnabling event for ${aAddon.id}`);
+    let [event, expectedRestart] = getExpectedEvent(aAddon.id);
+    Assert.equal("onEnabling", event);
+    Assert.equal(aRequiresRestart, expectedRestart);
+    if (expectedRestart)
+      Assert.ok(hasFlag(aAddon.pendingOperations, AddonManager.PENDING_ENABLE));
+    Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+    return check_test_completed(arguments);
+  },
+
+  onEnabled(aAddon) {
+    info(`Got onEnabled event for ${aAddon.id}`);
+    let [event] = getExpectedEvent(aAddon.id);
+    Assert.equal("onEnabled", event);
+    Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+    return check_test_completed(arguments);
+  },
+
+  onDisabling(aAddon, aRequiresRestart) {
+    info(`Got onDisabling event for ${aAddon.id}`);
+    let [event, expectedRestart] = getExpectedEvent(aAddon.id);
+    Assert.equal("onDisabling", event);
+    Assert.equal(aRequiresRestart, expectedRestart);
+    if (expectedRestart)
+      Assert.ok(hasFlag(aAddon.pendingOperations, AddonManager.PENDING_DISABLE));
+    Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+    return check_test_completed(arguments);
+  },
+
+  onDisabled(aAddon) {
+    info(`Got onDisabled event for ${aAddon.id}`);
+    let [event] = getExpectedEvent(aAddon.id);
+    Assert.equal("onDisabled", event);
+    Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+    return check_test_completed(arguments);
+  },
+
+  onInstalling(aAddon, aRequiresRestart) {
+    info(`Got onInstalling event for ${aAddon.id}`);
+    let [event, expectedRestart] = getExpectedEvent(aAddon.id);
+    Assert.equal("onInstalling", event);
+    Assert.equal(aRequiresRestart, expectedRestart);
+    if (expectedRestart)
+      Assert.ok(hasFlag(aAddon.pendingOperations, AddonManager.PENDING_INSTALL));
+    return check_test_completed(arguments);
+  },
+
+  onInstalled(aAddon) {
+    info(`Got onInstalled event for ${aAddon.id}`);
+    let [event] = getExpectedEvent(aAddon.id);
+    Assert.equal("onInstalled", event);
+    return check_test_completed(arguments);
+  },
+
+  onUninstalling(aAddon, aRequiresRestart) {
+    info(`Got onUninstalling event for ${aAddon.id}`);
+    let [event, expectedRestart] = getExpectedEvent(aAddon.id);
+    Assert.equal("onUninstalling", event);
+    Assert.equal(aRequiresRestart, expectedRestart);
+    if (expectedRestart)
+      Assert.ok(hasFlag(aAddon.pendingOperations, AddonManager.PENDING_UNINSTALL));
+    return check_test_completed(arguments);
+  },
+
+  onUninstalled(aAddon) {
+    info(`Got onUninstalled event for ${aAddon.id}`);
+    let [event] = getExpectedEvent(aAddon.id);
+    Assert.equal("onUninstalled", event);
+    return check_test_completed(arguments);
+  },
+
+  onOperationCancelled(aAddon) {
+    info(`Got onOperationCancelled event for ${aAddon.id}`);
+    let [event] = getExpectedEvent(aAddon.id);
+    Assert.equal("onOperationCancelled", event);
+    return check_test_completed(arguments);
+  },
+};
+
+const InstallListener = {
+  onNewInstall(install) {
+    if (install.state != AddonManager.STATE_DOWNLOADED &&
+        install.state != AddonManager.STATE_DOWNLOAD_FAILED &&
+        install.state != AddonManager.STATE_AVAILABLE)
+      do_throw("Bad install state " + install.state);
+    if (install.state != AddonManager.STATE_DOWNLOAD_FAILED)
+      Assert.equal(install.error, 0);
+    else
+      Assert.notEqual(install.error, 0);
+    Assert.equal("onNewInstall", getExpectedInstall());
+    return check_test_completed(arguments);
+  },
+
+  onDownloadStarted(install) {
+    Assert.equal(install.state, AddonManager.STATE_DOWNLOADING);
+    Assert.equal(install.error, 0);
+    Assert.equal("onDownloadStarted", getExpectedInstall());
+    return check_test_completed(arguments);
+  },
+
+  onDownloadEnded(install) {
+    Assert.equal(install.state, AddonManager.STATE_DOWNLOADED);
+    Assert.equal(install.error, 0);
+    Assert.equal("onDownloadEnded", getExpectedInstall());
+    return check_test_completed(arguments);
+  },
+
+  onDownloadFailed(install) {
+    Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+    Assert.equal("onDownloadFailed", getExpectedInstall());
+    return check_test_completed(arguments);
+  },
+
+  onDownloadCancelled(install) {
+    Assert.equal(install.state, AddonManager.STATE_CANCELLED);
+    Assert.equal(install.error, 0);
+    Assert.equal("onDownloadCancelled", getExpectedInstall());
+    return check_test_completed(arguments);
+  },
+
+  onInstallStarted(install) {
+    Assert.equal(install.state, AddonManager.STATE_INSTALLING);
+    Assert.equal(install.error, 0);
+    Assert.equal("onInstallStarted", getExpectedInstall(install.addon));
+    return check_test_completed(arguments);
+  },
+
+  onInstallEnded(install, newAddon) {
+    Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+    Assert.equal(install.error, 0);
+    Assert.equal("onInstallEnded", getExpectedInstall(install.addon));
+    return check_test_completed(arguments);
+  },
+
+  onInstallFailed(install) {
+    Assert.equal(install.state, AddonManager.STATE_INSTALL_FAILED);
+    Assert.equal("onInstallFailed", getExpectedInstall(install.addon));
+    return check_test_completed(arguments);
+  },
+
+  onInstallCancelled(install) {
+    // If the install was cancelled by a listener returning false from
+    // onInstallStarted, then the state will revert to STATE_DOWNLOADED.
+    let possibleStates = [AddonManager.STATE_CANCELLED,
+                          AddonManager.STATE_DOWNLOADED];
+    Assert.ok(possibleStates.includes(install.state));
+    Assert.equal(install.error, 0);
+    Assert.equal("onInstallCancelled", getExpectedInstall(install.addon));
+    return check_test_completed(arguments);
+  },
+
+  onExternalInstall(aAddon, existingAddon, aRequiresRestart) {
+    Assert.equal("onExternalInstall", getExpectedInstall(aAddon));
+    Assert.ok(!aRequiresRestart);
+    return check_test_completed(arguments);
+  },
+};
+
+function hasFlag(aBits, aFlag) {
+  return (aBits & aFlag) != 0;
+}
+
+// Just a wrapper around setting the expected events.
+function prepare_test(aExpectedEvents, aExpectedInstalls, aNext) {
+  AddonManager.addAddonListener(AddonListener);
+  AddonManager.addInstallListener(InstallListener);
+
+  gExpectedInstalls = aExpectedInstalls;
+  gExpectedEvents = aExpectedEvents;
+  gNext = aNext;
+}
+
+function clearListeners() {
+  AddonManager.removeAddonListener(AddonListener);
+  AddonManager.removeInstallListener(InstallListener);
+}
+
+function end_test() {
+  clearListeners();
+}
+
+// Checks if all expected events have been seen and if so calls the callback.
+function check_test_completed(aArgs) {
+  if (!gNext)
+    return undefined;
+
+  if (gExpectedInstalls instanceof Array &&
+      gExpectedInstalls.length > 0)
+    return undefined;
+
+  for (let id in gExpectedInstalls) {
+    let installList = gExpectedInstalls[id];
+    if (installList.length > 0)
+      return undefined;
+  }
+
+  for (let id in gExpectedEvents) {
+    if (gExpectedEvents[id].length > 0)
+      return undefined;
+  }
+
+  return gNext.apply(null, aArgs);
+}
+
+// Verifies that all the expected events for all add-ons were seen.
+function ensure_test_completed() {
+  for (let i in gExpectedEvents) {
+    if (gExpectedEvents[i].length > 0)
+      do_throw(`Didn't see all the expected events for ${i}: Still expecting ${gExpectedEvents[i]}`);
+  }
+  gExpectedEvents = {};
+  if (gExpectedInstalls)
+    Assert.equal(gExpectedInstalls.length, 0);
+}
+
+/**
+ * A helper method to install an array of AddonInstall to completion and then
+ * call a provided callback.
+ *
+ * @param   aInstalls
+ *          The array of AddonInstalls to install
+ * @param   aCallback
+ *          The callback to call when all installs have finished
+ */
+function completeAllInstalls(aInstalls, aCallback) {
+  promiseCompleteAllInstalls(aInstalls).then(aCallback);
+}
+
+/**
+ * A helper method to install an array of files and call a callback after the
+ * installs are completed.
+ *
+ * @param   aFiles
+ *          The array of files to install
+ * @param   aCallback
+ *          The callback to call when all installs have finished
+ * @param   aIgnoreIncompatible
+ *          Optional parameter to ignore add-ons that are incompatible in
+ *          aome way with the application
+ */
+function installAllFiles(aFiles, aCallback, aIgnoreIncompatible) {
+  promiseInstallAllFiles(aFiles, aIgnoreIncompatible).then(aCallback);
+}
+
+const EXTENSIONS_DB = "extensions.json";
+var gExtensionsJSON = gProfD.clone();
+gExtensionsJSON.append(EXTENSIONS_DB);
+
+async function promiseInstallWebExtension(aData) {
+  let addonFile = createTempWebExtensionFile(aData);
+
+  let {addon} = await promiseInstallFile(addonFile);
+  return addon;
+}
+
+// By default use strict compatibility.
+Services.prefs.setBoolPref("extensions.strictCompatibility", true);
+
+// Ensure signature checks are enabled by default.
+Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+
+Services.prefs.setBoolPref("extensions.legacy.enabled", true);
+
+// Copies blocklistFile (an nsIFile) to gProfD/blocklist.xml.
+function copyBlocklistToProfile(blocklistFile) {
+  var dest = gProfD.clone();
+  dest.append("blocklist.xml");
+  if (dest.exists())
+    dest.remove(false);
+  blocklistFile.copyTo(gProfD, "blocklist.xml");
+  dest.lastModifiedTime = Date.now();
+}
+
+// Make sure that a given path does not exist.
+function pathShouldntExist(file) {
+  if (file.exists()) {
+    do_throw(`Test cleanup: path ${file.path} exists when it should not`);
+  }
+}
+
+// Wrap a function (typically a callback) to catch and report exceptions.
+function do_exception_wrap(func) {
+  return function() {
+    try {
+      func.apply(null, arguments);
+    } catch (e) {
+      do_report_unexpected_exception(e);
+    }
+  };
+}
+
+/**
+ * Change the schema version of the JSON extensions database.
+ */
+async function changeXPIDBVersion(aNewVersion) {
+  let json = await loadJSON(gExtensionsJSON.path);
+  json.schemaVersion = aNewVersion;
+  await saveJSON(json, gExtensionsJSON.path);
+}
+
+/**
+ * Load a file into a string.
+ */
+async function loadFile(aFile) {
+  let buffer = await OS.File.read(aFile);
+  return new TextDecoder().decode(buffer);
+}
+
+/**
+ * Raw load of a JSON file.
+ */
+async function loadJSON(aFile) {
+  let data = await loadFile(aFile);
+  info("Loaded JSON file " + aFile);
+  return JSON.parse(data);
+}
+
+/**
+ * Raw save of a JSON blob to file.
+ */
+async function saveJSON(aData, aFile) {
+  info("Starting to save JSON file " + aFile);
+  await OS.File.writeAtomic(aFile, new TextEncoder().encode(JSON.stringify(aData, null, 2)));
+  info("Done saving JSON file " + aFile.path);
+}
+
+/**
+ * Create a callback function that calls do_execute_soon on an actual callback and arguments.
+ */
+function callback_soon(aFunction) {
+  return function(...args) {
+    executeSoon(function() {
+      aFunction.apply(null, args);
+    }, aFunction.name ? "delayed callback " + aFunction.name : "delayed callback");
+  };
+}
+
+XPCOMUtils.defineLazyServiceGetter(this, "pluginHost",
+                                  "@mozilla.org/plugin/host;1", "nsIPluginHost");
+
+class MockPluginTag {
+  constructor(opts, enabledState = Ci.nsIPluginTag.STATE_ENABLED) {
+    this.pluginTag = pluginHost.createFakePlugin({
+      handlerURI: "resource://fake-plugin/${Math.random()}.xhtml",
+      mimeEntries: [{type: "application/x-fake-plugin"}],
+      fileName: `${opts.name}.so`,
+      ...opts,
+    });
+    this.pluginTag.enabledState = enabledState;
+
+    this.name = opts.name;
+    this.version = opts.version;
+  }
+  async isBlocklisted() {
+    let state = await Blocklist.getPluginBlocklistState(this.pluginTag);
+    return state == Services.blocklist.STATE_BLOCKED;
+  }
+  get disabled() {
+    return this.pluginTag.enabledState == Ci.nsIPluginTag.STATE_DISABLED;
+  }
+  set disabled(val) {
+    this.enabledState = Ci.nsIPluginTag[val ? "STATE_DISABLED" : "STATE_ENABLED"];
+  }
+  get enabledState() {
+    return this.pluginTag.enabledState;
+  }
+  set enabledState(val) {
+    this.pluginTag.enabledState = val;
+  }
+}
+
+function mockPluginHost(plugins) {
+  let PluginHost = {
+    getPluginTags(count) {
+      count.value = plugins.length;
+      return plugins.map(p => p.pluginTag);
+    },
+
+    QueryInterface: ChromeUtils.generateQI(["nsIPluginHost"]),
+  };
+
+  MockRegistrar.register("@mozilla.org/plugin/host;1", PluginHost);
+}
+
+async function setInitialState(addon, initialState) {
+  if (initialState.userDisabled) {
+    await addon.disable();
+  } else if (initialState.userDisabled === false) {
+    await addon.enable();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/test_bootstrap.js
@@ -0,0 +1,1181 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const APP_STARTUP                     = 1;
+const APP_SHUTDOWN                    = 2;
+const ADDON_ENABLE                    = 3;
+const ADDON_DISABLE                   = 4;
+const ADDON_INSTALL                   = 5;
+const ADDON_UNINSTALL                 = 6;
+const ADDON_UPGRADE                   = 7;
+const ADDON_DOWNGRADE                 = 8;
+
+const ID1 = "bootstrap1@tests.mozilla.org";
+const ID2 = "bootstrap2@tests.mozilla.org";
+
+// This verifies that bootstrappable add-ons can be used without restarts.
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+// Enable loading extensions from the user scopes
+Services.prefs.setIntPref("extensions.enabledScopes",
+                          AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_USER);
+
+BootstrapMonitor.init();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const userExtDir = gProfD.clone();
+userExtDir.append("extensions2");
+userExtDir.append(gAppInfo.ID);
+registerDirectory("XREUSysExt", userExtDir.parent);
+
+const ADDONS = {
+  test_bootstrap1_1: {
+    "install.rdf": {
+      id: "bootstrap1@tests.mozilla.org",
+
+      name: "Test Bootstrap 1",
+
+      iconURL: "chrome://foo/skin/icon.png",
+      aboutURL: "chrome://foo/content/about.xul",
+      optionsURL: "chrome://foo/content/options.xul",
+    },
+    "bootstrap.js": BOOTSTRAP_MONITOR_BOOTSTRAP_JS,
+  },
+  test_bootstrap1_2: {
+    "install.rdf": {
+      id: "bootstrap1@tests.mozilla.org",
+      version: "2.0",
+
+      name: "Test Bootstrap 1",
+    },
+    "bootstrap.js": BOOTSTRAP_MONITOR_BOOTSTRAP_JS,
+  },
+  test_bootstrap1_3: {
+    "install.rdf": {
+      id: "bootstrap1@tests.mozilla.org",
+      version: "3.0",
+
+      name: "Test Bootstrap 1",
+
+      targetApplications: [{
+        id: "undefined",
+        minVersion: "1",
+        maxVersion: "1"}],
+    },
+    "bootstrap.js": BOOTSTRAP_MONITOR_BOOTSTRAP_JS,
+  },
+  test_bootstrap2_1: {
+    "install.rdf": {
+      id: "bootstrap2@tests.mozilla.org",
+    },
+    "bootstrap.js": BOOTSTRAP_MONITOR_BOOTSTRAP_JS,
+  },
+};
+
+var testserver = AddonTestUtils.createHttpServer({hosts: ["example.com"]});
+
+const XPIS = {};
+for (let [name, addon] of Object.entries(ADDONS)) {
+  XPIS[name] = AddonTestUtils.createTempXPIFile(addon);
+  testserver.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+}
+
+
+function getStartupReason() {
+  let info = BootstrapMonitor.started.get(ID1);
+  return info ? info.reason : undefined;
+}
+
+function getShutdownReason() {
+  let info = BootstrapMonitor.stopped.get(ID1);
+  return info ? info.reason : undefined;
+}
+
+function getInstallReason() {
+  let info = BootstrapMonitor.installed.get(ID1);
+  return info ? info.reason : undefined;
+}
+
+function getUninstallReason() {
+  let info = BootstrapMonitor.uninstalled.get(ID1);
+  return info ? info.reason : undefined;
+}
+
+function getStartupOldVersion() {
+  let info = BootstrapMonitor.started.get(ID1);
+  return info ? info.data.oldVersion : undefined;
+}
+
+function getShutdownNewVersion() {
+  let info = BootstrapMonitor.stopped.get(ID1);
+  return info ? info.data.newVersion : undefined;
+}
+
+function getInstallOldVersion() {
+  let info = BootstrapMonitor.installed.get(ID1);
+  return info ? info.data.oldVersion : undefined;
+}
+
+function getUninstallNewVersion() {
+  let info = BootstrapMonitor.uninstalled.get(ID1);
+  return info ? info.data.newVersion : undefined;
+}
+
+async function checkBootstrappedPref() {
+  let XPIScope = ChromeUtils.import("resource://gre/modules/addons/XPIProvider.jsm", {});
+
+  let data = new Map();
+  for (let entry of XPIScope.XPIStates.enabledAddons()) {
+    data.set(entry.id, entry);
+  }
+
+  let addons = await AddonManager.getAddonsByTypes(["extension"]);
+  for (let addon of addons) {
+    if (!addon.id.endsWith("@tests.mozilla.org"))
+      continue;
+    if (!addon.isActive)
+      continue;
+    if (addon.operationsRequiringRestart != AddonManager.OP_NEEDS_RESTART_NONE)
+      continue;
+
+    ok(data.has(addon.id));
+    let addonData = data.get(addon.id);
+    data.delete(addon.id);
+
+    equal(addonData.version, addon.version);
+    equal(addonData.type, addon.type);
+    let file = addon.getResourceURI().QueryInterface(Ci.nsIFileURL).file;
+    equal(addonData.path, file.path);
+  }
+  equal(data.size, 0);
+}
+
+add_task(async function run_test() {
+  promiseStartupManager();
+
+  ok(!gExtensionsJSON.exists());
+  ok(!gAddonStartup.exists());
+});
+
+// Tests that installing doesn't require a restart
+add_task(async function test_1() {
+  prepare_test({}, [
+    "onNewInstall",
+  ]);
+
+  let install = await AddonManager.getInstallForFile(XPIS.test_bootstrap1_1);
+  ensure_test_completed();
+
+  notEqual(install, null);
+  equal(install.type, "extension");
+  equal(install.version, "1.0");
+  equal(install.name, "Test Bootstrap 1");
+  equal(install.state, AddonManager.STATE_DOWNLOADED);
+  notEqual(install.addon.syncGUID, null);
+  equal(install.addon.operationsRequiringRestart &
+               AddonManager.OP_NEEDS_RESTART_INSTALL, 0);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+
+  let addon = install.addon;
+
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID1),
+    new Promise(resolve => {
+      prepare_test({
+        [ID1]: [
+          ["onInstalling", false],
+          "onInstalled",
+        ],
+      }, [
+        "onInstallStarted",
+        "onInstallEnded",
+      ], function() {
+        // startup should not have been called yet.
+        BootstrapMonitor.checkAddonNotStarted(ID1);
+        resolve();
+      });
+      install.install();
+    }),
+  ]);
+
+  await checkBootstrappedPref();
+  let installSyncGUID = addon.syncGUID;
+
+  let installs = await AddonManager.getAllInstalls();
+  // There should be no active installs now since the install completed and
+  // doesn't require a restart.
+  equal(installs.length, 0);
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  notEqual(b1.syncGUID, null);
+  equal(b1.syncGUID, installSyncGUID);
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  equal(getStartupReason(), ADDON_INSTALL);
+  equal(getStartupOldVersion(), undefined);
+  do_check_in_crash_annotation(ID1, "1.0");
+
+  let dir = do_get_addon_root_uri(profileDir, ID1);
+  equal(b1.getResourceURI("bootstrap.js").spec, dir + "bootstrap.js");
+});
+
+// Tests that disabling doesn't require a restart
+add_task(async function test_2() {
+  let b1 = await AddonManager.getAddonByID(ID1);
+  prepare_test({
+    [ID1]: [
+      ["onDisabling", false],
+      "onDisabled",
+    ],
+  });
+
+  equal(b1.operationsRequiringRestart &
+        AddonManager.OP_NEEDS_RESTART_DISABLE, 0);
+  await b1.disable();
+  ensure_test_completed();
+
+  await new Promise(executeSoon);
+
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(b1.userDisabled);
+  ok(!b1.isActive);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  equal(getShutdownReason(), ADDON_DISABLE);
+  equal(getShutdownNewVersion(), undefined);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+
+  let newb1 = await AddonManager.getAddonByID(ID1);
+  notEqual(newb1, null);
+  equal(newb1.version, "1.0");
+  ok(!newb1.appDisabled);
+  ok(newb1.userDisabled);
+  ok(!newb1.isActive);
+
+  await checkBootstrappedPref();
+});
+
+// Test that restarting doesn't accidentally re-enable
+add_task(async function test_3() {
+  await promiseShutdownManager();
+
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  equal(getShutdownReason(), ADDON_DISABLE);
+  equal(getShutdownNewVersion(), undefined);
+
+  await promiseStartupManager();
+
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  equal(getShutdownReason(), ADDON_DISABLE);
+  equal(getShutdownNewVersion(), undefined);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+
+  ok(gAddonStartup.exists());
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(b1.userDisabled);
+  ok(!b1.isActive);
+
+  await checkBootstrappedPref();
+});
+
+// Tests that enabling doesn't require a restart
+add_task(async function test_4() {
+  let b1 = await AddonManager.getAddonByID(ID1);
+  prepare_test({
+    [ID1]: [
+      ["onEnabling", false],
+      "onEnabled",
+    ],
+  });
+
+  equal(b1.operationsRequiringRestart &
+               AddonManager.OP_NEEDS_RESTART_ENABLE, 0);
+  await b1.enable();
+  ensure_test_completed();
+
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  equal(getStartupReason(), ADDON_ENABLE);
+  equal(getStartupOldVersion(), undefined);
+  do_check_in_crash_annotation(ID1, "1.0");
+
+  let newb1 = await AddonManager.getAddonByID(ID1);
+  notEqual(newb1, null);
+  equal(newb1.version, "1.0");
+  ok(!newb1.appDisabled);
+  ok(!newb1.userDisabled);
+  ok(newb1.isActive);
+
+  await checkBootstrappedPref();
+});
+
+// Tests that a restart shuts down and restarts the add-on
+add_task(async function test_5() {
+  await promiseShutdownManager();
+  // By the time we've shut down, the database must have been written
+  ok(gExtensionsJSON.exists());
+
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  equal(getShutdownReason(), APP_SHUTDOWN);
+  equal(getShutdownNewVersion(), undefined);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+  await promiseStartupManager();
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  equal(getStartupReason(), APP_STARTUP);
+  equal(getStartupOldVersion(), undefined);
+  do_check_in_crash_annotation(ID1, "1.0");
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+
+  await checkBootstrappedPref();
+});
+
+// Tests that installing an upgrade doesn't require a restart
+add_task(async function test_6() {
+  prepare_test({}, [
+    "onNewInstall",
+  ]);
+
+  let install = await AddonManager.getInstallForFile(XPIS.test_bootstrap1_2);
+  ensure_test_completed();
+
+  notEqual(install, null);
+  equal(install.type, "extension");
+  equal(install.version, "2.0");
+  equal(install.name, "Test Bootstrap 1");
+  equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID1),
+    new Promise(resolve => {
+      prepare_test({
+        [ID1]: [
+          ["onInstalling", false],
+          "onInstalled",
+        ],
+      }, [
+        "onInstallStarted",
+        "onInstallEnded",
+      ], resolve);
+      install.install();
+    }),
+  ]);
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "2.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "2.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "2.0");
+  equal(getStartupReason(), ADDON_UPGRADE);
+  equal(getInstallOldVersion(), 1);
+  equal(getStartupOldVersion(), 1);
+  equal(getShutdownReason(), ADDON_UPGRADE);
+  equal(getShutdownNewVersion(), 2);
+  equal(getUninstallNewVersion(), 2);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+  do_check_in_crash_annotation(ID1, "2.0");
+
+  await checkBootstrappedPref();
+});
+
+// Tests that uninstalling doesn't require a restart
+add_task(async function test_7() {
+  let b1 = await AddonManager.getAddonByID(ID1);
+  prepare_test({
+    [ID1]: [
+      ["onUninstalling", false],
+      "onUninstalled",
+    ],
+  });
+
+  equal(b1.operationsRequiringRestart &
+        AddonManager.OP_NEEDS_RESTART_UNINSTALL, 0);
+  await b1.uninstall();
+
+  await checkBootstrappedPref();
+
+  ensure_test_completed();
+  BootstrapMonitor.checkAddonNotInstalled(ID1);
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  equal(getShutdownReason(), ADDON_UNINSTALL);
+  equal(getShutdownNewVersion(), undefined);
+  do_check_not_in_crash_annotation(ID1, "2.0");
+
+  b1 = await AddonManager.getAddonByID(ID1);
+  equal(b1, null);
+
+  await promiseRestartManager();
+
+  let newb1 = await AddonManager.getAddonByID(ID1);
+  equal(newb1, null);
+
+  await checkBootstrappedPref();
+});
+
+// Test that a bootstrapped extension dropped into the profile loads properly
+// on startup and doesn't cause an EM restart
+add_task(async function test_8() {
+  await promiseShutdownManager();
+
+  await manuallyInstall(XPIS.test_bootstrap1_1, profileDir, ID1);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  equal(getStartupReason(), ADDON_INSTALL);
+  equal(getStartupOldVersion(), undefined);
+  do_check_in_crash_annotation(ID1, "1.0");
+
+  await checkBootstrappedPref();
+});
+
+// Test that items detected as removed during startup get removed properly
+add_task(async function test_9() {
+  await promiseShutdownManager();
+
+  manuallyUninstall(profileDir, ID1);
+  BootstrapMonitor.clear(ID1);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  equal(b1, null);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+
+  await checkBootstrappedPref();
+});
+
+
+// Tests that installing a downgrade sends the right reason
+add_task(async function test_10() {
+  prepare_test({}, [
+    "onNewInstall",
+  ]);
+
+  let install = await AddonManager.getInstallForFile(XPIS.test_bootstrap1_2);
+  ensure_test_completed();
+
+  notEqual(install, null);
+  equal(install.type, "extension");
+  equal(install.version, "2.0");
+  equal(install.name, "Test Bootstrap 1");
+  equal(install.state, AddonManager.STATE_DOWNLOADED);
+  do_check_not_in_crash_annotation(ID1, "2.0");
+
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID1),
+    new Promise(resolve => {
+      prepare_test({
+        [ID1]: [
+          ["onInstalling", false],
+          "onInstalled",
+        ],
+      }, [
+        "onInstallStarted",
+        "onInstallEnded",
+      ], resolve);
+      install.install();
+    }),
+  ]);
+
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "2.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "2.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "2.0");
+  equal(getStartupReason(), ADDON_INSTALL);
+  equal(getStartupOldVersion(), undefined);
+  do_check_in_crash_annotation(ID1, "2.0");
+
+  prepare_test({}, [
+    "onNewInstall",
+  ]);
+
+  install = await AddonManager.getInstallForFile(XPIS.test_bootstrap1_1);
+  ensure_test_completed();
+
+  notEqual(install, null);
+  equal(install.type, "extension");
+  equal(install.version, "1.0");
+  equal(install.name, "Test Bootstrap 1");
+  equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID1),
+    new Promise(resolve => {
+      prepare_test({
+        [ID1]: [
+          ["onInstalling", false],
+          "onInstalled",
+        ],
+      }, [
+        "onInstallStarted",
+        "onInstallEnded",
+      ], resolve);
+      install.install();
+    }),
+  ]);
+
+  b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  equal(getStartupReason(), ADDON_DOWNGRADE);
+  equal(getInstallOldVersion(), 2);
+  equal(getStartupOldVersion(), 2);
+  equal(getShutdownReason(), ADDON_DOWNGRADE);
+  equal(getShutdownNewVersion(), 1);
+  equal(getUninstallNewVersion(), 1);
+  do_check_in_crash_annotation(ID1, "1.0");
+  do_check_not_in_crash_annotation(ID1, "2.0");
+
+  await checkBootstrappedPref();
+});
+
+// Tests that uninstalling a disabled add-on still calls the uninstall method
+add_task(async function test_11() {
+  let b1 = await AddonManager.getAddonByID(ID1);
+  prepare_test({
+    [ID1]: [
+      ["onDisabling", false],
+      "onDisabled",
+      ["onUninstalling", false],
+      "onUninstalled",
+    ],
+  });
+
+  await b1.disable();
+
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  equal(getShutdownReason(), ADDON_DISABLE);
+  equal(getShutdownNewVersion(), undefined);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+
+  await b1.uninstall();
+
+  ensure_test_completed();
+  BootstrapMonitor.checkAddonNotInstalled(ID1);
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  do_check_not_in_crash_annotation(ID1, "1.0");
+
+  await checkBootstrappedPref();
+});
+
+// Tests that bootstrapped extensions are correctly loaded even if the app is
+// upgraded at the same time
+add_task(async function test_12() {
+  await promiseShutdownManager();
+
+  await manuallyInstall(XPIS.test_bootstrap1_1, profileDir, ID1);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  equal(getStartupReason(), ADDON_INSTALL);
+  equal(getStartupOldVersion(), undefined);
+  do_check_in_crash_annotation(ID1, "1.0");
+
+  await b1.uninstall();
+
+  await promiseRestartManager();
+  await checkBootstrappedPref();
+});
+
+
+// Tests that installing a bootstrapped extension with an invalid application
+// entry doesn't call it's startup method
+add_task(async function test_13() {
+  prepare_test({}, [
+    "onNewInstall",
+  ]);
+
+  let install = await AddonManager.getInstallForFile(XPIS.test_bootstrap1_3);
+  ensure_test_completed();
+
+  notEqual(install, null);
+  equal(install.type, "extension");
+  equal(install.version, "3.0");
+  equal(install.name, "Test Bootstrap 1");
+  equal(install.state, AddonManager.STATE_DOWNLOADED);
+  do_check_not_in_crash_annotation(ID1, "3.0");
+
+  await new Promise(resolve => {
+    prepare_test({
+      [ID1]: [
+        ["onInstalling", false],
+        "onInstalled",
+      ],
+    }, [
+      "onInstallStarted",
+      "onInstallEnded",
+    ], resolve);
+    install.install();
+  });
+
+  let installs = await AddonManager.getAllInstalls();
+
+  // There should be no active installs now since the install completed and
+  // doesn't require a restart.
+  equal(installs.length, 0);
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "3.0");
+  ok(b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(!b1.isActive);
+  BootstrapMonitor.checkAddonInstalled(ID1, "3.0"); // We call install even for disabled add-ons
+  BootstrapMonitor.checkAddonNotStarted(ID1); // Should not have called startup though
+  do_check_not_in_crash_annotation(ID1, "3.0");
+
+  await promiseRestartManager();
+
+  b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "3.0");
+  ok(b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(!b1.isActive);
+  BootstrapMonitor.checkAddonInstalled(ID1, "3.0"); // We call install even for disabled add-ons
+  BootstrapMonitor.checkAddonNotStarted(ID1); // Should not have called startup though
+  do_check_not_in_crash_annotation(ID1, "3.0");
+
+  await checkBootstrappedPref();
+  await b1.uninstall();
+});
+
+// Tests that a bootstrapped extension with an invalid target application entry
+// does not get loaded when detected during startup
+add_task(async function test_14() {
+  await promiseRestartManager();
+
+  await promiseShutdownManager();
+
+  await manuallyInstall(XPIS.test_bootstrap1_3, profileDir, ID1);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "3.0");
+  ok(b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(!b1.isActive);
+  BootstrapMonitor.checkAddonInstalled(ID1, "3.0"); // We call install even for disabled add-ons
+  BootstrapMonitor.checkAddonNotStarted(ID1); // Should not have called startup though
+  do_check_not_in_crash_annotation(ID1, "3.0");
+
+  await checkBootstrappedPref();
+  await b1.uninstall();
+});
+
+// Tests that upgrading a disabled bootstrapped extension still calls uninstall
+// and install but doesn't startup the new version
+add_task(async function test_15() {
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID1),
+    promiseInstallFile(XPIS.test_bootstrap1_1),
+  ]);
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+
+  await b1.disable();
+  ok(!b1.isActive);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+
+  prepare_test({}, [
+    "onNewInstall",
+  ]);
+
+  let install = await AddonManager.getInstallForFile(XPIS.test_bootstrap1_2);
+  ensure_test_completed();
+
+  notEqual(install, null);
+  ok(install.addon.userDisabled);
+
+  await new Promise(resolve => {
+    prepare_test({
+      [ID1]: [
+        ["onInstalling", false],
+        "onInstalled",
+      ],
+    }, [
+      "onInstallStarted",
+      "onInstallEnded",
+    ], resolve);
+    install.install();
+  });
+
+  b1 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1, null);
+  equal(b1.version, "2.0");
+  ok(!b1.appDisabled);
+  ok(b1.userDisabled);
+  ok(!b1.isActive);
+  BootstrapMonitor.checkAddonInstalled(ID1, "2.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+
+  await checkBootstrappedPref();
+  await promiseRestartManager();
+
+  let b1_2 = await AddonManager.getAddonByID(ID1);
+  notEqual(b1_2, null);
+  equal(b1_2.version, "2.0");
+  ok(!b1_2.appDisabled);
+  ok(b1_2.userDisabled);
+  ok(!b1_2.isActive);
+  BootstrapMonitor.checkAddonInstalled(ID1, "2.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+
+  await b1_2.uninstall();
+});
+
+// Tests that bootstrapped extensions don't get loaded when in safe mode
+add_task(async function test_16() {
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID1),
+    promiseInstallFile(XPIS.test_bootstrap1_1),
+  ]);
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  // Should have installed and started
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  equal(b1.iconURL, "chrome://foo/skin/icon.png");
+  equal(b1.aboutURL, "chrome://foo/content/about.xul");
+  equal(b1.optionsURL, "chrome://foo/content/options.xul");
+
+  await promiseShutdownManager();
+
+  // Should have stopped
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+
+  gAppInfo.inSafeMode = true;
+  await promiseStartupManager();
+
+  let b1_2 = await AddonManager.getAddonByID(ID1);
+  // Should still be stopped
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  ok(!b1_2.isActive);
+  equal(b1_2.iconURL, null);
+  equal(b1_2.aboutURL, null);
+  equal(b1_2.optionsURL, null);
+
+  await promiseShutdownManager();
+  gAppInfo.inSafeMode = false;
+  await promiseStartupManager();
+
+  // Should have started
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+
+  let b1_3 = await AddonManager.getAddonByID(ID1);
+  await b1_3.uninstall();
+});
+
+// Check that a bootstrapped extension in a non-profile location is loaded
+add_task(async function test_17() {
+  await promiseShutdownManager();
+
+  await manuallyInstall(XPIS.test_bootstrap1_1, userExtDir, ID1);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  // Should have installed and started
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+
+  await checkBootstrappedPref();
+});
+
+// Check that installing a new bootstrapped extension in the profile replaces
+// the existing one
+add_task(async function test_18() {
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID1),
+    promiseInstallFile(XPIS.test_bootstrap1_2),
+  ]);
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  // Should have installed and started
+  BootstrapMonitor.checkAddonInstalled(ID1, "2.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "2.0");
+  notEqual(b1, null);
+  equal(b1.version, "2.0");
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+
+  equal(getShutdownReason(), ADDON_UPGRADE);
+  equal(getUninstallReason(), ADDON_UPGRADE);
+  equal(getInstallReason(), ADDON_UPGRADE);
+  equal(getStartupReason(), ADDON_UPGRADE);
+
+  equal(getShutdownNewVersion(), 2);
+  equal(getUninstallNewVersion(), 2);
+  equal(getInstallOldVersion(), 1);
+  equal(getStartupOldVersion(), 1);
+
+  await checkBootstrappedPref();
+});
+
+// Check that uninstalling the profile version reveals the non-profile one
+add_task(async function test_19() {
+  let b1 = await AddonManager.getAddonByID(ID1);
+  // The revealed add-on gets activated asynchronously
+  await new Promise(resolve => {
+    prepare_test({
+      [ID1]: [
+        ["onUninstalling", false],
+        "onUninstalled",
+        ["onInstalling", false],
+        "onInstalled",
+      ],
+    }, [], resolve);
+
+    b1.uninstall();
+  });
+
+  b1 = await AddonManager.getAddonByID(ID1);
+  // Should have reverted to the older version
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+
+  equal(getShutdownReason(), ADDON_DOWNGRADE);
+  equal(getUninstallReason(), ADDON_DOWNGRADE);
+  equal(getInstallReason(), ADDON_DOWNGRADE);
+  equal(getStartupReason(), ADDON_DOWNGRADE);
+
+  equal(getShutdownNewVersion(), "1.0");
+  equal(getUninstallNewVersion(), "1.0");
+  equal(getInstallOldVersion(), "2.0");
+  equal(getStartupOldVersion(), "2.0");
+
+  await checkBootstrappedPref();
+});
+
+// Check that a new profile extension detected at startup replaces the non-profile
+// one
+add_task(async function test_20() {
+  await promiseShutdownManager();
+
+  await manuallyInstall(XPIS.test_bootstrap1_2, profileDir, ID1);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  // Should have installed and started
+  BootstrapMonitor.checkAddonInstalled(ID1, "2.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "2.0");
+  notEqual(b1, null);
+  equal(b1.version, "2.0");
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+
+  equal(getShutdownReason(), APP_SHUTDOWN);
+  equal(getUninstallReason(), ADDON_UPGRADE);
+  equal(getInstallReason(), ADDON_UPGRADE);
+  equal(getStartupReason(), APP_STARTUP);
+
+  equal(getShutdownNewVersion(), undefined);
+  equal(getUninstallNewVersion(), 2);
+  equal(getInstallOldVersion(), 1);
+  equal(getStartupOldVersion(), undefined);
+});
+
+// Check that a detected removal reveals the non-profile one
+add_task(async function test_21() {
+  await promiseShutdownManager();
+
+  equal(getShutdownReason(), APP_SHUTDOWN);
+  equal(getShutdownNewVersion(), undefined);
+
+  manuallyUninstall(profileDir, ID1);
+  BootstrapMonitor.clear(ID1);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  // Should have installed and started
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+
+  // This won't be set as the bootstrap script was gone so we couldn't
+  // uninstall it properly
+  equal(getUninstallReason(), undefined);
+  equal(getUninstallNewVersion(), undefined);
+
+  equal(getInstallReason(), ADDON_DOWNGRADE);
+  equal(getInstallOldVersion(), 2);
+
+  equal(getStartupReason(), APP_STARTUP);
+  equal(getStartupOldVersion(), undefined);
+
+  await checkBootstrappedPref();
+  await promiseShutdownManager();
+
+  manuallyUninstall(userExtDir, ID1);
+  BootstrapMonitor.clear(ID1);
+
+  await promiseStartupManager();
+});
+
+// Check that an upgrade from the filesystem is detected and applied correctly
+add_task(async function test_22() {
+  await promiseShutdownManager();
+
+  let file = await manuallyInstall(XPIS.test_bootstrap1_1, profileDir, ID1);
+  if (file.isDirectory())
+    file.append("install.rdf");
+
+  // Make it look old so changes are detected
+  setExtensionModifiedTime(file, file.lastModifiedTime - 5000);
+
+  await promiseStartupManager();
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+  // Should have installed and started
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+
+  await promiseShutdownManager();
+
+  equal(getShutdownReason(), APP_SHUTDOWN);
+  equal(getShutdownNewVersion(), undefined);
+
+  manuallyUninstall(profileDir, ID1);
+  BootstrapMonitor.clear(ID1);
+  await manuallyInstall(XPIS.test_bootstrap1_2, profileDir, ID1);
+
+  await promiseStartupManager();
+
+  let b1_2 = await AddonManager.getAddonByID(ID1);
+  // Should have installed and started
+  BootstrapMonitor.checkAddonInstalled(ID1, "2.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "2.0");
+  notEqual(b1_2, null);
+  equal(b1_2.version, "2.0");
+  ok(b1_2.isActive);
+  ok(!b1_2.isSystem);
+
+  // This won't be set as the bootstrap script was gone so we couldn't
+  // uninstall it properly
+  equal(getUninstallReason(), undefined);
+  equal(getUninstallNewVersion(), undefined);
+
+  equal(getInstallReason(), ADDON_UPGRADE);
+  equal(getInstallOldVersion(), 1);
+  equal(getStartupReason(), APP_STARTUP);
+  equal(getStartupOldVersion(), undefined);
+
+  await checkBootstrappedPref();
+  await b1_2.uninstall();
+});
+
+
+// Tests that installing from a URL doesn't require a restart
+add_task(async function test_23() {
+  prepare_test({}, [
+    "onNewInstall",
+  ]);
+
+  let url = "http://example.com/addons/test_bootstrap1_1.xpi";
+  let install = await AddonManager.getInstallForURL(url, "application/x-xpinstall");
+
+  ensure_test_completed();
+
+  notEqual(install, null);
+
+  await new Promise(resolve => {
+    prepare_test({}, [
+      "onDownloadStarted",
+      "onDownloadEnded",
+    ], function() {
+      equal(install.type, "extension");
+      equal(install.version, "1.0");
+      equal(install.name, "Test Bootstrap 1");
+      equal(install.state, AddonManager.STATE_DOWNLOADED);
+      equal(install.addon.operationsRequiringRestart &
+                   AddonManager.OP_NEEDS_RESTART_INSTALL, 0);
+      do_check_not_in_crash_annotation(ID1, "1.0");
+
+      prepare_test({
+        [ID1]: [
+          ["onInstalling", false],
+          "onInstalled",
+        ],
+      }, [
+        "onInstallStarted",
+        "onInstallEnded",
+      ], resolve);
+    });
+    install.install();
+  });
+
+  await checkBootstrappedPref();
+
+  let installs = await AddonManager.getAllInstalls();
+
+  // There should be no active installs now since the install completed and
+  // doesn't require a restart.
+  equal(installs.length, 0);
+
+  let b1 = await AddonManager.getAddonByID(ID1);
+
+  notEqual(b1, null);
+  equal(b1.version, "1.0");
+  ok(!b1.appDisabled);
+  ok(!b1.userDisabled);
+  ok(b1.isActive);
+  ok(!b1.isSystem);
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  equal(getStartupReason(), ADDON_INSTALL);
+  equal(getStartupOldVersion(), undefined);
+  do_check_in_crash_annotation(ID1, "1.0");
+
+  let dir = do_get_addon_root_uri(profileDir, ID1);
+  equal(b1.getResourceURI("bootstrap.js").spec, dir + "bootstrap.js");
+
+  await promiseRestartManager();
+
+  let b1_2 = await AddonManager.getAddonByID(ID1);
+  await b1_2.uninstall();
+});
+
+// Tests that we recover from a broken preference
+add_task(async function test_24() {
+  info("starting 24");
+
+  await Promise.all([
+    BootstrapMonitor.promiseAddonStartup(ID2),
+    promiseInstallAllFiles([XPIS.test_bootstrap1_1, XPIS.test_bootstrap2_1]),
+  ]);
+
+  info("test 24 got prefs");
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  BootstrapMonitor.checkAddonInstalled(ID2, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID2, "1.0");
+
+  await promiseRestartManager();
+
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  BootstrapMonitor.checkAddonInstalled(ID2, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID2, "1.0");
+
+  await promiseShutdownManager();
+
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID1);
+  BootstrapMonitor.checkAddonInstalled(ID2, "1.0");
+  BootstrapMonitor.checkAddonNotStarted(ID2);
+
+  // Break the JSON.
+  let data = aomStartup.readStartupData();
+  data["app-profile"].addons[ID1].path += "foo";
+
+  await OS.File.writeAtomic(gAddonStartup.path,
+                            new TextEncoder().encode(JSON.stringify(data)),
+                            {compression: "lz4"});
+
+  await promiseStartupManager();
+
+  BootstrapMonitor.checkAddonInstalled(ID1, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID1, "1.0");
+  BootstrapMonitor.checkAddonInstalled(ID2, "1.0");
+  BootstrapMonitor.checkAddonStarted(ID2, "1.0");
+});
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/test_bootstrap_const.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const ADDONS = {
+  test_bootstrap_const: {
+    "install.rdf": {
+      "id": "bootstrap@tests.mozilla.org",
+    },
+    "bootstrap.js": "ChromeUtils.import(\"resource://gre/modules/Services.jsm\");\n\nconst install = function() {\n  Services.obs.notifyObservers(null, \"addon-install\");\n};\n",
+  },
+};
+
+add_task(async function() {
+  await promiseStartupManager();
+
+  let sawInstall = false;
+  Services.obs.addObserver(function() {
+    sawInstall = true;
+  }, "addon-install");
+
+  await AddonTestUtils.promiseInstallXPI(ADDONS.test_bootstrap_const);
+
+  ok(sawInstall);
+});
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/test_bootstrap_globals.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that bootstrap.js has the expected globals defined
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const ADDONS = {
+  bootstrap_globals: {
+    "install.rdf": {
+      "id": "bootstrap_globals@tests.mozilla.org",
+    },
+    "bootstrap.js": String.raw`ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var seenGlobals = new Set();
+var scope = this;
+function checkGlobal(name, type) {
+  if (scope[name] && typeof(scope[name]) == type)
+    seenGlobals.add(name);
+}
+
+var wrapped = {};
+Services.obs.notifyObservers({ wrappedJSObject: wrapped }, "bootstrap-request-globals");
+for (let [name, type] of wrapped.expectedGlobals) {
+  checkGlobal(name, type);
+}
+
+function startup(data, reason) {
+  Services.obs.notifyObservers({ wrappedJSObject: seenGlobals }, "bootstrap-seen-globals");
+}
+
+function install(data, reason) {}
+function shutdown(data, reason) {}
+function uninstall(data, reason) {}
+`,
+  },
+};
+
+
+const EXPECTED_GLOBALS = [
+  ["console", "object"],
+];
+
+async function run_test() {
+  do_test_pending();
+  await promiseStartupManager();
+  let sawGlobals = false;
+
+  Services.obs.addObserver(function(subject) {
+    subject.wrappedJSObject.expectedGlobals = EXPECTED_GLOBALS;
+  }, "bootstrap-request-globals");
+
+  Services.obs.addObserver(function({ wrappedJSObject: seenGlobals }) {
+    for (let [name ] of EXPECTED_GLOBALS)
+      Assert.ok(seenGlobals.has(name));
+
+    sawGlobals = true;
+  }, "bootstrap-seen-globals");
+
+  await AddonTestUtils.promiseInstallXPI(ADDONS.bootstrap_globals);
+  Assert.ok(sawGlobals);
+  await promiseShutdownManager();
+  do_test_finished();
+}
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/test_bootstrapped_chrome_manifest.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ADDON = {
+  "install.rdf": {
+    "id": "bug675371@tests.mozilla.org",
+  },
+  "chrome.manifest": `content bug675371 .`,
+  "test.js": `var active = true;`,
+};
+
+add_task(async function run_test() {
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+  await promiseStartupManager();
+});
+
+function checkActive(expected) {
+  let target = { active: false };
+  let load = () => {
+    Services.scriptloader.loadSubScript("chrome://bug675371/content/test.js", target);
+  };
+
+  if (expected) {
+    load();
+  } else {
+    Assert.throws(load, /Error opening input stream/);
+  }
+  equal(target.active, expected, "Manifest is active?");
+}
+
+add_task(async function test() {
+  let {addon} = await AddonTestUtils.promiseInstallXPI(ADDON);
+
+  Assert.ok(addon.isActive);
+
+  // Tests that chrome.manifest is registered when the addon is installed.
+  checkActive(true);
+
+  await addon.disable();
+  checkActive(false);
+
+  await addon.enable();
+  checkActive(true);
+
+  await promiseShutdownManager();
+
+  // Tests that chrome.manifest remains registered at app shutdown.
+  checkActive(true);
+});
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/test_invalid_install_rdf.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that side-loaded extensions with invalid install.rdf files are
+// not initialized at startup.
+
+const APP_ID = "xpcshell@tests.mozilla.org";
+
+Services.prefs.setIntPref("extensions.enabledScopes", AddonManager.SCOPE_USER);
+
+createAppInfo(APP_ID, "XPCShell", "1", "1.9.2");
+
+const userAppDir = AddonTestUtils.profileDir.clone();
+userAppDir.append("app-extensions");
+userAppDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+AddonTestUtils.registerDirectory("XREUSysExt", userAppDir);
+
+const userExtensions = userAppDir.clone();
+userExtensions.append(APP_ID);
+userExtensions.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+  ChromeRegistry: ["@mozilla.org/chrome/chrome-registry;1", "nsIChromeRegistry"],
+});
+
+function hasChromeEntry(package) {
+  try {
+    void ChromeRegistry.convertChromeURL(Services.io.newURI(`chrome://${package}/content/`));
+    return true;
+  } catch (e) {
+    return false;
+  }
+}
+
+add_task(async function() {
+  await promiseWriteInstallRDFToXPI({
+    id: "langpack-foo@addons.mozilla.org",
+    version: "1.0",
+    type: 8,
+    targetApplications: [{
+      id: "xpcshell@tests.mozilla.org",
+      minVersion: "1",
+      maxVersion: "1",
+    }],
+    name: "Invalid install.rdf extension",
+  }, userExtensions, undefined, {
+    "chrome.manifest": `
+      content foo-langpack ./
+    `,
+  });
+
+  await promiseWriteInstallRDFToXPI({
+    id: "foo@addons.mozilla.org",
+    version: "1.0",
+    bootstrap: true,
+    targetApplications: [{
+      id: "xpcshell@tests.mozilla.org",
+      minVersion: "1",
+      maxVersion: "1",
+    }],
+    name: "Invalid install.rdf extension",
+  }, userExtensions, undefined, {
+    "chrome.manifest": `
+      content foo ./
+    `,
+  });
+
+  await promiseWriteInstallRDFToXPI({
+    id: "foo-legacy-legacy@addons.mozilla.org",
+    version: "1.0",
+    bootstrap: false,
+    targetApplications: [{
+      id: "xpcshell@tests.mozilla.org",
+      minVersion: "1",
+      maxVersion: "1",
+    }],
+    name: "Invalid install.rdf extension",
+  }, userExtensions, undefined, {
+    "chrome.manifest": `
+      content foo-legacy-legacy ./
+    `,
+  });
+
+  equal(hasChromeEntry("foo-langpack"), false,
+        "Should not have registered foo-langpack resource before AOM startup");
+  equal(hasChromeEntry("foo-legacy-legacy"), false,
+        "Should not have registered foo-legacy-legacy resource before AOM startup");
+  equal(hasChromeEntry("foo"), false,
+        "Should not have registered foo resource before AOM startup");
+
+  await promiseStartupManager();
+
+  equal(hasChromeEntry("foo-langpack"), false,
+        "Should not have registered chrome manifest for invalid extension");
+  equal(hasChromeEntry("foo-legacy-legacy"), false,
+        "Should not have registered chrome manifest for non-restartless extension");
+  equal(hasChromeEntry("foo"), true,
+        "Should have registered chrome manifest for valid extension");
+
+  await promiseRestartManager();
+
+  equal(hasChromeEntry("foo-langpack"), false,
+        "Should still not have registered chrome manifest for invalid extension after restart");
+  equal(hasChromeEntry("foo-legacy-legacy"), false,
+        "Should still not have registered chrome manifest for non-restartless extension");
+  equal(hasChromeEntry("foo"), true,
+        "Should still have registered chrome manifest for valid extension after restart");
+
+  await promiseShutdownManager();
+
+  userAppDir.remove(true);
+});
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/test_manifest.js
@@ -0,0 +1,752 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This tests that all properties are read from the install manifests and that
+// items are correctly enabled/disabled based on them (blocklist tests are
+// elsewhere)
+
+const ADDONS = [
+  {
+    "install.rdf": {
+      id: "addon1@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      aboutURL: "chrome://test/content/about.xul",
+      iconURL: "chrome://test/skin/icon.png",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 1",
+      description: "Test Description",
+      creator: "Test Creator",
+      homepageURL: "http://www.example.com",
+      developer: [
+        "Test Developer 1",
+        "Test Developer 2",
+      ],
+      translator: [
+        "Test Translator 1",
+        "Test Translator 2",
+      ],
+      contributor: [
+        "Test Contributor 1",
+        "Test Contributor 2",
+      ],
+    },
+
+    expected: {
+      id: "addon1@tests.mozilla.org",
+      type: "extension",
+      version: "1.0",
+      optionsType: null,
+      aboutURL: "chrome://test/content/about.xul",
+      iconURL: "chrome://test/skin/icon.png",
+      icons: {32: "chrome://test/skin/icon.png", 48: "chrome://test/skin/icon.png"},
+      name: "Test Addon 1",
+      description: "Test Description",
+      creator: "Test Creator",
+      homepageURL: "http://www.example.com",
+      developers: ["Test Developer 1", "Test Developer 2"],
+      translators: ["Test Translator 1", "Test Translator 2"],
+      contributors: ["Test Contributor 1", "Test Contributor 2"],
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+      providesUpdatesSecurely: true,
+      blocklistState: Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon2@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      updateURL: "https://www.foo.com",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 2",
+    },
+
+    expected: {
+      id: "addon2@tests.mozilla.org",
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      providesUpdatesSecurely: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon3@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      updateURL: "http://www.foo.com",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 3",
+    },
+
+    expected: {
+      id: "addon3@tests.mozilla.org",
+      isActive: false,
+      userDisabled: false,
+      appDisabled: true,
+      providesUpdatesSecurely: false,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon4@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      updateURL: "http://www.foo.com",
+      updateKey: "foo",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 4",
+    },
+
+    expected: {
+      id: "addon4@tests.mozilla.org",
+      isActive: false,
+      userDisabled: false,
+      appDisabled: true,
+      providesUpdatesSecurely: false,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon5@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "*",
+      }],
+      name: "Test Addon 5",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon6@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "0",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 6",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon7@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "0",
+        maxVersion: "0",
+      }],
+      name: "Test Addon 7",
+    },
+
+    expected: {
+      isActive: false,
+      userDisabled: false,
+      appDisabled: true,
+      isCompatible: false,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon8@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1.1",
+        maxVersion: "*",
+      }],
+      name: "Test Addon 8",
+    },
+
+    expected: {
+      isActive: false,
+      userDisabled: false,
+      appDisabled: true,
+      isCompatible: false,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon9@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "1.9.2",
+        maxVersion: "1.9.*",
+      }],
+      name: "Test Addon 9",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon10@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "1.9.2.1",
+        maxVersion: "1.9.*",
+      }],
+      name: "Test Addon 10",
+    },
+
+    expected: {
+      isActive: false,
+      userDisabled: false,
+      appDisabled: true,
+      isCompatible: false,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon11@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "1.9",
+        maxVersion: "1.9.2",
+      }],
+      name: "Test Addon 11",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon12@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "1.9",
+        maxVersion: "1.9.1.*",
+      }],
+      name: "Test Addon 12",
+    },
+
+    expected: {
+      isActive: false,
+      userDisabled: false,
+      appDisabled: true,
+      isCompatible: false,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon13@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "1.9",
+        maxVersion: "1.9.*",
+      }, {
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "0",
+        maxVersion: "0.5",
+      }],
+      name: "Test Addon 13",
+    },
+
+    expected: {
+      isActive: false,
+      userDisabled: false,
+      appDisabled: true,
+      isCompatible: false,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon14@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "1.9",
+        maxVersion: "1.9.1",
+      }, {
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 14",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon15@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      updateKey: "foo",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 15",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+      providesUpdatesSecurely: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon16@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      updateKey: "foo",
+      updateURL: "https://www.foo.com",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 16",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+      providesUpdatesSecurely: true,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon17@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsURL: "chrome://test/content/options.xul",
+      optionsType: "2",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 17",
+    },
+
+    // An obsolete optionsType means the add-on isn't registered.
+    expected: null,
+  },
+
+  {
+    "install.rdf": {
+      id: "addon18@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 18",
+    },
+    extraFiles: {"options.xul": ""},
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+      optionsURL: null,
+      optionsType: null,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon19@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsType: "99",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 19",
+    },
+
+    expected: null,
+  },
+
+  {
+    "install.rdf": {
+      id: "addon20@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsURL: "chrome://test/content/options.xul",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 20",
+    },
+
+    // Even with a defined optionsURL optionsType is null by default.
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+      optionsURL: "chrome://test/content/options.xul",
+      optionsType: null,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon21@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsType: "3",
+      optionsURL: "chrome://test/content/options.xul",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 21",
+    },
+
+    expected: {
+      isActive: true,
+      userDisabled: false,
+      appDisabled: false,
+      isCompatible: true,
+      optionsURL: "chrome://test/content/options.xul",
+      optionsType: AddonManager.OPTIONS_TYPE_TAB,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon22@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsType: "2",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 22",
+    },
+
+    // An obsolete optionsType means the add-on isn't registered.
+    expected: null,
+  },
+
+  {
+    "install.rdf": {
+      id: "addon23@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsType: "2",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 23",
+    },
+    extraFiles: {"options.xul": ""},
+
+    // An obsolete optionsType means the add-on isn't registered.
+    expected: null,
+  },
+
+  {
+    "install.rdf": {
+      id: "addon24@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 24",
+    },
+    extraFiles: {"options.xul": ""},
+
+    expected: {
+      optionsType: null,
+      optionsURL: null,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon25@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsType: "3",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 25",
+    },
+
+    expected: {
+      optionsType: null,
+      optionsURL: null,
+    },
+  },
+
+  {
+    "install.rdf": {
+      id: "addon26@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      optionsType: "4",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      name: "Test Addon 26",
+    },
+    extraFiles: {"options.xul": ""},
+    expected: null,
+  },
+
+  // Tests compatibility based on target platforms.
+
+  // No targetPlatforms so should be compatible
+  {
+    "install.rdf": {
+      id: "tp-addon1@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      name: "Test 1",
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+    },
+
+    expected: {
+      appDisabled: false,
+      isPlatformCompatible: true,
+      isActive: true,
+    },
+  },
+
+  // Matches the OS
+  {
+    "install.rdf": {
+      id: "tp-addon2@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      name: "Test 2",
+      targetPlatforms: [
+        "XPCShell",
+        "WINNT_x86",
+        "XPCShell",
+      ],
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+    },
+
+    expected: {
+      appDisabled: false,
+      isPlatformCompatible: true,
+      isActive: true,
+    },
+  },
+
+  // Matches the OS and ABI
+  {
+    "install.rdf": {
+      id: "tp-addon3@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      name: "Test 3",
+      targetPlatforms: [
+        "WINNT",
+        "XPCShell_noarch-spidermonkey",
+      ],
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+    },
+
+    expected: {
+      appDisabled: false,
+      isPlatformCompatible: true,
+      isActive: true,
+    },
+  },
+
+  // Doesn't match
+  {
+    "install.rdf": {
+      id: "tp-addon4@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      name: "Test 4",
+      targetPlatforms: [
+        "WINNT_noarch-spidermonkey",
+        "Darwin",
+        "WINNT_noarch-spidermonkey",
+      ],
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+    },
+
+    expected: {
+      appDisabled: true,
+      isPlatformCompatible: false,
+      isActive: false,
+    },
+  },
+
+  // Matches the OS but since a different entry specifies ABI this doesn't match.
+  {
+    "install.rdf": {
+      id: "tp-addon5@tests.mozilla.org",
+      version: "1.0",
+      bootstrap: true,
+      name: "Test 5",
+      targetPlatforms: [
+        "XPCShell",
+        "XPCShell_foo",
+      ],
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+    },
+
+    expected: {
+      appDisabled: true,
+      isPlatformCompatible: false,
+      isActive: false,
+    },
+  },
+];
+
+const IDS = ADDONS.map(a => a["install.rdf"].id);
+
+add_task(async function setup() {
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+  const profileDir = gProfD.clone();
+  profileDir.append("extensions");
+
+  for (let addon of ADDONS) {
+    await promiseWriteInstallRDFForExtension(addon["install.rdf"], profileDir, undefined, addon.extraFiles);
+  }
+});
+
+add_task(async function test_values() {
+  await promiseStartupManager();
+
+  let addons = await getAddons(IDS);
+
+  for (let addon of ADDONS) {
+    let {id} = addon["install.rdf"];
+    checkAddon(id, addons.get(id), addon.expected);
+  }
+
+  await promiseShutdownManager();
+});
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/test_manifest_locales.js
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+const ID = "bug397778@tests.mozilla.org";
+
+const ADDON = {
+  id: "bug397778@tests.mozilla.org",
+  version: "1.0",
+  name: "Fallback Name",
+  description: "Fallback Description",
+  bootstrap: true,
+
+  targetApplications: [{
+    id: "xpcshell@tests.mozilla.org",
+    minVersion: "1",
+    maxVersion: "1"}],
+
+  localized: [
+    {
+      locale: ["fr"],
+      name: "fr Name",
+      description: "fr Description",
+    },
+    {
+      locale: ["de-DE"],
+      name: "Deutsches W\u00f6rterbuch",
+    },
+    {
+      locale: ["es-ES"],
+      name: "es-ES Name",
+      description: "es-ES Description",
+    },
+    {
+      locale: ["zh-TW"],
+      name: "zh-TW Name",
+      description: "zh-TW Description",
+    },
+    {
+      locale: ["zh-CN"],
+      name: "zh-CN Name",
+      description: "zh-CN Description",
+    },
+    {
+      locale: ["en-GB"],
+      name: "en-GB Name",
+      description: "en-GB Description",
+    },
+    {
+      locale: ["en"],
+      name: "en Name",
+      description: "en Description",
+    },
+    {
+      locale: ["en-CA"],
+      name: "en-CA Name",
+      description: "en-CA Description",
+    },
+  ],
+};
+
+add_task(async function setup() {
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1");
+  Services.locale.requestedLocales = ["fr-FR"];
+
+  await promiseStartupManager();
+  await promiseInstallXPI(ADDON);
+});
+
+add_task(async function test_1() {
+  let addon = await AddonManager.getAddonByID(ID);
+  Assert.notEqual(addon, null);
+  Assert.equal(addon.name, "fr Name");
+  Assert.equal(addon.description, "fr Description");
+
+  await addon.disable();
+  await promiseRestartManager();
+
+  let newAddon = await AddonManager.getAddonByID(ID);
+  Assert.notEqual(newAddon, null);
+  Assert.equal(newAddon.name, "fr Name");
+});
+
+add_task(async function test_2() {
+  // Change locale. The more specific de-DE is the best match
+  await restartWithLocales(["de"]);
+
+  let addon = await AddonManager.getAddonByID(ID);
+  Assert.notEqual(addon, null);
+  Assert.equal(addon.name, "Deutsches W\u00f6rterbuch");
+  Assert.equal(addon.description, null);
+});
+
+add_task(async function test_3() {
+  // Change locale. Locale case should have no effect
+  await restartWithLocales(["DE-de"]);
+
+  let addon = await AddonManager.getAddonByID(ID);
+  Assert.notEqual(addon, null);
+  Assert.equal(addon.name, "Deutsches W\u00f6rterbuch");
+  Assert.equal(addon.description, null);
+});
+
+add_task(async function test_4() {
+  // Change locale. es-ES should closely match
+  await restartWithLocales(["es-AR"]);
+
+  let addon = await AddonManager.getAddonByID(ID);
+  Assert.notEqual(addon, null);
+  Assert.equal(addon.name, "es-ES Name");
+  Assert.equal(addon.description, "es-ES Description");
+});
+
+add_task(async function test_5() {
+  // Change locale. Either zh-CN or zh-TW could match
+  await restartWithLocales(["zh"]);
+
+  let addon = await AddonManager.getAddonByID(ID);
+  Assert.notEqual(addon, null);
+  ok(addon.name == "zh-TW Name" || addon.name == "zh-CN Name",
+     `Add-on name mismatch: ${addon.name}`);
+});
+
+add_task(async function test_6() {
+  // Unknown locale should try to match against en-US as well. Of en,en-GB
+  // en should match as being less specific
+  await restartWithLocales(["nl-NL"]);
+
+  let addon = await AddonManager.getAddonByID(ID);
+  Assert.notEqual(addon, null);
+  Assert.equal(addon.name, "en Name");
+  Assert.equal(addon.description, "en Description");
+});
new file mode 100644
--- /dev/null
+++ b/common/test/xpcshell/xpcshell.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+tags = addons
+head = head_addons.js
+support-files =
+  data/**
+
+[test_bootstrap.js]
+[test_bootstrap_const.js]
+[test_bootstrap_globals.js]
+[test_bootstrapped_chrome_manifest.js]
+[test_invalid_install_rdf.js]
+[test_manifest.js]
+[test_manifest_locales.js]
--- a/mail/components/mailGlue.js
+++ b/mail/components/mailGlue.js
@@ -101,16 +101,20 @@ MailGlue.prototype = {
 
     ExtensionSupport.unregisterWindowListener("Thunderbird-internal-Toolbox");
     ExtensionSupport.unregisterWindowListener("Thunderbird-internal-BrowserConsole");
   },
 
   // nsIObserver implementation
   observe(aSubject, aTopic, aData) {
     switch (aTopic) {
+    case "app-startup":
+      ChromeUtils.import("resource:///modules/BootstrapLoader.jsm");
+      AddonManager.addExternalExtensionLoader(BootstrapLoader);
+      break;
     case "xpcom-shutdown":
       this._dispose();
       break;
     case "final-ui-startup":
       this._onProfileStartup();
       break;
     case "mail-startup-done":
       this._onMailStartupDone();