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

/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */

if (!_TEST_NAME.includes("toolkit/mozapps/extensions/test/xpcshell/")) {
  Assert.ok(
    false,
    "head_addons.js may not be loaded by tests outside of " +
      "the add-on manager component."
  );
}

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_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
const PREF_DATA_COLLECTION_PERMISSIONS_ENABLED =
  "extensions.dataCollectionPermissions.enabled";

// 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;

const { AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
  "resource://gre/modules/AddonManager.sys.mjs"
);
var { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);
var { FileUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/FileUtils.sys.mjs"
);
var { NetUtil } = ChromeUtils.importESModule(
  "resource://gre/modules/NetUtil.sys.mjs"
);
var { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);
var { AddonRepository } = ChromeUtils.importESModule(
  "resource://gre/modules/addons/AddonRepository.sys.mjs"
);

var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule(
  "resource://testing-common/AddonTestUtils.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
  Extension: "resource://gre/modules/Extension.sys.mjs",
  ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
  ExtensionTestUtils:
    "resource://testing-common/ExtensionXPCShellUtils.sys.mjs",
  HttpServer: "resource://testing-common/httpd.sys.mjs",
  MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
  MockRegistry: "resource://testing-common/MockRegistry.sys.mjs",
  PromiseTestUtils: "resource://testing-common/PromiseTestUtils.sys.mjs",
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  TestUtils: "resource://testing-common/TestUtils.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetter(
  this,
  "aomStartup",
  "@mozilla.org/addons/addon-manager-startup;1",
  Ci.amIAddonManagerStartup
);

const {
  createAppInfo,
  createHttpServer,
  createTempWebExtensionFile,
  getFileForAddon,
  manuallyInstall,
  manuallyUninstall,
  overrideBuiltIns,
  promiseAddonEvent,
  promiseCompleteAllInstalls,
  promiseCompleteInstall,
  promiseConsoleOutput,
  promiseFindAddonUpdates,
  promiseInstallAllFiles,
  promiseInstallFile,
  promiseRestartManager,
  promiseSetExtensionModifiedTime,
  promiseShutdownManager,
  promiseStartupManager,
  promiseWebExtensionStartup,
  promiseWriteProxyFileToDir,
  registerDirectory,
  setExtensionModifiedTime,
  writeFilesToZip,
} = AddonTestUtils;

// WebExtension wrapper for ease of testing
ExtensionTestUtils.init(this);

AddonTestUtils.init(this);
AddonTestUtils.overrideCertDB();

ChromeUtils.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) {
    AddonTestUtils.useRealCertChecks = val;
  },
});

Object.defineProperty(this, "TEST_UNPACKED", {
  get() {
    return AddonTestUtils.testUnpacked;
  },
  set(val) {
    AddonTestUtils.testUnpacked = val;
  },
});

const promiseAddonByID = AddonManager.getAddonByID;
const promiseAddonsByIDs = AddonManager.getAddonsByIDs;
const promiseAddonsByTypes = AddonManager.getAddonsByTypes;

var gPort = null;

