/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/
 */

/* eslint "mozilla/no-aArgs": 1 */
/* eslint "no-unused-vars": [2, {"argsIgnorePattern": "^_", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}] */
/* eslint "semi": [2, "always"] */
/* eslint "valid-jsdoc": [2, {requireReturn: false}] */

const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1";

import {
  AddonManager,
  AddonManagerPrivate,
} from "resource://gre/modules/AddonManager.sys.mjs";
import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ExtensionAddonObserver: "resource://gre/modules/Extension.sys.mjs",
  ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
  FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
  Management: "resource://gre/modules/Extension.sys.mjs",
  MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",

  XPCShellContentUtils:
    "resource://testing-common/XPCShellContentUtils.sys.mjs",

  getAppInfo: "resource://testing-common/AppInfo.sys.mjs",
  updateAppInfo: "resource://testing-common/AppInfo.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetters(lazy, {
  aomStartup: [
    "@mozilla.org/addons/addon-manager-startup;1",
    "amIAddonManagerStartup",
  ],
});

const ArrayBufferInputStream = Components.Constructor(
  "@mozilla.org/io/arraybuffer-input-stream;1",
  "nsIArrayBufferInputStream",
  "setData"
);

const nsFile = Components.Constructor(
  "@mozilla.org/file/local;1",
  "nsIFile",
  "initWithPath"
);

const ZipReader = Components.Constructor(
  "@mozilla.org/libjar/zip-reader;1",
  "nsIZipReader",
  "open"
);

const ZipWriter = Components.Constructor(
  "@mozilla.org/zipwriter;1",
  "nsIZipWriter",
  "open"
);

function isRegExp(val) {
  return val && typeof val === "object" && typeof val.test === "function";
}

class MockBarrier {
  constructor(name) {
    this.name = name;
    this.blockers = [];
  }

  addBlocker(name, blocker, options) {
    this.blockers.push({ name, blocker, options });
  }

  async trigger() {
    await Promise.all(
      this.blockers.map(async ({ blocker, name }) => {
        try {
          if (typeof blocker == "function") {
            await blocker();
          } else {
            await blocker;
          }
        } catch (e) {
          Cu.reportError(e);
          dump(
            `Shutdown blocker '${name}' for ${this.name} threw error: ${e} :: ${e.stack}\n`
          );
        }
      })
    );

    this.blockers = [];
  }
}

// Mock out AddonManager's reference to the AsyncShutdown module so we can shut
// down AddonManager from the test
export var MockAsyncShutdown = {
  profileBeforeChange: new MockBarrier("profileBeforeChange"),
  profileChangeTeardown: new MockBarrier("profileChangeTeardown"),
  quitApplicationGranted: new MockBarrier("quitApplicationGranted"),
  // We can use the real Barrier
  Barrier: AsyncShutdown.Barrier,
};

AddonManagerPrivate.overrideAsyncShutdown(MockAsyncShutdown);

class AddonsList {
  constructor(file) {
    this.extensions = [];
    this.themes = [];
    this.xpis = [];

    if (!file.exists()) {
      return;
    }

    let data = lazy.aomStartup.readStartupData();

    for (let loc of Object.values(data)) {
      let dir = loc.path && new nsFile(loc.path);

      for (let addon of Object.values(loc.addons)) {
        let file;
        if (dir) {
          file = dir.clone();
          try {
            file.appendRelativePath(addon.path);
          } catch (e) {
            file = new nsFile(addon.path);
          }
        } else if (addon.path) {
          file = new nsFile(addon.path);
        }

        if (!file) {
          continue;
        }

        this.xpis.push(file);

        if (addon.enabled) {
          addon.type = addon.type || "extension";

          if (addon.type == "theme") {
            this.themes.push(file);
          } else {
            this.extensions.push(file);
          }
        }
      }
    }
  }

  hasItem(type, dir, id) {
    var path = dir.clone();
    path.append(id);

    var xpiPath = dir.clone();
    xpiPath.append(`${id}.xpi`);

    return this[type].some(file => {
      if (!file.exists()) {
        throw new Error(
          `Non-existent path found in addonStartup.json: ${file.path}`
        );
      }

      if (file.isDirectory()) {
        return file.equals(path);
      }
      if (file.isFile()) {
        return file.equals(xpiPath);
      }
      return false;
    });
  }

  hasTheme(dir, id) {
    return this.hasItem("themes", dir, id);
  }

  hasExtension(dir, id) {
    return this.hasItem("extensions", dir, id);
  }
}

// The number of resetXPIExports calls.
//
// This is added to the URL of the modules once resetXPIExports is called,
// so that they become different module instances for each reset, and also the
// suffix is not used outside of tests.
let resetXPIExportsCount = 0;

// Reset all properties of XPIExports to lazy getters, with new module URIs,
// in order to simulate the shutdown+restart situation.
function resetXPIExports(XPIExports) {
  resetXPIExportsCount++;

  const suffix = "?" + resetXPIExportsCount;

  // The list of lazy getters should be in sync with XPIExports.sys.mjs.
  //
  // eslint-disable-next-line mozilla/lazy-getter-object-name
  ChromeUtils.defineESModuleGetters(XPIExports, {
    // XPIDatabase.sys.mjs
    AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
    BuiltInThemesHelpers:
      "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
    XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
    XPIDatabaseReconcile:
      "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,

    // XPIInstall.sys.mjs
    UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
    XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
    verifyBundleSignedState:
      "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,

    // XPIProvider.sys.mjs
    XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
    XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
  });
}

