Bug 1280083: Support dependencies for bootstrapped add-ons. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Tue, 14 Jun 2016 14:44:50 +0100
changeset 378162 b579273620fe14f62ac7534c07ddee4e3432be4a
parent 378038 5003caf3aa25dad509e53c68d2ee39de58fe5ec5
child 523478 3f4c37a9f7e5fc103f4822e8b2a2989a50a6d7ef
push id20947
push usermaglione.k@gmail.com
push dateWed, 15 Jun 2016 08:45:38 +0000
reviewersaswan
bugs1280083
milestone50.0a1
Bug 1280083: Support dependencies for bootstrapped add-ons. r?aswan MozReview-Commit-ID: ACmsUcKZ2Jp
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/internal/XPIProviderUtils.js
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -732,16 +732,26 @@ function isUsableAddon(aAddon) {
     return true;
 
   if (AddonManager.checkUpdateSecurity && !aAddon.providesUpdatesSecurely)
     return false;
 
   if (!aAddon.isPlatformCompatible)
     return false;
 
+  if (aAddon.dependencies.length) {
+    let isActive = id => {
+      let active = XPIProvider.activeAddons.get(id);
+      return active && !active.disable;
+    };
+
+    if (aAddon.dependencies.some(id => !isActive(id)))
+      return false;
+  }
+
   if (AddonManager.checkCompatibility) {
     if (!aAddon.isCompatible)
       return false;
   }
   else {
     let app = aAddon.matchingTargetApplication;
     if (!app)
       return false;
@@ -772,16 +782,17 @@ function EM_R(aProperty) {
 
 function createAddonDetails(id, aAddon) {
   return {
     id: id || aAddon.id,
     type: aAddon.type,
     version: aAddon.version,
     multiprocessCompatible: aAddon.multiprocessCompatible,
     runInSafeMode: aAddon.runInSafeMode,
+    dependencies: aAddon.dependencies,
   };
 }
 
 /**
  * Converts an internal add-on type to the type presented through the API.
  *
  * @param  aType
  *         The internal add-on type
@@ -1153,16 +1164,25 @@ function loadManifestFromRDF(aUri, aStre
   let targets = ds.GetTargets(root, EM_R("localized"), true);
   while (targets.hasMoreElements()) {
     let target = targets.getNext().QueryInterface(Ci.nsIRDFResource);
     let locale = readLocale(ds, target, false, seenLocales);
     if (locale)
       addon.locales.push(locale);
   }
 
+  let dependencies = new Set();
+  targets = ds.GetTargets(root, EM_R("dependency"), true);
+  while (targets.hasMoreElements()) {
+    let target = targets.getNext().QueryInterface(Ci.nsIRDFResource);
+    let id = getRDFProperty(ds, target, "id");
+    dependencies.add(id);
+  }
+  addon.dependencies = Object.freeze(Array.from(dependencies));
+
   let seenApplications = [];
   addon.targetApplications = [];
   targets = ds.GetTargets(root, EM_R("targetApplication"), true);
   while (targets.hasMoreElements()) {
     let target = targets.getNext().QueryInterface(Ci.nsIRDFResource);
     let targetAppInfo = {};
     for (let prop of PROP_TARGETAPP) {
       targetAppInfo[prop] = getRDFProperty(ds, target, prop);
@@ -2409,16 +2429,44 @@ this.XPIProvider = {
   _telemetryDetails: {},
   // A Map from an add-on install to its ID
   _addonFileMap: new Map(),
   // Flag to know if ToolboxProcess.jsm has already been loaded by someone or not
   _toolboxProcessLoaded: false,
   // Have we started shutting down bootstrap add-ons?
   _closing: false,
 
+  sortBootstrappedAddons: function() {
+    let addons = {};
+
+    // Sort the list of IDs so that the ordering is deterministic.
+    for (let id of Object.keys(this.bootstrappedAddons).sort()) {
+      addons[id] = Object.assign({id}, this.bootstrappedAddons[id]);
+    }
+
+    let res = new Set();
+    let seen = new Set();
+
+    let add = addon => {
+      seen.add(addon.id);
+
+      for (let id of addon.dependencies || []) {
+        if (id in addons && !seen.has(id)) {
+          add(addons[id]);
+        }
+      }
+
+      res.add(addon.id);
+    }
+
+    Object.values(addons).forEach(add);
+
+    return Array.from(res, id => addons[id]);
+  },
+
   /*
    * Set a value in the telemetry hash for a given ID
    */
   setTelemetry: function(aId, aName, aValue) {
     if (!this._telemetryDetails[aId])
       this._telemetryDetails[aId] = {};
     this._telemetryDetails[aId][aName] = aValue;
   },
@@ -2745,66 +2793,67 @@ this.XPIProvider = {
           Services.appinfo.annotateCrashReport("EMCheckCompatibility",
                                                AddonManager.checkCompatibility);
         } catch (e) { }
         this.addAddonsToCrashReporter();
       }
 
       try {
         AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_begin");
-        for (let id in this.bootstrappedAddons) {
+
+        for (let addon of this.sortBootstrappedAddons()) {
           try {
             let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
-            file.persistentDescriptor = this.bootstrappedAddons[id].descriptor;
+            file.persistentDescriptor = addon.descriptor;
             let reason = BOOTSTRAP_REASONS.APP_STARTUP;
             // Eventually set INSTALLED reason when a bootstrap addon
             // is dropped in profile folder and automatically installed
             if (AddonManager.getStartupChanges(AddonManager.STARTUP_CHANGE_INSTALLED)
-                            .indexOf(id) !== -1)
+                            .indexOf(addon.id) !== -1)
               reason = BOOTSTRAP_REASONS.ADDON_INSTALL;
-            this.callBootstrapMethod(createAddonDetails(id, this.bootstrappedAddons[id]),
+            this.callBootstrapMethod(createAddonDetails(addon.id, addon),
                                      file, "startup", reason);
           }
           catch (e) {
-            logger.error("Failed to load bootstrap addon " + id + " from " +
-                  this.bootstrappedAddons[id].descriptor, e);
+            logger.error("Failed to load bootstrap addon " + addon.id + " from " +
+                         addon.descriptor, e);
           }
         }
         AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_end");
       }
       catch (e) {
         logger.error("bootstrap startup failed", e);
         AddonManagerPrivate.recordException("XPI-BOOTSTRAP", "startup failed", e);
       }
 
       // Let these shutdown a little earlier when they still have access to most
       // of XPCOM
       Services.obs.addObserver({
         observe: function(aSubject, aTopic, aData) {
           XPIProvider._closing = true;
-          for (let id in XPIProvider.bootstrappedAddons) {
+          for (let addon of XPIProvider.sortBootstrappedAddons().reverse()) {
             // If no scope has been loaded for this add-on then there is no need
             // to shut it down (should only happen when a bootstrapped add-on is
             // pending enable)
-            if (!XPIProvider.activeAddons.has(id))
+            if (!XPIProvider.activeAddons.has(addon.id))
               continue;
 
             let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
-            file.persistentDescriptor = XPIProvider.bootstrappedAddons[id].descriptor;
-            let addon = createAddonDetails(id, XPIProvider.bootstrappedAddons[id]);
+            file.persistentDescriptor = addon.descriptor;
+            let addonDetails = createAddonDetails(addon.id, addon);
 
             // If the add-on was pending disable then shut it down and remove it
             // from the persisted data.
-            if (XPIProvider.bootstrappedAddons[id].disable) {
-              XPIProvider.callBootstrapMethod(addon, file, "shutdown",
+            if (addon.disable) {
+              XPIProvider.callBootstrapMethod(addonDetails, file, "shutdown",
                                               BOOTSTRAP_REASONS.ADDON_DISABLE);
-              delete XPIProvider.bootstrappedAddons[id];
+              delete XPIProvider.bootstrappedAddons[addon.id];
             }
             else {
-              XPIProvider.callBootstrapMethod(addon, file, "shutdown",
+              XPIProvider.callBootstrapMethod(addonDetails, file, "shutdown",
                                               BOOTSTRAP_REASONS.APP_SHUTDOWN);
             }
           }
           Services.obs.removeObserver(this, "quit-application-granted");
         }
       }, "quit-application-granted", false);
 
       // Detect final-ui-startup for telemetry reporting
@@ -3588,16 +3637,21 @@ this.XPIProvider = {
    * Imports the xpinstall permissions from preferences into the permissions
    * manager for the user to change later.
    */
   importPermissions: function() {
     PermissionsUtils.importFromPrefs(PREF_XPI_PERMISSIONS_BRANCH,
                                      XPI_PERMISSION);
   },
 
+  getDependentAddons: function(aAddon) {
+    return Array.from(XPIDatabase.getAddons())
+                .filter(addon => addon.dependencies.includes(aAddon.id));
+  },
+
   /**
    * Checks for any changes that have occurred since the last time the
    * application was launched.
    *
    * @param  aAppChanged
    *         A tri-state value. Undefined means the current profile was created
    *         for this session, true means the profile already existed but was
    *         last used with an application with a different version number,
@@ -4558,27 +4612,31 @@ this.XPIProvider = {
    * @param  aVersion
    *         The add-on's version
    * @param  aType
    *         The type for the add-on
    * @param  aMultiprocessCompatible
    *         Boolean indicating whether the add-on is compatible with electrolysis.
    * @param  aRunInSafeMode
    *         Boolean indicating whether the add-on can run in safe mode.
+   * @param  aDependencies
+   *         An array of add-on IDs on which this add-on depends.
    * @return a JavaScript scope
    */
   loadBootstrapScope: function(aId, aFile, aVersion, aType,
-                               aMultiprocessCompatible, aRunInSafeMode) {
+                               aMultiprocessCompatible, aRunInSafeMode,
+                               aDependencies) {
     // Mark the add-on as active for the crash reporter before loading
     this.bootstrappedAddons[aId] = {
       version: aVersion,
       type: aType,
       descriptor: aFile.persistentDescriptor,
       multiprocessCompatible: aMultiprocessCompatible,
       runInSafeMode: aRunInSafeMode,
+      dependencies: aDependencies,
     };
     this.persistBootstrappedAddons();
     this.addAddonsToCrashReporter();
 
     this.activeAddons.set(aId, {
       debugGlobal: null,
       safeWrapper: null,
       bootstrapScope: null,
@@ -4722,17 +4780,17 @@ this.XPIProvider = {
     }
 
     try {
       // Load the scope if it hasn't already been loaded
       let activeAddon = this.activeAddons.get(aAddon.id);
       if (!activeAddon) {
         this.loadBootstrapScope(aAddon.id, aFile, aAddon.version, aAddon.type,
                                 aAddon.multiprocessCompatible || false,
-                                runInSafeMode);
+                                runInSafeMode, aAddon.dependencies);
         activeAddon = this.activeAddons.get(aAddon.id);
       }
 
       if (aMethod == "startup" || aMethod == "shutdown") {
         if (!aExtraParams) {
           aExtraParams = {};
         }
         aExtraParams["instanceID"] = this.activeAddons.get(aAddon.id).instanceID;
@@ -4752,16 +4810,25 @@ this.XPIProvider = {
         // That will be logged below.
       }
 
       if (!method) {
         logger.warn("Add-on " + aAddon.id + " is missing bootstrap method " + aMethod);
         return;
       }
 
+      // Extensions are automatically deinitialized in the correct order at shutdown.
+      if (aMethod == "shutdown" && aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
+        activeAddon.disable = true;
+        for (let addon of this.getDependentAddons(aAddon)) {
+          if (addon.active)
+            this.updateAddonDisabledState(addon);
+        }
+      }
+
       let params = {
         id: aAddon.id,
         version: aAddon.version,
         installPath: aFile.clone(),
         resourceURI: getURIForResourceInFile(aFile, "")
       };
 
       if (aExtraParams) {
@@ -4775,16 +4842,22 @@ this.XPIProvider = {
       try {
         method(params, aReason);
       }
       catch (e) {
         logger.warn("Exception running bootstrap method " + aMethod + " on " + aAddon.id, e);
       }
     }
     finally {
+      // Extensions are automatically initialized in the correct order at startup.
+      if (aMethod == "startup" && aReason != BOOTSTRAP_REASONS.APP_STARTUP) {
+        for (let addon of this.getDependentAddons(aAddon))
+          this.updateAddonDisabledState(addon);
+      }
+
       if (CHROME_TYPES.has(aAddon.type) && aMethod == "shutdown" && aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
         logger.debug("Removing manifest for " + aFile.path);
         Components.manager.removeBootstrappedManifestLocation(aFile);
 
         let manifest = getURIForResourceInFile(aFile, "chrome.manifest");
         for (let line of ChromeManifestParser.parseSync(manifest)) {
           if (line.type == "resource") {
             ResProtocolHandler.setSubstitution(line.args[0], null);
@@ -4885,16 +4958,17 @@ this.XPIProvider = {
       else {
         needsRestart = this.enableRequiresRestart(aAddon);
         AddonManagerPrivate.callAddonListeners("onEnabling", wrapper,
                                                needsRestart);
       }
 
       if (!needsRestart) {
         XPIDatabase.updateAddonActive(aAddon, !isDisabled);
+
         if (isDisabled) {
           if (aAddon.bootstrap) {
             this.callBootstrapMethod(aAddon, aAddon._sourceBundle, "shutdown",
                                      BOOTSTRAP_REASONS.ADDON_DISABLE);
             this.unloadBootstrapScope(aAddon.id);
           }
           AddonManagerPrivate.callAddonListeners("onDisabled", wrapper);
         }
@@ -4914,16 +4988,17 @@ this.XPIProvider = {
         }
         else {
           this.bootstrappedAddons[aAddon.id] = {
             version: aAddon.version,
             type: aAddon.type,
             descriptor: aAddon._sourceBundle.persistentDescriptor,
             multiprocessCompatible: aAddon.multiprocessCompatible,
             runInSafeMode: canRunInSafeMode(aAddon),
+            dependencies: aAddon.dependencies,
           };
           this.persistBootstrappedAddons();
         }
       }
     }
 
     // Sync with XPIStates.
     let xpiState = XPIStates.getAddon(aAddon.location, aAddon.id);
@@ -6793,16 +6868,17 @@ AddonInternal.prototype = {
   userDisabled: false,
   appDisabled: false,
   softDisabled: false,
   sourceURI: null,
   releaseNotesURI: null,
   foreignInstall: false,
   seen: true,
   skinnable: false,
+  dependencies: Object.freeze([]),
 
   get selectedLocale() {
     if (this._selectedLocale)
       return this._selectedLocale;
     let locale = Locale.findClosestLocale(this.locales);
     this._selectedLocale = locale ? locale : this.defaultLocale;
     return this._selectedLocale;
   },
@@ -7564,17 +7640,17 @@ function defineAddonWrapperProperty(name
     get: getter,
     enumerable: true,
   });
 }
 
 ["id", "syncGUID", "version", "isCompatible", "isPlatformCompatible",
  "providesUpdatesSecurely", "blocklistState", "blocklistURL", "appDisabled",
  "softDisabled", "skinnable", "size", "foreignInstall", "hasBinaryComponents",
- "strictCompatibility", "compatibilityOverrides", "updateURL",
+ "strictCompatibility", "compatibilityOverrides", "updateURL", "dependencies",
  "getDataDirectory", "multiprocessCompatible", "signedState"].forEach(function(aProp) {
    defineAddonWrapperProperty(aProp, function() {
      let addon = addonFor(this);
      return (aProp in addon) ? addon[aProp] : undefined;
    });
 });
 
 ["fullDescription", "developerComments", "eula", "supportURL",
--- a/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
+++ b/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
@@ -81,17 +81,17 @@ const PROP_JSON_FIELDS = ["id", "syncGUI
                           "optionsType", "aboutURL", "icons", "iconURL", "icon64URL",
                           "defaultLocale", "visible", "active", "userDisabled",
                           "appDisabled", "pendingUninstall", "descriptor", "installDate",
                           "updateDate", "applyBackgroundUpdates", "bootstrap",
                           "skinnable", "size", "sourceURI", "releaseNotesURI",
                           "softDisabled", "foreignInstall", "hasBinaryComponents",
                           "strictCompatibility", "locales", "targetApplications",
                           "targetPlatforms", "multiprocessCompatible", "signedState",
-                          "seen"];
+                          "seen", "dependencies"];
 
 // Properties that should be migrated where possible from an old database. These
 // shouldn't include properties that can be read directly from install.rdf files
 // or calculated
 const DB_MIGRATE_METADATA= ["installDate", "userDisabled", "softDisabled",
                             "sourceURI", "applyBackgroundUpdates",
                             "releaseNotesURI", "foreignInstall", "syncGUID"];
 
@@ -325,16 +325,20 @@ function copyRowProperties(aRow, aProper
  * @param aLoaded
  *        Addon data fields loaded from JSON or the addon manifest.
  */
 function DBAddonInternal(aLoaded) {
   AddonInternal.call(this);
 
   copyProperties(aLoaded, PROP_JSON_FIELDS, this);
 
+  if (!this.dependencies)
+    this.dependencies = [];
+  Object.freeze(this.dependencies);
+
   if (aLoaded._installLocation) {
     this._installLocation = aLoaded._installLocation;
     this.location = aLoaded._installLocation.name;
   }
   else if (aLoaded.location) {
     this._installLocation = XPIProvider.installLocationsByName[this.location];
   }
 
@@ -2147,16 +2151,17 @@ this.XPIDatabaseReconcile = {
       // Make sure the bootstrap information is up to date for this ID
       if (currentAddon.bootstrap && currentAddon.active) {
         XPIProvider.bootstrappedAddons[id] = {
           version: currentAddon.version,
           type: currentAddon.type,
           descriptor: currentAddon._sourceBundle.persistentDescriptor,
           multiprocessCompatible: currentAddon.multiprocessCompatible,
           runInSafeMode: canRunInSafeMode(currentAddon),
+          dependencies: currentAddon.dependencies,
         };
       }
 
       if (currentAddon.active && currentAddon.internalName == XPIProvider.selectedSkin)
         sawActiveTheme = true;
     }
 
     // Pass over the set of previously visible add-ons that have now gone away
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -1,16 +1,18 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
 var AM_Cc = Components.classes;
 var AM_Ci = Components.interfaces;
 var AM_Cu = Components.utils;
 
+AM_Cu.importGlobalProperties(["TextEncoder"]);
+
 const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1";
 const CERTDB_CID = Components.ID("{fb0bbc5c-452e-4783-b32c-80124693d871}");
 
 const PREF_EM_CHECK_UPDATE_SECURITY   = "extensions.checkUpdateSecurity";
 const PREF_EM_STRICT_COMPATIBILITY    = "extensions.strictCompatibility";
 const PREF_EM_MIN_COMPAT_APP_VERSION      = "extensions.minCompatibleAppVersion";
 const PREF_EM_MIN_COMPAT_PLATFORM_VERSION = "extensions.minCompatiblePlatformVersion";
 const PREF_GETADDONS_BYIDS               = "extensions.getAddons.get.url";
@@ -1022,16 +1024,22 @@ function createInstallRDF(aData) {
           rdf += "<em:locale>" + escapeXML(aLocaleName) + "</em:locale>\n";
         });
       }
       rdf += writeLocaleStrings(aLocalized);
       rdf += "</Description></em:localized>\n";
     });
   }
 
+  if ("dependencies" in aData) {
+    aData.dependencies.forEach(function(aDependency) {
+      rdf += `<em:dependency><Description em:id="${escapeXML(aDependency)}"/></em:dependency>\n`;
+    });
+  }
+
   rdf += "</Description>\n</RDF>\n";
   return rdf;
 }
 
 /**
  * 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,
@@ -1184,64 +1192,93 @@ function writeInstallRDFToXPI(aData, aDi
   var file = aDir.clone();
   file.append(id + ".xpi");
   writeInstallRDFToXPIFile(aData, file, aExtraFile);
 
   return file;
 }
 
 /**
+ * Writes the given data to a file in the given zip file.
+ *
+ * @param   aFile
+ *          The zip file to write to.
+ * @param   aFiles
+ *          An object containing filenames and the data to write to the
+ *          corresponding paths in the zip file.
+ * @param   aFlags
+ *          Additional flags to open the file with.
+ */
+function writeFilesToZip(aFile, aFiles, aFlags = 0) {
+  var zipW = AM_Cc["@mozilla.org/zipwriter;1"].createInstance(AM_Ci.nsIZipWriter);
+  zipW.open(aFile, FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | aFlags);
+
+  for (let path of Object.keys(aFiles)) {
+    let data = aFiles[path];
+    if (!(data instanceof ArrayBuffer)) {
+      data = new TextEncoder("utf-8").encode(data).buffer;
+    }
+
+    let stream = AM_Cc["@mozilla.org/io/arraybuffer-input-stream;1"]
+      .createInstance(AM_Ci.nsIArrayBufferInputStream);
+    stream.setData(data, 0, data.byteLength);
+
+    // Note these files are being created in the XPI archive with date "0" which is 1970-01-01.
+    zipW.addEntryStream(path, 0, AM_Ci.nsIZipWriter.COMPRESSION_NONE,
+                        stream, false);
+  }
+
+  zipW.close();
+}
+
+/**
  * Writes an install.rdf manifest into an XPI file 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   aFile
  *          The XPI file to write to. Any existing file will be overwritten
  * @param   aExtraFile
  *          An optional dummy file to create in the extension
  */
 function writeInstallRDFToXPIFile(aData, aFile, aExtraFile) {
-  var rdf = createInstallRDF(aData);
-  var stream = AM_Cc["@mozilla.org/io/string-input-stream;1"].
-               createInstance(AM_Ci.nsIStringInputStream);
-  stream.setData(rdf, -1);
-  var zipW = AM_Cc["@mozilla.org/zipwriter;1"].
-             createInstance(AM_Ci.nsIZipWriter);
-  zipW.open(aFile, FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | FileUtils.MODE_TRUNCATE);
-  // Note these files are being created in the XPI archive with date "0" which is 1970-01-01.
-  zipW.addEntryStream("install.rdf", 0, AM_Ci.nsIZipWriter.COMPRESSION_NONE,
-                      stream, false);
-  if (aExtraFile)
-    zipW.addEntryStream(aExtraFile, 0, AM_Ci.nsIZipWriter.COMPRESSION_NONE,
-                        stream, false);
-  zipW.close();
+  let files = {
+    "install.rdf": createInstallRDF(aData),
+  };
+
+  if (typeof aExtraFile == "object")
+    Object.assign(files, aExtraFile);
+  else if (aExtraFile)
+    files[aExtraFile] = "";
+
+  writeFilesToZip(aFile, files, FileUtils.MODE_TRUNCATE);
 }
 
 var temp_xpis = [];
 /**
  * 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) {
+function createTempXPIFile(aData, aExtraFile) {
   var file = gTmpD.clone();
   file.append("foo.xpi");
   do {
     file.leafName = Math.floor(Math.random() * 1000000) + ".xpi";
   } while (file.exists());
 
   temp_xpis.push(file);
-  writeInstallRDFToXPIFile(aData, file);
+  writeInstallRDFToXPIFile(aData, file, aExtraFile);
   return file;
 }
 
 /**
  * Creates an XPI file for some WebExtension data in the temporary directory and
  * returns the nsIFile for it. The file will be deleted when the test completes.
  *
  * @param   aData
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+startupManager();
+
+const BOOTSTRAP = String.raw`
+  Components.utils.import("resource://gre/modules/Services.jsm");
+
+  function startup(data) {
+    Services.obs.notifyObservers(null, "test-addon-bootstrap-startup", data.id);
+  }
+  function shutdown(data) {
+    Services.obs.notifyObservers(null, "test-addon-bootstrap-shutdown", data.id);
+  }
+  function install() {}
+  function uninstall() {}
+`;
+
+const ADDONS = [
+  {
+    id: "addon1@dependency-test.mozilla.org",
+    dependencies: ["addon2@dependency-test.mozilla.org"],
+  },
+  {
+    id: "addon2@dependency-test.mozilla.org",
+    dependencies: ["addon3@dependency-test.mozilla.org"],
+  },
+  {
+    id: "addon3@dependency-test.mozilla.org",
+  },
+  {
+    id: "addon4@dependency-test.mozilla.org",
+  },
+  {
+    id: "addon5@dependency-test.mozilla.org",
+    dependencies: ["addon2@dependency-test.mozilla.org"],
+  },
+];
+
+let addonFiles = [];
+
+let events = [];
+add_task(function* setup() {
+  let startupObserver = (subject, topic, data) => {
+    events.push(["startup", data]);
+  };
+  let shutdownObserver = (subject, topic, data) => {
+    events.push(["shutdown", data]);
+  };
+
+  Services.obs.addObserver(startupObserver, "test-addon-bootstrap-startup", false);
+  Services.obs.addObserver(shutdownObserver, "test-addon-bootstrap-shutdown", false);
+  do_register_cleanup(() => {
+    Services.obs.removeObserver(startupObserver, "test-addon-bootstrap-startup");
+    Services.obs.removeObserver(shutdownObserver, "test-addon-bootstrap-shutdown");
+  });
+
+  for (let addon of ADDONS) {
+    Object.assign(addon, {
+      targetApplications: [{
+        id: "xpcshell@tests.mozilla.org",
+        minVersion: "1",
+        maxVersion: "1",
+      }],
+      version: "1.0",
+      name: addon.id,
+      bootstrap: true,
+    });
+
+    addonFiles.push(createTempXPIFile(addon, {"bootstrap.js": BOOTSTRAP}));
+
+    let dir = AM_Cc["@mozilla.org/file/local;1"].createInstance(AM_Ci.nsIFile);
+    dir.initWithPath("/home/kris");
+    // addonFiles[addonFiles.length - 1].copyTo(dir, null);
+  }
+});
+
+add_task(function*() {
+  deepEqual(events, [], "Should have no events");
+
+  yield promiseInstallAllFiles([addonFiles[3]]);
+
+  deepEqual(events, [
+    ["startup", ADDONS[3].id],
+  ]);
+
+  events.length = 0;
+
+  yield promiseInstallAllFiles([addonFiles[0]]);
+  deepEqual(events, [], "Should have no events");
+
+  yield promiseInstallAllFiles([addonFiles[1]]);
+  deepEqual(events, [], "Should have no events");
+
+  yield promiseInstallAllFiles([addonFiles[2]]);
+
+  deepEqual(events, [
+    ["startup", ADDONS[2].id],
+    ["startup", ADDONS[1].id],
+    ["startup", ADDONS[0].id],
+  ]);
+
+  events.length = 0;
+
+  yield promiseInstallAllFiles([addonFiles[2]]);
+
+  deepEqual(events, [
+    ["shutdown", ADDONS[0].id],
+    ["shutdown", ADDONS[1].id],
+    ["shutdown", ADDONS[2].id],
+
+    ["startup", ADDONS[2].id],
+    ["startup", ADDONS[1].id],
+    ["startup", ADDONS[0].id],
+  ]);
+
+  events.length = 0;
+
+  yield promiseInstallAllFiles([addonFiles[4]]);
+
+  deepEqual(events, [
+    ["startup", ADDONS[4].id],
+  ]);
+
+  events.length = 0;
+
+  yield promiseRestartManager();
+
+  deepEqual(events, [
+    ["shutdown", ADDONS[4].id],
+    ["shutdown", ADDONS[3].id],
+    ["shutdown", ADDONS[0].id],
+    ["shutdown", ADDONS[1].id],
+    ["shutdown", ADDONS[2].id],
+
+    ["startup", ADDONS[2].id],
+    ["startup", ADDONS[1].id],
+    ["startup", ADDONS[0].id],
+    ["startup", ADDONS[3].id],
+    ["startup", ADDONS[4].id],
+  ]);
+});
+
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -32,11 +32,12 @@ skip-if = appname != "firefox"
 [test_system_reset.js]
 [test_XPIcancel.js]
 [test_XPIStates.js]
 [test_temporary.js]
 [test_proxies.js]
 [test_proxy.js]
 [test_pass_symbol.js]
 [test_delay_update.js]
+[test_dependencies.js]
 
 
 [include:xpcshell-shared.ini]