var BootstrapMonitor = {
  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} ${reason} 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, method });
        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.oldVersion should match last call"
      );
    } else {
      equal(
        params.version,
        lastParams.version,
        "params.version should match last call"
      );
    }

    if (method !== "update" && method !== "uninstall") {
      equal(
        params.resourceURI.spec,
        lastParams.resourceURI.spec,
        `params.resourceURI 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");
    }
    return started;
  },

  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;
  },

  checkUpdated(id, version = undefined) {
    const installed = this.installed.get(id);
    equal(installed.method, "update", `Should have seen update 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}`
    );
  },
};

async function restartWithLocales(locales) {
  Services.locale.requestedLocales = locales;
  await promiseRestartManager();
}

function delay(msec) {
  return new Promise(resolve => {
    setTimeout(resolve, msec);
  });
}

/**
 * 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 = "sha256";
  }

  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";
}

/**
 * Returns the file containing the add-on. For packed add-ons, this is
 * an XPI file. For unpacked add-ons, it is the add-on's root directory.
 *
 * @param {Addon} addon
 * @returns {nsIFile}
 */
function getAddonFile(addon) {
  let uri = addon.getResourceURI("");
  if (uri instanceof Ci.nsIJARURI) {
    uri = uri.JARFile;
  }
  return uri.QueryInterface(Ci.nsIFileURL).file;
}

/**
 * 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;
    } else 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 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) {
  let files = {
    "manifest.json": JSON.stringify(aData),
  };
  if (!aId) {
    aId =
      aData?.browser_specific_settings?.gecko?.id ||
      aData?.applications?.gecko?.id;
  }
  return AddonTestUtils.promiseWriteFilesToExtension(aDir.path, aId, files);
}

function hasFlag(aBits, aFlag) {
  return (aBits & aFlag) != 0;
}

class EventChecker {
  constructor(options) {
    this.expectedEvents = options.addonEvents || {};
    this.expectedInstalls = options.installEvents || null;
    this.ignorePlugins = options.ignorePlugins || false;

    this.finished = new Promise(resolve => {
      this.resolveFinished = resolve;
    });

    AddonManager.addAddonListener(this);
    if (this.expectedInstalls) {
      AddonManager.addInstallListener(this);
    }
  }

  cleanup() {
    AddonManager.removeAddonListener(this);
    if (this.expectedInstalls) {
      AddonManager.removeInstallListener(this);
    }
  }

  checkValue(prop, value, flagName) {
    if (Array.isArray(flagName)) {
      let names = flagName.map(name => `AddonManager.${name}`);

      Assert.ok(
        flagName.map(name => AddonManager[name]).includes(value),
        `${prop} value \`${value}\` should be one of [${names.join(", ")}`
      );
    } else {
      Assert.equal(
        value,
        AddonManager[flagName],
        `${prop} should have value AddonManager.${flagName}`
      );
    }
  }

  checkFlag(prop, value, flagName) {
    Assert.equal(
      value & AddonManager[flagName],
      AddonManager[flagName],
      `${prop} should have flag AddonManager.${flagName}`
    );
  }

  checkNoFlag(prop, value, flagName) {
    Assert.ok(
      !(value & AddonManager[flagName]),
      `${prop} should not have flag AddonManager.${flagName}`
    );
  }

  checkComplete() {
    if (this.expectedInstalls && this.expectedInstalls.length) {
      return;
    }

    if (Object.values(this.expectedEvents).some(events => events.length)) {
      return;
    }

    info("Test complete");
    this.cleanup();
    this.resolveFinished();
  }

  ensureComplete() {
    this.cleanup();

    for (let [id, events] of Object.entries(this.expectedEvents)) {
      Assert.equal(
        events.length,
        0,
        `Should have no remaining events for ${id}`
      );
    }
    if (this.expectedInstalls) {
      Assert.deepEqual(
        this.expectedInstalls,
        [],
        "Should have no remaining install events"
      );
    }
  }

  // Add-on listener events
  getExpectedEvent(aId) {
    if (!(aId in this.expectedEvents)) {
      return null;
    }

    let events = this.expectedEvents[aId];
    Assert.ok(!!events.length, `Should be expecting events for ${aId}`);

    return events.shift();
  }

  checkAddonEvent(event, addon, details = {}) {
    info(`Got event "${event}" for add-on ${addon.id}`);

    if ("requiresRestart" in details) {
      Assert.equal(
        details.requiresRestart,
        false,
        "requiresRestart should always be false"
      );
    }

    let expected = this.getExpectedEvent(addon.id);
    if (!expected) {
      return undefined;
    }

    Assert.equal(
      expected.event,
      event,
      `Expecting event "${expected.event}" got "${event}"`
    );

    for (let prop of ["properties"]) {
      if (prop in expected) {
        Assert.deepEqual(
          expected[prop],
          details[prop],
          `Expected value for ${prop}`
        );
      }
    }

    this.checkComplete();

    if ("returnValue" in expected) {
      return expected.returnValue;
    }
    return undefined;
  }

  onPropertyChanged(addon, properties) {
    return this.checkAddonEvent("onPropertyChanged", addon, { properties });
  }

  onEnabling(addon, requiresRestart) {
    let result = this.checkAddonEvent("onEnabling", addon, { requiresRestart });

    this.checkNoFlag("addon.permissions", addon.permissions, "PERM_CAN_ENABLE");

    return result;
  }

  onEnabled(addon) {
    let result = this.checkAddonEvent("onEnabled", addon);

    this.checkNoFlag("addon.permissions", addon.permissions, "PERM_CAN_ENABLE");

    return result;
  }

  onDisabling(addon, requiresRestart) {
    let result = this.checkAddonEvent("onDisabling", addon, {
      requiresRestart,
    });

    this.checkNoFlag(
      "addon.permissions",
      addon.permissions,
      "PERM_CAN_DISABLE"
    );
    return result;
  }

  onDisabled(addon) {
    let result = this.checkAddonEvent("onDisabled", addon);

    this.checkNoFlag(
      "addon.permissions",
      addon.permissions,
      "PERM_CAN_DISABLE"
    );

    return result;
  }

  onInstalling(addon, requiresRestart) {
    return this.checkAddonEvent("onInstalling", addon, { requiresRestart });
  }

  onInstalled(addon) {
    return this.checkAddonEvent("onInstalled", addon);
  }

  onUninstalling(addon) {
    return this.checkAddonEvent("onUninstalling", addon);
  }

  onUninstalled(addon) {
    return this.checkAddonEvent("onUninstalled", addon);
  }

  onOperationCancelled(addon) {
    return this.checkAddonEvent("onOperationCancelled", addon);
  }

  // Install listener events.
  checkInstall(event, install, details = {}) {
    // Lazy initialization of the plugin host means we can get spurious
    // install events for plugins. If we're not looking for plugin
    // installs, ignore them completely. If we *are* looking for plugin
    // installs, the onus is on the individual test to ensure it waits
    // for the plugin host to have done its initial work.
    if (this.ignorePlugins && install.type == "plugin") {
      info(`Ignoring install event for plugin ${install.id}`);
      return undefined;
    }
    info(`Got install event "${event}"`);

    let expected = this.expectedInstalls.shift();
    Assert.ok(expected, "Should be expecting install event");

    Assert.equal(
      expected.event,
      event,
      "Should be expecting onExternalInstall event"
    );

    if ("state" in details) {
      this.checkValue("install.state", install.state, details.state);
    }

    this.checkComplete();

    if ("callback" in expected) {
      expected.callback(install);
    }

    if ("returnValue" in expected) {
      return expected.returnValue;
    }
    return undefined;
  }

  onNewInstall(install) {
    let result = this.checkInstall("onNewInstall", install, {
      state: ["STATE_DOWNLOADED", "STATE_DOWNLOAD_FAILED", "STATE_AVAILABLE"],
    });

    if (install.state != AddonManager.STATE_DOWNLOAD_FAILED) {
      Assert.equal(install.error, 0, "Should have no error");
    } else {
      Assert.notEqual(install.error, 0, "Should have error");
    }

    return result;
  }

  onDownloadStarted(install) {
    return this.checkInstall("onDownloadStarted", install, {
      state: "STATE_DOWNLOADING",
      error: 0,
    });
  }

  onDownloadEnded(install) {
    return this.checkInstall("onDownloadEnded", install, {
      state: "STATE_DOWNLOADED",
      error: 0,
    });
  }

  onDownloadFailed(install) {
    return this.checkInstall("onDownloadFailed", install, {
      state: "STATE_FAILED",
    });
  }

  onDownloadCancelled(install) {
    return this.checkInstall("onDownloadCancelled", install, {
      state: "STATE_CANCELLED",
      error: 0,
    });
  }

  onInstallStarted(install) {
    return this.checkInstall("onInstallStarted", install, {
      state: "STATE_INSTALLING",
      error: 0,
    });
  }

  onInstallEnded(install) {
    return this.checkInstall("onInstallEnded", install, {
      state: "STATE_INSTALLED",
      error: 0,
    });
  }

  onInstallFailed(install) {
    return this.checkInstall("onInstallFailed", install, {
      state: "STATE_FAILED",
    });
  }

  onInstallCancelled(install) {
    // If the install was cancelled by a listener returning false from
    // onInstallStarted, then the state will revert to STATE_DOWNLOADED.
    return this.checkInstall("onInstallCancelled", install, {
      state: ["STATE_CANCELED", "STATE_DOWNLOADED"],
      error: 0,
    });
  }

  onExternalInstall(addon, existingAddon, requiresRestart) {
    if (this.ignorePlugins && addon.type == "plugin") {
      info(`Ignoring install event for plugin ${addon.id}`);
      return undefined;
    }
    let expected = this.expectedInstalls.shift();
    Assert.ok(expected, "Should be expecting install event");

    Assert.equal(
      expected.event,
      "onExternalInstall",
      "Should be expecting onExternalInstall event"
    );
    Assert.ok(!requiresRestart, "Should never require restart");

    this.checkComplete();
    if ("returnValue" in expected) {
      return expected.returnValue;
    }
    return undefined;
  }
}