export var AddonTestUtils = {
  addonIntegrationService: null,
  addonsList: null,
  appInfo: null,
  addonStartup: null,
  collectedTelemetryEvents: [],
  testScope: null,
  testUnpacked: false,
  useRealCertChecks: false,
  usePrivilegedSignatures: true,
  certSignatureDate: null,
  overrideEntry: null,

  maybeInit(testScope) {
    if (this.testScope != testScope) {
      this.init(testScope);
    }
  },

  init(testScope, enableLogging = true) {
    if (this.testScope === testScope) {
      return;
    }
    this.testScope = testScope;

    // Get the profile directory for tests to use.
    this.profileDir = testScope.do_get_profile();

    this.profileExtensions = this.profileDir.clone();
    this.profileExtensions.append("extensions");

    this.addonStartup = this.profileDir.clone();
    this.addonStartup.append("addonStartup.json.lz4");

    // Register a temporary directory for the tests.
    this.tempDir = this.profileDir.clone();
    this.tempDir.append("temp");
    this.tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
    this.registerDirectory("TmpD", this.tempDir);

    // Create a replacement app directory for the tests.
    const appDirForAddons = this.profileDir.clone();
    appDirForAddons.append("appdir-addons");
    appDirForAddons.create(
      Ci.nsIFile.DIRECTORY_TYPE,
      FileUtils.PERMS_DIRECTORY
    );
    this.registerDirectory("XREAddonAppDir", appDirForAddons);

    // Enable more extensive EM logging.
    if (enableLogging) {
      Services.prefs.setBoolPref("extensions.logging.enabled", true);
    }

    // By default only load extensions from the profile install location
    Services.prefs.setIntPref(
      "extensions.enabledScopes",
      AddonManager.SCOPE_PROFILE
    );

    // By default don't disable add-ons from any scope
    Services.prefs.setIntPref("extensions.autoDisableScopes", 0);

    // And scan for changes at startup
    Services.prefs.setIntPref("extensions.startupScanScopes", 15);

    // By default, don't cache add-ons in AddonRepository.sys.mjs
    Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", false);

    // Point update checks to the local machine for fast failures
    Services.prefs.setCharPref(
      "extensions.update.url",
      "http://127.0.0.1/updateURL"
    );
    Services.prefs.setCharPref(
      "extensions.update.background.url",
      "http://127.0.0.1/updateBackgroundURL"
    );
    Services.prefs.setCharPref(
      "services.settings.server",
      "data:,#remote-settings-dummy/v1"
    );

    // By default ignore bundled add-ons
    Services.prefs.setBoolPref("extensions.installDistroAddons", false);

    // Ensure signature checks are enabled by default
    Services.prefs.setBoolPref("xpinstall.signatures.required", true);

    // Make sure that a given path does not exist
    function pathShouldntExist(file) {
      if (file.exists()) {
        throw new Error(
          `Test cleanup: path ${file.path} exists when it should not`
        );
      }
    }

    testScope.registerCleanupFunction(() => {
      // Force a GC to ensure that anything holding a ref to temp file releases it.
      // XXX This shouldn't be needed here, since cleanupTempXPIs() does a GC if
      // something fails; see bug 1761255
      this.info(`Force a GC`);
      Cu.forceGC();

      this.cleanupTempXPIs();

      let ignoreEntries = new Set();
      {
        // FileTestUtils lazily creates a directory to hold the temporary files
        // it creates. If that directory exists, ignore it.
        let { value } = Object.getOwnPropertyDescriptor(
          lazy.FileTestUtils,
          "_globalTemporaryDirectory"
        );
        if (value) {
          ignoreEntries.add(value.leafName);
        }
      }

      // Check that the temporary directory is empty
      var entries = [];
      for (let { leafName } of this.iterDirectory(this.tempDir)) {
        if (!ignoreEntries.has(leafName)) {
          entries.push(leafName);
        }
      }
      if (entries.length) {
        throw new Error(
          `Found unexpected files in temporary directory: ${entries.join(", ")}`
        );
      }

      try {
        appDirForAddons.remove(true);
      } catch (ex) {
        testScope.info(`Got exception removing addon app dir: ${ex}`);
      }

      // ensure no leftover files in the system addon upgrade location
      let featuresDir = this.profileDir.clone();
      featuresDir.append("features");
      // upgrade directories will be in UUID folders under features/
      for (let dir of this.iterDirectory(featuresDir)) {
        dir.append("stage");
        pathShouldntExist(dir);
      }

      // ensure no leftover files in the user addon location
      let testDir = this.profileDir.clone();
      testDir.append("extensions");
      testDir.append("trash");
      pathShouldntExist(testDir);

      testDir.leafName = "staged";
      pathShouldntExist(testDir);

      return this.promiseShutdownManager();
    });
  },

  initMochitest(testScope) {
    if (this.testScope === testScope) {
      return;
    }
    this.testScope = testScope;

    this.profileDir = FileUtils.getDir("ProfD", []);

    this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]);

    this.tempDir = FileUtils.getDir("TmpD", []);
    this.tempDir.append("addons-mochitest");
    this.tempDir.createUnique(
      Ci.nsIFile.DIRECTORY_TYPE,
      FileUtils.PERMS_DIRECTORY
    );

    testScope.registerCleanupFunction(() => {
      // Defer testScope cleanup until the last cleanup function has run.
      testScope.registerCleanupFunction(() => {
        this.testScope = null;
      });
      this.cleanupTempXPIs();
      try {
        this.tempDir.remove(true);
      } catch (e) {
        Cu.reportError(e);
      }
    });
  },

  getXPIExports() {
    return ChromeUtils.importESModule(
      "resource://gre/modules/addons/XPIExports.sys.mjs"
    ).XPIExports;
  },

  getWeakSignatureInstallPrefName() {
    return this.getXPIExports().XPIInstall.getWeakSignatureInstallPrefName();
  },

  setWeakSignatureInstallAllowed(allowed) {
    const prefName = this.getWeakSignatureInstallPrefName();
    let cleanupCalled = false;
    const cleanup = () => {
      if (cleanupCalled) {
        return;
      }
      this.testScope.info(
        `=== clear ${prefName} pref value set by this test file ===`
      );
      Services.prefs.clearUserPref(prefName);
      cleanupCalled = true;
    };
    this.testScope.registerCleanupFunction(cleanup);
    this.testScope.info(`=== set ${prefName} pref value to ${allowed} ===`);
    Services.prefs.setBoolPref(prefName, allowed);
    return cleanup;
  },

  /**
   * Iterates over the entries in a given directory.
   *
   * Fails silently if the given directory does not exist.
   *
   * @param {nsIFile} dir
   *        Directory to iterate.
   */
  *iterDirectory(dir) {
    let dirEnum;
    try {
      dirEnum = dir.directoryEntries;
      let file;
      while ((file = dirEnum.nextFile)) {
        yield file;
      }
    } catch (e) {
      if (dir.exists()) {
        Cu.reportError(e);
      }
    } finally {
      if (dirEnum) {
        dirEnum.close();
      }
    }
  },

  /**
   * Creates a new HttpServer for testing, and begins listening on the
   * specified port. Automatically shuts down the server when the test
   * unit ends.
   *
   * @param {object} [options = {}]
   *        The options object.
   * @param {integer} [options.port = -1]
   *        The port to listen on. If omitted, listen on a random
   *        port. The latter is the preferred behavior.
   * @param {sequence<string>?} [options.hosts = null]
   *        A set of hosts to accept connections to. Support for this is
   *        implemented using a proxy filter.
   *
   * @returns {HttpServer}
   *        The HTTP server instance.
   */
  createHttpServer(...args) {
    lazy.XPCShellContentUtils.ensureInitialized(this.testScope);
    return lazy.XPCShellContentUtils.createHttpServer(...args);
  },

  registerJSON(...args) {
    return lazy.XPCShellContentUtils.registerJSON(...args);
  },

  info(msg) {
    // info() for mochitests, do_print for xpcshell.
    let print = this.testScope.info || this.testScope.do_print;
    print(msg);
  },

  cleanupTempXPIs() {
    let didGC = false;

    for (let file of this.tempXPIs.splice(0)) {
      if (file.exists()) {
        try {
          Services.obs.notifyObservers(file, "flush-cache-entry");
          file.remove(false);
        } catch (e) {
          if (didGC) {
            Cu.reportError(`Failed to remove ${file.path}: ${e}`);
          } else {
            // Bug 1606684 - Sometimes XPI files are still in use by a process
            // after the test has been finished. Force a GC once and try again.
            this.info(`Force a GC`);
            Cu.forceGC();
            didGC = true;

            try {
              file.remove(false);
            } catch (e) {
              Cu.reportError(`Failed to remove ${file.path} after GC: ${e}`);
            }
          }
        }
      }
    }
  },

  createAppInfo(ID, name, version, platformVersion = "1.0") {
    lazy.updateAppInfo({
      ID,
      name,
      version,
      platformVersion,
      crashReporter: true,
    });
    this.appInfo = lazy.getAppInfo();
  },

  getManifestURI(file) {
    if (file.isDirectory()) {
      file.leafName = "manifest.json";
      if (file.exists()) {
        return NetUtil.newURI(file);
      }

      throw new Error("No manifest file present");
    }

    let zip = ZipReader(file);
    try {
      let uri = NetUtil.newURI(file);

      if (zip.hasEntry("manifest.json")) {
        return NetUtil.newURI(`jar:${uri.spec}!/manifest.json`);
      }

      throw new Error("No manifest file present");
    } finally {
      zip.close();
    }
  },

  getIDFromExtension(file) {
    return this.getIDFromManifest(this.getManifestURI(file));
  },

  async getIDFromManifest(manifestURI) {
    let body = await fetch(manifestURI.spec);
    let manifest = await body.json();
    try {
      if (manifest.browser_specific_settings?.gecko?.id) {
        return manifest.browser_specific_settings.gecko.id;
      }
      return manifest.applications.gecko.id;
    } catch (e) {
      // IDs for WebExtensions are extracted from the certificate when
      // not present in the manifest, so just generate a random one.
      return Services.uuid.generateUUID().number;
    }
  },

  overrideCertDB() {
    let verifyCert = async (file, result, signatureInfos, callback) => {
      if (
        result == Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED &&
        !this.useRealCertChecks &&
        callback.wrappedJSObject
      ) {
        // Bypassing XPConnect allows us to create a fake x509 certificate from JS
        callback = callback.wrappedJSObject;

        try {
          let id;
          try {
            let manifestURI = this.getManifestURI(file);
            id = await this.getIDFromManifest(manifestURI);
          } catch (err) {
            if (file.leafName.endsWith(".xpi")) {
              id = file.leafName.slice(0, -4);
            }
          }

          let fakeCert = { commonName: id };
          if (this.usePrivilegedSignatures) {
            let privileged =
              typeof this.usePrivilegedSignatures == "function"
                ? this.usePrivilegedSignatures(id)
                : this.usePrivilegedSignatures;
            if (privileged === "system") {
              fakeCert.organizationalUnit = "Mozilla Components";
            } else if (privileged) {
              fakeCert.organizationalUnit = "Mozilla Extensions";
            }
          }
          if (this.certSignatureDate) {
            // addon.signedDate is derived from this, used by the blocklist.
            fakeCert.validity = {
              notBefore: this.certSignatureDate * 1000,
            };
          }

          return [
            callback,
            Cr.NS_OK,
            [
              {
                signerCert: fakeCert,
                signatureAlgorithm: Ci.nsIAppSignatureInfo.COSE_WITH_SHA256,
              },
            ],
          ];
        } catch (e) {
          // If there is any error then just pass along the original results
        } finally {
          // Make sure to close the open zip file or it will be locked.
          if (file.isFile()) {
            Services.obs.notifyObservers(
              file,
              "flush-cache-entry",
              "cert-override"
            );
          }
        }
      }

      return [callback, result, signatureInfos];
    };

    let FakeCertDB = {
      init() {
        for (let property of Object.keys(
          this._genuine.QueryInterface(Ci.nsIX509CertDB)
        )) {
          if (property in this) {
            continue;
          }

          if (typeof this._genuine[property] == "function") {
            this[property] = this._genuine[property].bind(this._genuine);
          }
        }
      },

      openSignedAppFileAsync(root, file, callback) {
        // First try calling the real cert DB
        this._genuine.openSignedAppFileAsync(
          root,
          file,
          (result, zipReader, signatureInfos) => {
            verifyCert(file.clone(), result, signatureInfos, callback).then(
              ([callback, result, signatureInfos]) => {
                callback.openSignedAppFileFinished(
                  result,
                  zipReader,
                  signatureInfos
                );
              }
            );
          }
        );
      },

      QueryInterface: ChromeUtils.generateQI(["nsIX509CertDB"]),
    };

    // Unregister the real database. This only works because the add-ons manager
    // hasn't started up and grabbed the certificate database yet.
    lazy.MockRegistrar.register(CERTDB_CONTRACTID, FakeCertDB);

    // Initialize the mock service.
    Cc[CERTDB_CONTRACTID].getService();
    FakeCertDB.init();
  },

  /**
   * Load the data from the specified files into the *real* blocklist providers.
   * Loads using loadBlocklistRawData, which will treat this as an update.
   *
   * @param {nsIFile} dir
   *        The directory in which the files live.
   * @param {string} prefix
   *        a prefix for the files which ought to be loaded.
   *        This method will suffix -extensions.json
   *        to the prefix it is given, and attempt to load it.
   *        If it exists, its data will be dumped into
   *        the respective store, and the update handler
   *        will be called.
   */
  async loadBlocklistData(dir, prefix) {
    let loadedData = {};
    let fileSuffix = "extensions";
    const fileName = `${prefix}-${fileSuffix}.json`;

    try {
      loadedData[fileSuffix] = await IOUtils.readJSON(
        PathUtils.join(dir.path, fileName)
      );
      this.info(`Loaded ${fileName}`);
    } catch (e) {}

    return this.loadBlocklistRawData(loadedData);
  },

  /**
   * Load the following data into the *real* blocklist providers.
   * Fires update methods as would happen if this data came from
   * an actual blocklist update, etc.
   *
   * @param {object} data
   *        The data to load.
   */
  async loadBlocklistRawData(data) {
    const { BlocklistPrivate } = ChromeUtils.importESModule(
      "resource://gre/modules/Blocklist.sys.mjs"
    );
    const blocklistMapping = {
      extensions: BlocklistPrivate.ExtensionBlocklistRS,
      extensionsMLBF: BlocklistPrivate.ExtensionBlocklistMLBF,
    };

    for (const [dataProp, blocklistObj] of Object.entries(blocklistMapping)) {
      let newData = data[dataProp];
      if (!newData) {
        continue;
      }
      if (!Array.isArray(newData)) {
        throw new Error(
          "Expected an array of new items to put in the " +
            dataProp +
            " blocklist!"
        );
      }
      for (let item of newData) {
        if (!item.id) {
          item.id = Services.uuid.generateUUID().number.slice(1, -1);
        }
        if (!item.last_modified) {
          item.last_modified = Date.now();
        }
      }
      blocklistObj.ensureInitialized();
      let db = await blocklistObj._client.db;
      const collectionTimestamp = Math.max(
        ...newData.map(r => r.last_modified)
      );
      await db.importChanges({}, collectionTimestamp, newData, {
        clear: true,
      });
      // We manually call _onUpdate... which is evil, but at the moment kinto doesn't have
      // a better abstraction unless you want to mock your own http server to do the update.
      await blocklistObj._onUpdate();
    }
  },

  /**
   * Starts up the add-on manager as if it was started by the application.
   *
   * @param {Object} params
   *        The new params are in an object and new code should use that.
   * @param {boolean} params.earlyStartup
   *        Notifies early startup phase. default is true
   * @param {boolean} params.lateStartup
   *        Notifies late startup phase which ensures addons are started or
   *        listeners are primed. default is true
   * @param {boolean} params.newVersion
   *        If provided, the application version is changed to this string
   *        before the AddonManager is started.
   */
  async promiseStartupManager(params) {
    if (this.addonIntegrationService) {
      throw new Error(
        "Attempting to startup manager that was already started."
      );
    }
    // Support old arguments
    if (typeof params != "object") {
      params = {
        newVersion: arguments[0],
      };
    }
    let { earlyStartup = true, lateStartup = true, newVersion } = params;

    lateStartup = earlyStartup && lateStartup;

    if (newVersion) {
      this.appInfo.version = newVersion;
      this.appInfo.platformVersion = newVersion;
    }

    // AddonListeners are removed when the addonManager is shutdown,
    // ensure the Extension observer is added.  We call uninit in
    // promiseShutdown to allow re-initialization.
    lazy.ExtensionAddonObserver.init();

    const { XPIExports } = ChromeUtils.importESModule(
      "resource://gre/modules/addons/XPIExports.sys.mjs"
    );
    XPIExports.XPIInternal.overrideAsyncShutdown(MockAsyncShutdown);

    XPIExports.XPIInternal.BootstrapScope.prototype._beforeCallBootstrapMethod =
      (method, params, reason) => {
        try {
          this.emit("bootstrap-method", { method, params, reason });
        } catch (e) {
          try {
            this.testScope.do_throw(e);
          } catch (e) {
            // Le sigh.
          }
        }
      };

    this.addonIntegrationService = Cc[
      "@mozilla.org/addons/integration;1"
    ].getService(Ci.nsIObserver);

    this.addonIntegrationService.observe(null, "addons-startup", null);

    this.emit("addon-manager-started");

    await Promise.all(XPIExports.XPIProvider.startupPromises);

    // Load the add-ons list as it was after extension registration
    await this.loadAddonsList(true);

    // Wait for all add-ons to finish starting up before resolving.
    await Promise.all(
      Array.from(
        XPIExports.XPIProvider.activeAddons.values(),
        addon => addon.startupPromise
      )
    );
    if (earlyStartup) {
      lazy.ExtensionTestCommon.notifyEarlyStartup();
    }
    if (lateStartup) {
      lazy.ExtensionTestCommon.notifyLateStartup();
    }
  },

  async promiseShutdownManager({
    clearOverrides = true,
    clearL10nRegistry = true,
  } = {}) {
    if (!this.addonIntegrationService) {
      return false;
    }

    if (this.overrideEntry && clearOverrides) {
      this.overrideEntry.destruct();
      this.overrideEntry = null;
    }

    const { XPIExports } = ChromeUtils.importESModule(
      "resource://gre/modules/addons/XPIExports.sys.mjs"
    );

    // Ensure some startup observers in XPIProvider are released.
    Services.obs.notifyObservers(null, "test-load-xpi-database");

    // Note: the code here used to trigger observer notifications such as
    // "quit-application-granted". That was removed because of unwanted side
    // effects in other components. The MockAsyncShutdown triggers here are very
    // specific and only affect the AddonManager/XPIProvider internals.
    await MockAsyncShutdown.quitApplicationGranted.trigger();

    // If XPIDatabase.asyncLoadDB() has been called before, then _dbPromise is
    // a promise, potentially still pending. Wait for it to settle before
    // triggering profileBeforeChange, because the latter can trigger errors in
    // the pending asyncLoadDB() by an indirect call to XPIDatabase.shutdown().
    await XPIExports.XPIDatabase._dbPromise;

    await MockAsyncShutdown.profileBeforeChange.trigger();
    await MockAsyncShutdown.profileChangeTeardown.trigger();

    this.emit("addon-manager-shutdown");

    this.addonIntegrationService = null;

    // Load the add-ons list as it was after application shutdown
    await this.loadAddonsList();

    // Flush the jar cache entries for each bootstrapped XPI so that
    // we don't run into file locking issues on Windows.
    for (let file of this.addonsList.xpis) {
      Services.obs.notifyObservers(file, "flush-cache-entry");
    }

    // Clear L10nRegistry entries so restaring the AOM will work correctly with locales.
    if (clearL10nRegistry) {
      L10nRegistry.getInstance().clearSources();
    }

    // Clear any crash report annotations
    this.appInfo.annotations = {};

    // Force the XPIProvider provider to reload to better
    // simulate real-world usage.

    // This would be cleaner if I could get it as the rejection reason from
    // the AddonManagerInternal.shutdown() promise
    let shutdownError = XPIExports.XPIDatabase._saveError;

    AddonManagerPrivate.unregisterProvider(XPIExports.XPIProvider);

    resetXPIExports(XPIExports);

    lazy.ExtensionAddonObserver.uninit();

    lazy.ExtensionTestCommon.resetStartupPromises();

    if (shutdownError) {
      throw shutdownError;
    }

    return true;
  },

  /**
   * Asynchronously restart the AddonManager.  If newVersion is provided,
   * simulate an application upgrade (or downgrade) where the version
   * is changed to newVersion when re-started.
   *
   * @param {Object} params
   *        The new params are in an object and new code should use that.
   *        See promiseStartupManager for param details.
   */
  async promiseRestartManager(params) {
    await this.promiseShutdownManager({ clearOverrides: false });
    await this.promiseStartupManager(params);
  },

  /**
   * If promiseStartupManager is called with earlyStartup: false, then
   * use this to notify early startup.
   *
   * @returns {Promise} resolves when notification is complete
   */
  notifyEarlyStartup() {
    return lazy.ExtensionTestCommon.notifyEarlyStartup();
  },

  /**
   * If promiseStartupManager is called with lateStartup: false, then
   * use this to notify late startup.  You should also call early startup
   * if necessary.
   *
   * @returns {Promise} resolves when notification is complete
   */
  notifyLateStartup() {
    return lazy.ExtensionTestCommon.notifyLateStartup();
  },

  async loadAddonsList(flush = false) {
    if (flush) {
      const { XPIExports } = ChromeUtils.importESModule(
        "resource://gre/modules/addons/XPIExports.sys.mjs"
      );
      XPIExports.XPIInternal.XPIStates.save();
      await XPIExports.XPIInternal.XPIStates._jsonFile._save();
    }

    this.addonsList = new AddonsList(this.addonStartup);
  },

  /**
   * Writes the given data to a file in the given zip file.
   *
   * @param {string|nsIFile} zipFile
   *        The zip file to write to.
   * @param {Object} files
   *        An object containing filenames and the data to write to the
   *        corresponding paths in the zip file.
   * @param {integer} [flags = 0]
   *        Additional flags to open the file with.
   */
  writeFilesToZip(zipFile, files, flags = 0) {
    if (typeof zipFile == "string") {
      zipFile = nsFile(zipFile);
    }

    var zipW = ZipWriter(
      zipFile,
      FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | flags
    );

    for (let [path, data] of Object.entries(files)) {
      if (
        typeof data === "object" &&
        ChromeUtils.getClassName(data) === "Object"
      ) {
        data = JSON.stringify(data);
      }
      if (!(data instanceof ArrayBuffer)) {
        data = new TextEncoder().encode(data).buffer;
      }

      let stream = ArrayBufferInputStream(data, 0, data.byteLength);

      // Note these files are being created in the XPI archive with date
      // 1 << 49, which is a valid time for ZipWriter.
      zipW.addEntryStream(
        path,
        Math.pow(2, 49),
        Ci.nsIZipWriter.COMPRESSION_NONE,
        stream,
        false
      );
    }

    zipW.close();
  },

  async promiseWriteFilesToZip(zip, files, flags) {
    await IOUtils.makeDirectory(PathUtils.parent(zip));

    this.writeFilesToZip(zip, files, flags);

    return Promise.resolve(nsFile(zip));
  },

  async promiseWriteFilesToDir(dir, files) {
    await IOUtils.makeDirectory(dir);

    for (let [path, data] of Object.entries(files)) {
      path = path.split("/");
      let leafName = path.pop();

      // Create parent directories, if necessary.
      let dirPath = dir;
      for (let subDir of path) {
        dirPath = PathUtils.join(dirPath, subDir);
        await PathUtils.makeDirectory(dirPath);
      }

      const leafPath = PathUtils.join(dirPath, leafName);
      if (
        typeof data == "object" &&
        ChromeUtils.getClassName(data) == "Object"
      ) {
        await IOUtils.writeJSON(leafPath, data);
      } else if (typeof data == "string") {
        await IOUtils.writeUTF8(leafPath, data);
      }
    }

    return nsFile(dir);
  },

  promiseWriteFilesToExtension(dir, id, files, unpacked = this.testUnpacked) {
    if (unpacked) {
      let path = PathUtils.join(dir, id);

      return this.promiseWriteFilesToDir(path, files);
    }

    let xpi = PathUtils.join(dir, `${id}.xpi`);

    return this.promiseWriteFilesToZip(xpi, files);
  },

  tempXPIs: [],

  allocTempXPIFile() {
    let file = this.tempDir.clone();
    let uuid = Services.uuid.generateUUID().number.slice(1, -1);
    file.append(`${uuid}.xpi`);

    this.tempXPIs.push(file);

    return file;
  },

  /**
   * 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 {object} files
   *          The object holding data about the add-on
   * @return {nsIFile} A file pointing to the created XPI file
   */
  createTempXPIFile(files) {
    let file = this.allocTempXPIFile();
    this.writeFilesToZip(file.path, files);
    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 {Object} data
   *        The object holding data about the add-on, as expected by
   *        |ExtensionTestCommon.generateXPI|.
   * @return {nsIFile} A file pointing to the created XPI file
   */
  createTempWebExtensionFile(data) {
    let file = lazy.ExtensionTestCommon.generateXPI(data);
    this.tempXPIs.push(file);
    return file;
  },

  /**
   * Creates an XPI with the given files and installs it.
   *
   * @param {object} files
   *        A files object as would be passed to {@see #createTempXPI}.
   * @returns {Promise}
   *        A promise which resolves when the add-on is installed.
   */
  promiseInstallXPI(files) {
    return this.promiseInstallFile(this.createTempXPIFile(files));
  },

  /**
   * Creates an extension proxy file.
   * See: https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file
   *
   * @param {nsIFile} dir
   *        The directory to add the proxy file to.
   * @param {nsIFile} addon
   *        An nsIFile for the add-on file that this is a proxy file for.
   * @param {string} id
   *        A string to use for the add-on ID.
   * @returns {Promise} Resolves when the file has been created.
   */
  promiseWriteProxyFileToDir(dir, addon, id) {
    let files = {
      [id]: addon.path,
    };

    return this.promiseWriteFilesToDir(dir.path, files);
  },

  /**
   * Manually installs an XPI file into an install location by either copying the
   * XPI there or extracting it depending on whether unpacking is being tested
   * or not.
   *
   * @param {nsIFile} xpiFile
   *        The XPI file to install.
   * @param {nsIFile} [installLocation = this.profileExtensions]
   *        The install location (an nsIFile) to install into.
   * @param {string} [id]
   *        The ID to install as.
   * @param {boolean} [unpacked = this.testUnpacked]
   *        If true, install as an unpacked directory, rather than a
   *        packed XPI.
   * @returns {nsIFile}
   *        A file pointing to the installed location of the XPI file or
   *        unpacked directory.
   */
  async manuallyInstall(
    xpiFile,
    installLocation = this.profileExtensions,
    id = null,
    unpacked = this.testUnpacked
  ) {
    if (id == null) {
      id = await this.getIDFromExtension(xpiFile);
    }

    if (unpacked) {
      let dir = installLocation.clone();
      dir.append(id);
      dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);

      let zip = ZipReader(xpiFile);
      for (let entry of zip.findEntries(null)) {
        let target = dir.clone();
        for (let part of entry.split("/")) {
          target.append(part);
        }
        if (!target.parent.exists()) {
          target.parent.create(
            Ci.nsIFile.DIRECTORY_TYPE,
            FileUtils.PERMS_DIRECTORY
          );
        }
        try {
          zip.extract(entry, target);
        } catch (e) {
          if (
            e.result != Cr.NS_ERROR_FILE_DIR_NOT_EMPTY &&
            !(target.exists() && target.isDirectory())
          ) {
            throw e;
          }
        }
        target.permissions |= FileUtils.PERMS_FILE;
      }
      zip.close();

      return dir;
    }

    let target = installLocation.clone();
    target.append(`${id}.xpi`);
    xpiFile.copyTo(target.parent, target.leafName);
    return target;
  },

  /**
   * Manually uninstalls an add-on by removing its files from the install
   * location.
   *
   * @param {nsIFile} installLocation
   *        The nsIFile of the install location to remove from.
   * @param {string} id
   *        The ID of the add-on to remove.
   * @param {boolean} [unpacked = this.testUnpacked]
   *        If true, uninstall an unpacked directory, rather than a
   *        packed XPI.
   */
  manuallyUninstall(installLocation, id, unpacked = this.testUnpacked) {
    let file = this.getFileForAddon(installLocation, id, unpacked);

    // In reality because the app is restarted a flush isn't necessary for XPIs
    // removed outside the app, but for testing we must flush manually.
    if (file.isFile()) {
      Services.obs.notifyObservers(file, "flush-cache-entry");
    }

    file.remove(true);
  },

  /**
   * Gets the nsIFile for where an add-on is installed. It may point to a file or
   * a directory depending on whether add-ons are being installed unpacked or not.
   *
   * @param {nsIFile} dir
   *         The nsIFile for the install location
   * @param {string} id
   *        The ID of the add-on
   * @param {boolean} [unpacked = this.testUnpacked]
   *        If true, return the path to an unpacked directory, rather than a
   *        packed XPI.
   * @returns {nsIFile}
   *        A file pointing to the XPI file or unpacked directory where
   *        the add-on should be installed.
   */
  getFileForAddon(dir, id, unpacked = this.testUnpacked) {
    dir = dir.clone();
    if (unpacked) {
      dir.append(id);
    } else {
      dir.append(`${id}.xpi`);
    }
    return dir;
  },

  /**
   * Sets the last modified time of the extension, usually to trigger an update
   * of its metadata.
   *
   * @param {nsIFile} ext A file pointing to either the packed extension or its unpacked directory.
   * @param {number} time The time to which we set the lastModifiedTime of the extension
   *
   * @deprecated Please use promiseSetExtensionModifiedTime instead
   */
  setExtensionModifiedTime(ext, time) {
    ext.lastModifiedTime = time;
    if (ext.isDirectory()) {
      for (let file of this.iterDirectory(ext)) {
        this.setExtensionModifiedTime(file, time);
      }
    }
  },

  async promiseSetExtensionModifiedTime(path, time) {
    await IOUtils.setModificationTime(path, time);

    const stat = await IOUtils.stat(path);
    if (stat.type !== "directory") {
      return;
    }

    const children = await IOUtils.getChildren(path);

    try {
      await Promise.all(
        children.map(entry => this.promiseSetExtensionModifiedTime(entry, time))
      );
    } catch (ex) {
      if (DOMException.isInstance(ex)) {
        return;
      }
      throw ex;
    }
  },

  registerDirectory(key, dir) {
    var dirProvider = {
      getFile(prop, persistent) {
        persistent.value = false;
        if (prop == key) {
          return dir.clone();
        }
        return null;
      },

      QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
    };
    Services.dirsvc.registerProvider(dirProvider);

    try {
      Services.dirsvc.undefine(key);
    } catch (e) {
      // This throws if the key is not already registered, but that
      // doesn't matter.
      if (e.result != Cr.NS_ERROR_FAILURE) {
        throw e;
      }
    }
  },

  /**
   * Returns a promise that resolves when the given add-on event is fired. The
   * resolved value is an array of arguments passed for the event.
   *
   * @param {string} event
   *        The name of the AddonListener event handler method for which
   *        an event is expected.
   * @param {function} checkFn [optional]
   *        A function to check if this is the right event. Should return true
   *        for the event that it wants, false otherwise. Will be passed
   *        all the relevant arguments.
   *        If not passed, any event will do to resolve the promise.
   * @returns {Promise<Array>}
   *        Resolves to an array containing the event handler's
   *        arguments the first time it is called.
   */
  promiseAddonEvent(event, checkFn) {
    return new Promise(resolve => {
      let listener = {
        [event](...args) {
          if (typeof checkFn == "function" && !checkFn(...args)) {
            return;
          }
          AddonManager.removeAddonListener(listener);
          resolve(args);
        },
      };

      AddonManager.addAddonListener(listener);
    });
  },

  promiseInstallEvent(event, checkFn) {
    return new Promise(resolve => {
      let listener = {
        [event](...args) {
          if (typeof checkFn == "function" && !checkFn(...args)) {
            return;
          }
          AddonManager.removeInstallListener(listener);
          resolve(args);
        },
      };

      AddonManager.addInstallListener(listener);
    });
  },

  /**
   * A helper method to install AddonInstall and wait for completion.
   *
   * @param {AddonInstall} install
   *        The add-on to install.
   * @returns {Promise<AddonInstall>}
   *        Resolves when the install completes, either successfully or
   *        in failure.
   */
  promiseCompleteInstall(install) {
    let listener;
    return new Promise(resolve => {
      let installPromise;
      listener = {
        onDownloadFailed: resolve,
        onDownloadCancelled: resolve,
        onInstallFailed: resolve,
        onInstallCancelled: resolve,
        onInstallEnded() {
          // onInstallEnded is called right when an add-on has been installed.
          // install() may still be pending, e.g. for updates, and be awaiting
          // the completion of the update, part of which is the removal of the
          // temporary XPI file of the downloaded update. To avoid intermittent
          // test failures due to lingering temporary files, await install().
          resolve(installPromise);
        },
        onInstallPostponed: resolve,
      };

      install.addListener(listener);
      installPromise = install.install();
    }).then(() => {
      install.removeListener(listener);
      return install;
    });
  },

  /**
   * A helper method to install a file.
   *
   * @param {nsIFile} file
   *        The file to install
   * @param {boolean} [ignoreIncompatible = false]
   *        Optional parameter to ignore add-ons that are incompatible
   *        with the application
   * @param {Object} [installTelemetryInfo = undefined]
   *        Optional parameter to set the install telemetry info for the
   *        installed addon
   * @returns {Promise}
   *        Resolves when the install has completed.
   */
  async promiseInstallFile(
    file,
    ignoreIncompatible = false,
    installTelemetryInfo
  ) {
    let install = await AddonManager.getInstallForFile(
      file,
      null,
      installTelemetryInfo
    );
    if (!install) {
      throw new Error(`No AddonInstall created for ${file.path}`);
    }

    if (install.state != AddonManager.STATE_DOWNLOADED) {
      throw new Error(
        `Expected file to be downloaded for install of ${file.path}`
      );
    }

    if (ignoreIncompatible && install.addon.appDisabled) {
      return null;
    }

    await install.install();
    return install;
  },

  /**
   * A helper method to install an array of files.
   *
   * @param {Iterable<nsIFile>} files
   *        The files to install
   * @param {boolean} [ignoreIncompatible = false]
   *        Optional parameter to ignore add-ons that are incompatible
   *        with the application
   * @returns {Promise}
   *        Resolves when the installs have completed.
   */
  promiseInstallAllFiles(files, ignoreIncompatible = false) {
    return Promise.all(
      Array.from(files, file =>
        this.promiseInstallFile(file, ignoreIncompatible)
      )
    );
  },

  promiseCompleteAllInstalls(installs) {
    return Promise.all(Array.from(installs, this.promiseCompleteInstall));
  },

  /**
   * @property {number} updateReason
   *        The default update reason for {@see promiseFindAddonUpdates}
   *        calls. May be overwritten by tests which primarily check for
   *        updates with a particular reason.
   */
  updateReason: AddonManager.UPDATE_WHEN_PERIODIC_UPDATE,

  /**
   * Returns a promise that will be resolved when an add-on update check is
   * complete. The value resolved will be an AddonInstall if a new version was
   * found.
   *
   * @param {object} addon The add-on to find updates for.
   * @param {integer} reason The type of update to find.
   * @param {Array} args Additional args to pass to `checkUpdates` after
   *                     the update reason.
   * @return {Promise<object>} an object containing information about the update.
   */
  promiseFindAddonUpdates(
    addon,
    reason = AddonTestUtils.updateReason,
    ...args
  ) {
    // Retrieve the test assertion helper from the testScope
    // (which is `equal` in xpcshell-test and `is` in mochitest)
    let equal = this.testScope.equal || this.testScope.is;
    return new Promise((resolve, reject) => {
      let result = {};
      addon.findUpdates(
        {
          onNoCompatibilityUpdateAvailable(addon2) {
            if ("compatibilityUpdate" in result) {
              throw new Error("Saw multiple compatibility update events");
            }
            equal(addon, addon2, "onNoCompatibilityUpdateAvailable");
            result.compatibilityUpdate = false;
          },

          onCompatibilityUpdateAvailable(addon2) {
            if ("compatibilityUpdate" in result) {
              throw new Error("Saw multiple compatibility update events");
            }
            equal(addon, addon2, "onCompatibilityUpdateAvailable");
            result.compatibilityUpdate = true;
          },

          onNoUpdateAvailable(addon2) {
            if ("updateAvailable" in result) {
              throw new Error("Saw multiple update available events");
            }
            equal(addon, addon2, "onNoUpdateAvailable");
            result.updateAvailable = false;
          },

          onUpdateAvailable(addon2, install) {
            if ("updateAvailable" in result) {
              throw new Error("Saw multiple update available events");
            }
            equal(addon, addon2, "onUpdateAvailable");
            result.updateAvailable = install;
          },

          onUpdateFinished(addon2, error) {
            equal(addon, addon2, "onUpdateFinished");
            if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
              resolve(result);
            } else {
              result.error = error;
              reject(result);
            }
          },
        },
        reason,
        ...args
      );
    });
  },

  /**
   * Monitors console output for the duration of a task, and returns a promise
   * which resolves to a tuple containing a list of all console messages
   * generated during the task's execution, and the result of the task itself.
   *
   * @param {function} task
   *        The task to run while monitoring console output. May be
   *        an async function, or an ordinary function which returns a promose.
   * @return {Promise<[Array<nsIConsoleMessage>, *]>}
   *        Resolves to an object containing a `messages` property, with
   *        the array of console messages emitted during the execution
   *        of the task, and a `result` property, containing the task's
   *        return value.
   */
  async promiseConsoleOutput(task) {
    const DONE = "=== xpcshell test console listener done ===";

    let listener,
      messages = [];
    let awaitListener = new Promise(resolve => {
      listener = msg => {
        if (msg == DONE) {
          resolve();
        } else {
          msg instanceof Ci.nsIScriptError;
          messages.push(msg);
        }
      };
    });

    Services.console.registerListener(listener);
    try {
      let result = await task();

      Services.console.logStringMessage(DONE);
      await awaitListener;

      return { messages, result };
    } finally {
      Services.console.unregisterListener(listener);
    }
  },

  /**
   * An object describing an expected or forbidden console message. Each
   * property in the object corresponds to a property with the same name
   * in a console message. If the value in the pattern object is a
   * regular expression, it must match the value of the corresponding
   * console message property. If it is any other value, it must be
   * strictly equal to the correspondng console message property.
   *
   * @typedef {object} ConsoleMessagePattern
   */

  /**
   * Checks the list of messages returned from `promiseConsoleOutput`
   * against the given set of expected messages.
   *
   * This is roughly equivalent to the expected and forbidden message
   * matching functionality of SimpleTest.monitorConsole.
   *
   * @param {Array<object>} messages
   *        The array of console messages to match.
   * @param {object} options
   *        Options describing how to perform the match.
   * @param {Array<ConsoleMessagePattern>} [options.expected = []]
   *        An array of messages which must appear in `messages`. The
   *        matching messages in the `messages` array must appear in the
   *        same order as the patterns in the `expected` array.
   * @param {Array<ConsoleMessagePattern>} [options.forbidden = []]
   *        An array of messages which must not appear in the `messages`
   *        array.
   * @param {bool} [options.forbidUnexpected = false]
   *        If true, the `messages` array must not contain any messages
   *        which are not matched by the given `expected` patterns.
   */
  checkMessages(
    messages,
    { expected = [], forbidden = [], forbidUnexpected = false }
  ) {
    function msgMatches(msg, expectedMsg) {
      for (let [prop, pattern] of Object.entries(expectedMsg)) {
        if (isRegExp(pattern) && typeof msg[prop] === "string") {
          if (!pattern.test(msg[prop])) {
            return false;
          }
        } else if (msg[prop] !== pattern) {
          return false;
        }
      }
      return true;
    }

    function validateOptionFormat(optionName, optionValue) {
      for (let item of optionValue) {
        if (!item || typeof item !== "object" || isRegExp(item)) {
          throw new Error(
            `Unexpected format in AddonTestUtils.checkMessages "${optionName}" parameter`
          );
        }
      }
    }

    validateOptionFormat("expected", expected);
    validateOptionFormat("forbidden", forbidden);

    let i = 0;
    for (let msg of messages) {
      if (forbidden.some(pat => msgMatches(msg, pat))) {
        this.testScope.ok(false, `Got forbidden console message: ${msg}`);
        continue;
      }

      if (i < expected.length && msgMatches(msg, expected[i])) {
        this.info(`Matched expected console message: ${msg}`);
        i++;
      } else if (forbidUnexpected) {
        this.testScope.ok(false, `Got unexpected console message: ${msg}`);
      }
    }
    for (let pat of expected.slice(i)) {
      this.testScope.ok(
        false,
        `Did not get expected console message: ${uneval(pat)}`
      );
    }
  },

  /**
   * Asserts that the expected installTelemetryInfo properties are available
   * on the AddonWrapper or AddonInstall objects.
   *
   * @param {AddonWrapper|AddonInstall} addonOrInstall
   *        The addon or addonInstall object to check.
   * @param {Object} expectedInstallInfo
   *        The expected installTelemetryInfo properties
   *        (every property can be a primitive value or a regular expression).
   * @param {string} [msg]
   *        Optional assertion message suffix.
   */
  checkInstallInfo(addonOrInstall, expectedInstallInfo, msg = undefined) {
    const installInfo = addonOrInstall.installTelemetryInfo;
    const { Assert } = this.testScope;

    msg = msg ? ` ${msg}` : "";

    for (const key of Object.keys(expectedInstallInfo)) {
      const actual = installInfo[key];
      let expected = expectedInstallInfo[key];

      // Assert the property value using a regular expression.
      if (expected && typeof expected.test == "function") {
        Assert.ok(
          expected.test(actual),
          `${key} value "${actual}" has the value expected "${expected}"${msg}`
        );
      } else {
        Assert.deepEqual(
          actual,
          expected,
          `Got the expected value for ${key}${msg}`
        );
      }
    }
  },

  /**
   * Helper to wait for a webextension to completely start
   *
   * @param {string} [id]
   *        An optional extension id to look for.
   *
   * @returns {Promise<Extension>}
   *           A promise that resolves with the extension, once it is started.
   */
  promiseWebExtensionStartup(id) {
    return new Promise(resolve => {
      lazy.Management.on("ready", function listener(event, extension) {
        if (!id || extension.id == id) {
          lazy.Management.off("ready", listener);
          resolve(extension);
        }
      });
    });
  },

  /**
   * Wait until an extension with a search provider has been loaded.
   * This should be called after the extension has started, but before shutdown.
   *
   * @param {object} extension
   *        The return value of ExtensionTestUtils.loadExtension.
   *        For browser tests, see mochitest/tests/SimpleTest/ExtensionTestUtils.js
   *        For xpcshell tests, see toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs
   * @param {object} [options]
   *        Optional options.
   * @param {boolean} [options.expectPending = false]
   *        Whether to expect the search provider to still be starting up.
   */
  async waitForSearchProviderStartup(
    extension,
    { expectPending = false } = {}
  ) {
    // In xpcshell tests, equal/ok are defined in the global scope.
    let { equal, ok } = this.testScope;
    if (!equal || !ok) {
      // In mochitests, these are available via Assert.sys.mjs.
      let { Assert } = this.testScope;
      equal = Assert.equal.bind(Assert);
      ok = Assert.ok.bind(Assert);
    }

    equal(
      extension.state,
      "running",
      "Search provider extension should be running"
    );
    ok(extension.id, "Extension ID of search provider should be set");

    // The map of promises from browser/components/extensions/parent/ext-chrome-settings-overrides.js
    let { pendingSearchSetupTasks } = lazy.Management.global;
    let searchStartupPromise = pendingSearchSetupTasks.get(extension.id);
    if (expectPending) {
      ok(
        searchStartupPromise,
        "Search provider registration should be in progress"
      );
    }
    return searchStartupPromise;
  },

  /**
   * Initializes the URLPreloader, which is required in order to load
   * built_in_addons.json.
   */
  initializeURLPreloader() {
    lazy.aomStartup.initializeURLPreloader();
  },

  /**
   * Override chrome URL for specifying allowed built-in add-ons.
   *
   * @param {object} data - An object specifying which add-on IDs are permitted
   *                        to load, for instance: { "system": ["id1", "..."] }
   */
  async overrideBuiltIns(data) {
    this.initializeURLPreloader();

    let file = this.tempDir.clone();
    file.append("override.txt");
    this.tempXPIs.push(file);

    let manifest = Services.io.newFileURI(file);
    await IOUtils.writeJSON(file.path, data);
    this.overrideEntry = lazy.aomStartup.registerChrome(manifest, [
      [
        "override",
        "chrome://browser/content/built_in_addons.json",
        Services.io.newFileURI(file).spec,
      ],
    ]);
  },

  // AMTelemetry events helpers.

  /**
   * Formerly this function re-routed telemetry events. Now it just ensures
   * that there are no unexamined events after the test file is exiting.
   */
  hookAMTelemetryEvents() {
    this.testScope.registerCleanupFunction(() => {
      this.testScope.Assert.deepEqual(
        [],
        this.getAMTelemetryEvents(),
        "No unexamined telemetry events after test is finished"
      );
    });
  },

  /**
   * Retrive any AMTelemetry event collected and clears _all_ telemetry events.
   *
   * @returns {Array<Object>}
   *          The array of the collected telemetry data.
   */
  getAMTelemetryEvents() {
    // This duplicates some logic from TelemetryTestUtils.
    let snapshots = Services.telemetry.snapshotEvents(
      Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
      /* clear = */ true
    );
    let events = (snapshots.parent ?? [])
      .filter(entry => entry[1] == "addonsManager")
      .map(entry => ({
        // The callers don't expect the timestamp or the category.
        method: entry[2],
        object: entry[3],
        value: entry[4],
        extra: entry[5],
      }));

    return events;
  },

  /**
   * @param {string|string[]} events - The event(s) to retrieve.
   * @param {object} [filter] - key/value pairs to filter events.
   * @returns {object[]} Collected extra objects from events.
   */
  getAMGleanEvents(events, filter = {}) {
    let result = [];
    for (let event of [].concat(events)) {
      result = result.concat(Glean.addonsManager[event].testGetValue() ?? []);
    }

    // When combining multiple events, we want them in chronological order.
    result.sort((a, b) => a.timestamp - b.timestamp);

    result = result.filter(e =>
      Object.keys(filter).every(key => e.extra[key] === filter[key])
    );

    // We (usually) don't care about install_id, so drop it to ease comparison.
    result.forEach(e => delete e.extra.install_id);

    // For Glean events, all data is in the extra object.
    return result.map(e => e.extra);
  },
};

for (let [key, val] of Object.entries(AddonTestUtils)) {
  if (typeof val == "function") {
    AddonTestUtils[key] = val.bind(AddonTestUtils);
  }
}

EventEmitter.decorate(AddonTestUtils);