/**
 * Run the giving callback function, and expect the given set of add-on
 * and install listener events to be emitted, and returns a promise
 * which resolves when they have all been observed.
 *
 * If `callback` returns a promise, all events are expected to be
 * observed by the time the promise resolves. If not, simply waits for
 * all events to be observed before resolving the returned promise.
 *
 * @param {object} details
 * @param {function} callback
 * @returns {Promise}
 */
/* exported expectEvents */
async function expectEvents(details, callback) {
  let checker = new EventChecker(details);

  try {
    let result = callback();

    if (
      result &&
      typeof result === "object" &&
      typeof result.then === "function"
    ) {
      result = await result;
      checker.ensureComplete();
    } else {
      await checker.finished;
    }

    return result;
  } catch (e) {
    do_throw(e);
    return undefined;
  }
}

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, true);

Services.prefs.setBoolPref("extensions.experiments.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();
}

async function mockGfxBlocklistItemsFromDisk(path) {
  let response = await fetch(Services.io.newFileURI(do_get_file(path)).spec);
  let json = await response.json();
  return mockGfxBlocklistItems(json);
}

async function mockGfxBlocklistItems(items) {
  const { generateUUID } = Services.uuid;
  const { BlocklistPrivate } = ChromeUtils.importESModule(
    "resource://gre/modules/Blocklist.sys.mjs"
  );
  const client = RemoteSettings("gfx", {
    bucketName: "blocklists",
  });
  const records = items.map(item => {
    if (item.id && item.last_modified) {
      return item;
    }
    return {
      id: generateUUID().toString().replace(/[{}]/g, ""),
      last_modified: Date.now(),
      ...item,
    };
  });
  const collectionTimestamp = Math.max(...records.map(r => r.last_modified));
  await client.db.importChanges({}, collectionTimestamp, records, {
    clear: true,
  });
  let rv = await BlocklistPrivate.GfxBlocklistRS.checkForEntries();
  return rv;
}

/**
 * Change the schema version of the JSON extensions database
 */
async function changeXPIDBVersion(aNewVersion) {
  let json = await IOUtils.readJSON(gExtensionsJSON.path);
  json.schemaVersion = aNewVersion;
  await IOUtils.writeJSON(gExtensionsJSON.path, json);
}

async function setInitialState(addon, initialState) {
  if (initialState.userDisabled) {
    await addon.disable();
  } else if (initialState.userDisabled === false) {
    await addon.enable();
  }
}

async function setupBuiltinExtension(extensionData, location = "ext-test") {
  let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData);

  // The built-in location requires a resource: URL that maps to a
  // jar: or file: URL.  This would typically be something bundled
  // into omni.ja but for testing we just use a temp file.
  let base = Services.io.newURI(`jar:file:${xpi.path}!/`);
  let resProto = Services.io
    .getProtocolHandler("resource")
    .QueryInterface(Ci.nsIResProtocolHandler);
  resProto.setSubstitution(location, base);
}

async function installBuiltinExtension(extensionData, waitForStartup = true) {
  await setupBuiltinExtension(extensionData);

  let id =
    extensionData.manifest?.browser_specific_settings?.gecko?.id ||
    extensionData.manifest?.applications?.gecko?.id;
  let wrapper = ExtensionTestUtils.expectExtension(id);
  await AddonManager.installBuiltinAddon("resource://ext-test/");
  if (waitForStartup) {
    await wrapper.awaitStartup();
  }
  return wrapper;
}

function useAMOStageCert() {
  // NOTE: add_task internally calls add_test which mutate the add_task properties object,
  // and so we should not reuse the same object as add_task options passed to multiple
  // add_task calls.
  return { pref_set: [["xpinstall.signatures.dev-root", true]] };
}
