/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

var { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
var { MailServices } = ChromeUtils.import(
  "resource:///modules/MailServices.jsm"
);
var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
var { MessageGenerator } = ChromeUtils.import(
  "resource://testing-common/mailnews/MessageGenerator.jsm"
);
var { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import(
  "resource:///modules/ExtensionToolbarButtons.jsm"
);
const { storeState, getState } = ChromeUtils.importESModule(
  "resource:///modules/CustomizationState.mjs"
);
const { getDefaultItemIdsForSpace, getAvailableItemIdsForSpace } =
  ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");

var { ExtensionCommon } = ChromeUtils.importESModule(
  "resource://gre/modules/ExtensionCommon.sys.mjs"
);
var { makeWidgetId } = ExtensionCommon;

// Persistent Listener test functionality
var { assertPersistentListeners } = ExtensionTestUtils.testAssertions;

// There are shutdown issues for which multiple rejections are left uncaught.
// This bug should be fixed, but for the moment this directory is whitelisted.
//
// NOTE: Entire directory whitelisting should be kept to a minimum. Normally you
//       should use "expectUncaughtRejection" to flag individual failures.
const { PromiseTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/PromiseTestUtils.sys.mjs"
);
PromiseTestUtils.allowMatchingRejectionsGlobally(
  /Message manager disconnected/
);
PromiseTestUtils.allowMatchingRejectionsGlobally(/No matching message handler/);
PromiseTestUtils.allowMatchingRejectionsGlobally(
  /Receiving end does not exist/
);

// Adjust timeout to take care of code coverage runs and fission runs to be a
// lot slower.
let originalRequestLongerTimeout = requestLongerTimeout;
// eslint-disable-next-line no-global-assign
requestLongerTimeout = factor => {
  let ccovMultiplier = AppConstants.MOZ_CODE_COVERAGE ? 2 : 1;
  let fissionMultiplier = SpecialPowers.useRemoteSubframes ? 2 : 1;
  originalRequestLongerTimeout(ccovMultiplier * fissionMultiplier * factor);
};
requestLongerTimeout(1);

add_setup(async () => {
  await check3PaneState(true, true);
  let tabmail = document.getElementById("tabmail");
  if (tabmail.tabInfo.length > 1) {
    info(`Will close ${tabmail.tabInfo.length - 1} tabs left over from others`);
    for (let i = tabmail.tabInfo.length - 1; i > 0; i--) {
      tabmail.closeTab(i);
    }
    is(tabmail.tabInfo.length, 1, "One tab open from start");
  }
});
registerCleanupFunction(() => {
  let tabmail = document.getElementById("tabmail");
  is(tabmail.tabInfo.length, 1, "Only one tab open at end of test");

  while (tabmail.tabInfo.length > 1) {
    tabmail.closeTab(tabmail.tabInfo[1]);
  }

  // Some tests that open new windows don't return focus to the main window
  // in a way that satisfies mochitest, and the test times out.
  Services.focus.focusedWindow = window;
  // Focus an element in the main window, then blur it again to avoid it
  // hijacking keypresses.
  let mainWindowElement = document.getElementById("button-appmenu");
  mainWindowElement.focus();
  mainWindowElement.blur();

  MailServices.accounts.accounts.forEach(cleanUpAccount);
  check3PaneState(true, true);

  // The unified toolbar must have been cleaned up. If this fails, check if a
  // test loaded an extension with a browser_action without setting "useAddonManager"
  // to either "temporary" or "permanent", which triggers onUninstalled to be
  // called on extension unload.
  let cachedAllowedSpaces = getCachedAllowedSpaces();
  is(
    cachedAllowedSpaces.size,
    0,
    `Stored known extension spaces should be cleared: ${JSON.stringify(
      Object.fromEntries(cachedAllowedSpaces)
    )}`
  );
  setCachedAllowedSpaces(new Map());
  Services.prefs.clearUserPref("mail.pane_config.dynamic");
  Services.xulStore.removeValue(
    "chrome://messenger/content/messenger.xhtml",
    "threadPane",
    "view"
  );
});

/**
 * Enforce a certain state in the unified toolbar.
 * @param {Object} state - A dictionary with arrays of buttons assigned to a space
 */
async function enforceState(state) {
  const stateChangeObserved = TestUtils.topicObserved(
    "unified-toolbar-state-change"
  );
  storeState(state);
  await stateChangeObserved;
}

async function check3PaneState(folderPaneOpen = null, messagePaneOpen = null) {
  let tabmail = document.getElementById("tabmail");
  let tab = tabmail.currentTabInfo;
  if (tab.chromeBrowser.contentDocument.readyState != "complete") {
    await BrowserTestUtils.waitForEvent(
      tab.chromeBrowser.contentWindow,
      "load"
    );
  }

  let { paneLayout } = tabmail.currentAbout3Pane;
  if (folderPaneOpen !== null) {
    Assert.equal(
      paneLayout.folderPaneVisible,
      folderPaneOpen,
      "State of folder pane splitter is correct"
    );
    paneLayout.folderPaneVisible = folderPaneOpen;
  }

  if (messagePaneOpen !== null) {
    Assert.equal(
      paneLayout.messagePaneVisible,
      messagePaneOpen,
      "State of message pane splitter is correct"
    );
    paneLayout.messagePaneVisible = messagePaneOpen;
  }
}

function createAccount(type = "none") {
  let account;

  if (type == "local") {
    MailServices.accounts.createLocalMailAccount();
    account = MailServices.accounts.FindAccountForServer(
      MailServices.accounts.localFoldersServer
    );
  } else {
    account = MailServices.accounts.createAccount();
    account.incomingServer = MailServices.accounts.createIncomingServer(
      `${account.key}user`,
      "localhost",
      type
    );
  }

  info(`Created account ${account.toString()}`);
  return account;
}

function cleanUpAccount(account) {
  // If the current displayed message/folder belongs to the account to be removed,
  // select the root folder, otherwise the removal of this account will trigger
  // a "shouldn't have any listeners left" assertion in nsMsgDatabase.cpp.
  let [folder] = window.GetSelectedMsgFolders();
  if (folder && folder.server && folder.server == account.incomingServer) {
    let tabmail = document.getElementById("tabmail");
    tabmail.currentAbout3Pane.displayFolder(folder.server.rootFolder.URI);
  }

  let serverKey = account.incomingServer.key;
  let serverType = account.incomingServer.type;
  info(
    `Cleaning up ${serverType} account ${account.key} and server ${serverKey}`
  );
  MailServices.accounts.removeAccount(account, true);

  try {
    let server = MailServices.accounts.getIncomingServer(serverKey);
    if (server) {
      info(`Cleaning up leftover ${serverType} server ${serverKey}`);
      MailServices.accounts.removeIncomingServer(server, false);
    }
  } catch (e) {}
}

function addIdentity(account, email = "mochitest@localhost") {
  let identity = MailServices.accounts.createIdentity();
  identity.email = email;
  account.addIdentity(identity);
  if (!account.defaultIdentity) {
    account.defaultIdentity = identity;
  }
  info(`Created identity ${identity.toString()}`);
  return identity;
}

async function createSubfolder(parent, name) {
  parent.createSubfolder(name, null);
  return parent.getChildNamed(name);
}

function createMessages(folder, makeMessagesArg) {
  if (typeof makeMessagesArg == "number") {
    makeMessagesArg = { count: makeMessagesArg };
  }
  if (!createMessages.messageGenerator) {
    createMessages.messageGenerator = new MessageGenerator();
  }

  let messages = createMessages.messageGenerator.makeMessages(makeMessagesArg);
  let messageStrings = messages.map(message => message.toMboxString());
  folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
  folder.addMessageBatch(messageStrings);
}

async function createMessageFromFile(folder, path) {
  let message = await IOUtils.readUTF8(path);

  // A cheap hack to make this acceptable to addMessageBatch. It works for
  // existing uses but may not work for future uses.
  let fromAddress = message.match(/From: .* <(.*@.*)>/)[0];
  message = `From ${fromAddress}\r\n${message}`;

  folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
  folder.addMessageBatch([message]);
  folder.callFilterPlugins(null);
}

async function promiseAnimationFrame(win = window) {
  await new Promise(win.requestAnimationFrame);
  // dispatchToMainThread throws if used as the first argument of Promise.
  return new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
}

async function focusWindow(win) {
  if (Services.focus.activeWindow == win) {
    return;
  }

  let promise = new Promise(resolve => {
    win.addEventListener(
      "focus",
      function () {
        resolve();
      },
      { capture: true, once: true }
    );
  });

  win.focus();
  await promise;
}

function promisePopupShown(popup) {
  return new Promise(resolve => {
    if (popup.state == "open") {
      resolve();
    } else {
      let onPopupShown = event => {
        popup.removeEventListener("popupshown", onPopupShown);
        resolve();
      };
      popup.addEventListener("popupshown", onPopupShown);
    }
  });
}

function getPanelForNode(node) {
  while (node.localName != "panel") {
    node = node.parentNode;
  }
  return node;
}

/**
 * Wait until the browser is fully loaded.
 *
 * @param {xul:browser} browser - A xul:browser.
 * @param {string|function} [wantLoad = null] - If a function, takes a URL and
 *   returns true if that's the load we're interested in. If a string, gives the
 *   URL of the load we're interested in. If not present, the first load resolves
 *   the promise.
 *
 * @returns {Promise} When a load event is triggered for the browser or the browser
 *   is already fully loaded.
 */
function awaitBrowserLoaded(browser, wantLoad) {
  let testFn = () => true;
  if (wantLoad) {
    testFn = typeof wantLoad === "function" ? wantLoad : url => url == wantLoad;
  }

  return TestUtils.waitForCondition(
    () =>
      browser.ownerGlobal.document.readyState === "complete" &&
      (browser.webProgress?.isLoadingDocument === false ||
        browser.contentDocument?.readyState === "complete") &&
      browser.currentURI &&
      testFn(browser.currentURI.spec),
    "Browser should be loaded"
  );
}

var awaitExtensionPanel = async function (
  extension,
  win = window,
  awaitLoad = true
) {
  let { originalTarget: browser } = await BrowserTestUtils.waitForEvent(
    win.document,
    "WebExtPopupLoaded",
    true,
    event => event.detail.extension.id === extension.id
  );

  if (awaitLoad) {
    await awaitBrowserLoaded(browser, url => url != "about:blank");
  }
  await promisePopupShown(getPanelForNode(browser));

  return browser;
};

function getBrowserActionPopup(extension, win = window) {
  return win.top.document.getElementById("webextension-remote-preload-panel");
}

function closeBrowserAction(extension, win = window) {
  let popup = getBrowserActionPopup(extension, win);
  let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
  popup.hidePopup();

  return hidden;
}

async function openNewMailWindow(options = {}) {
  if (!options.newAccountWizard) {
    Services.prefs.setBoolPref(
      "mail.provider.suppress_dialog_on_startup",
      true
    );
  }

  let win = window.openDialog(
    "chrome://messenger/content/messenger.xhtml",
    "_blank",
    "chrome,all,dialog=no"
  );
  await Promise.all([
    BrowserTestUtils.waitForEvent(win, "focus", true),
    BrowserTestUtils.waitForEvent(win, "activate", true),
  ]);

  return win;
}

async function openComposeWindow(account) {
  let params = Cc[
    "@mozilla.org/messengercompose/composeparams;1"
  ].createInstance(Ci.nsIMsgComposeParams);
  let composeFields = Cc[
    "@mozilla.org/messengercompose/composefields;1"
  ].createInstance(Ci.nsIMsgCompFields);

  params.identity = account.defaultIdentity;
  params.composeFields = composeFields;

  let composeWindowPromise = BrowserTestUtils.domWindowOpened(
    undefined,
    async win => {
      await BrowserTestUtils.waitForEvent(win, "load");
      if (
        win.document.documentURI !=
        "chrome://messenger/content/messengercompose/messengercompose.xhtml"
      ) {
        return false;
      }
      await BrowserTestUtils.waitForEvent(win, "compose-editor-ready");
      return true;
    }
  );
  MailServices.compose.OpenComposeWindowWithParams(null, params);
  return composeWindowPromise;
}

async function openMessageInTab(msgHdr) {
  if (!msgHdr.QueryInterface(Ci.nsIMsgDBHdr)) {
    throw new Error("No message passed to openMessageInTab");
  }

  // Ensure the behaviour pref is set to open a new tab. It is the default,
  // but you never know.
  let oldPrefValue = Services.prefs.getIntPref("mail.openMessageBehavior");
  Services.prefs.setIntPref(
    "mail.openMessageBehavior",
    MailConsts.OpenMessageBehavior.NEW_TAB
  );
  MailUtils.displayMessages([msgHdr]);
  Services.prefs.setIntPref("mail.openMessageBehavior", oldPrefValue);

  let win = Services.wm.getMostRecentWindow("mail:3pane");
  let tab = win.document.getElementById("tabmail").currentTabInfo;
  await BrowserTestUtils.waitForEvent(tab.chromeBrowser, "MsgLoaded");
  return tab;
}

async function openMessageInWindow(msgHdr) {
  if (!msgHdr.QueryInterface(Ci.nsIMsgDBHdr)) {
    throw new Error("No message passed to openMessageInWindow");
  }

  let messageWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
    undefined,
    async win =>
      win.document.documentURI ==
      "chrome://messenger/content/messageWindow.xhtml"
  );
  MailUtils.openMessageInNewWindow(msgHdr);

  let messageWindow = await messageWindowPromise;
  await BrowserTestUtils.waitForEvent(messageWindow, "MsgLoaded");
  return messageWindow;
}

async function promiseMessageLoaded(browser, msgHdr) {
  let messageURI = msgHdr.folder.getUriForMsg(msgHdr);
  messageURI = MailServices.messageServiceFromURI(messageURI).getUrlForUri(
    messageURI,
    null
  );

  await awaitBrowserLoaded(browser, uri => uri == messageURI.spec);
}

/**
 * Check the headers of an open compose window against expected values.
 *
 * @param {object} expected - A dictionary of expected headers.
 *    Omit headers that should have no value.
 * @param {string[]} [fields.to]
 * @param {string[]} [fields.cc]
 * @param {string[]} [fields.bcc]
 * @param {string[]} [fields.replyTo]
 * @param {string[]} [fields.followupTo]
 * @param {string[]} [fields.newsgroups]
 * @param {string} [fields.subject]
 */
async function checkComposeHeaders(expected) {
  let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
  is(composeWindows.length, 1);
  let composeDocument = composeWindows[0].document;
  let composeFields = composeWindows[0].gMsgCompose.compFields;

  await new Promise(resolve => composeWindows[0].setTimeout(resolve));

  if ("identityId" in expected) {
    is(composeWindows[0].getCurrentIdentityKey(), expected.identityId);
  }

  if (expected.attachVCard) {
    is(
      expected.attachVCard,
      composeFields.attachVCard,
      "attachVCard in window should be correct"
    );
  }

  let checkField = (fieldName, elementId) => {
    let pills = composeDocument
      .getElementById(elementId)
      .getElementsByTagName("mail-address-pill");

    if (fieldName in expected) {
      is(
        pills.length,
        expected[fieldName].length,
        `${fieldName} has the right number of pills`
      );
      for (let i = 0; i < expected[fieldName].length; i++) {
        is(pills[i].label, expected[fieldName][i]);
      }
    } else {
      is(pills.length, 0, `${fieldName} is empty`);
    }
  };

  checkField("to", "addressRowTo");
  checkField("cc", "addressRowCc");
  checkField("bcc", "addressRowBcc");
  checkField("replyTo", "addressRowReply");
  checkField("followupTo", "addressRowFollowup");
  checkField("newsgroups", "addressRowNewsgroups");

  let subject = composeDocument.getElementById("msgSubject").value;
  if ("subject" in expected) {
    is(subject, expected.subject, "subject is correct");
  } else {
    is(subject, "", "subject is empty");
  }

  if (expected.overrideDefaultFcc) {
    if (expected.overrideDefaultFccFolder) {
      let server = MailServices.accounts.getAccount(
        expected.overrideDefaultFccFolder.accountId
      ).incomingServer;
      let rootURI = server.rootFolder.URI;
      is(
        rootURI + expected.overrideDefaultFccFolder.path,
        composeFields.fcc,
        "fcc should be correct"
      );
    } else {
      ok(
        composeFields.fcc.startsWith("nocopy://"),
        "fcc should start with nocopy://"
      );
    }
  } else {
    is("", composeFields.fcc, "fcc should be empty");
  }

  if (expected.additionalFccFolder) {
    let server = MailServices.accounts.getAccount(
      expected.additionalFccFolder.accountId
    ).incomingServer;
    let rootURI = server.rootFolder.URI;
    is(
      rootURI + expected.additionalFccFolder.path,
      composeFields.fcc2,
      "fcc2 should be correct"
    );
  } else {
    ok(
      composeFields.fcc2 == "" || composeFields.fcc2.startsWith("nocopy://"),
      "fcc2 should not contain a folder uri"
    );
  }

  if (expected.hasOwnProperty("priority")) {
    is(
      composeFields.priority.toLowerCase(),
      expected.priority == "normal" ? "" : expected.priority,
      "priority in composeFields should be correct"
    );
  }

  if (expected.hasOwnProperty("returnReceipt")) {
    is(
      composeFields.returnReceipt,
      expected.returnReceipt,
      "returnReceipt in composeFields should be correct"
    );
    for (let item of composeDocument.querySelectorAll(`menuitem[command="cmd_toggleReturnReceipt"],
    toolbarbutton[command="cmd_toggleReturnReceipt"]`)) {
      is(
        item.getAttribute("checked") == "true",
        expected.returnReceipt,
        "returnReceipt in window should be correct"
      );
    }
  }

  if (expected.hasOwnProperty("deliveryStatusNotification")) {
    is(
      composeFields.DSN,
      !!expected.deliveryStatusNotification,
      "deliveryStatusNotification in composeFields should be correct"
    );
    is(
      composeDocument.getElementById("dsnMenu").getAttribute("checked") ==
        "true",
      !!expected.deliveryStatusNotification,
      "deliveryStatusNotification in window should be correct"
    );
  }

  if (expected.hasOwnProperty("deliveryFormat")) {
    const deliveryFormats = {
      auto: Ci.nsIMsgCompSendFormat.Auto,
      plaintext: Ci.nsIMsgCompSendFormat.PlainText,
      html: Ci.nsIMsgCompSendFormat.HTML,
      both: Ci.nsIMsgCompSendFormat.Both,
    };
    const formatToId = new Map([
      [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"],
      [Ci.nsIMsgCompSendFormat.HTML, "format_html"],
      [Ci.nsIMsgCompSendFormat.Both, "format_both"],
      [Ci.nsIMsgCompSendFormat.Auto, "format_auto"],
    ]);
    let expectedFormat = deliveryFormats[expected.deliveryFormat || "auto"];
    is(
      expectedFormat,
      composeFields.deliveryFormat,
      "deliveryFormat in composeFields should be correct"
    );
    for (let [format, id] of formatToId.entries()) {
      let menuitem = composeDocument.getElementById(id);
      is(
        format == expectedFormat,
        menuitem.getAttribute("checked") == "true",
        "checked state of the deliveryFormat menu item <${id}> in window should be correct"
      );
    }
  }
}

async function synthesizeMouseAtCenterAndRetry(selector, event, browser) {
  let success = false;
  let type = event.type || "click";
  for (let retries = 0; !success && retries < 2; retries++) {
    let clickPromise = BrowserTestUtils.waitForContentEvent(browser, type).then(
      () => true
    );
    // Linux: Sometimes the actor used to simulate the mouse event in the content process does not
    // react, even though the content page signals to be fully loaded. There is no status signal
    // we could wait for, the loaded page *should* be ready at this point. To mitigate, we wait
    // for the click event and if we do not see it within a certain time, we click again.
    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    let failPromise = new Promise(r =>
      browser.ownerGlobal.setTimeout(r, 500)
    ).then(() => false);

    await BrowserTestUtils.synthesizeMouseAtCenter(selector, event, browser);
    success = await Promise.race([clickPromise, failPromise]);
  }
  Assert.ok(success, `Should have received ${type} event.`);
}

async function openContextMenu(selector = "#img1", win = window) {
  let contentAreaContextMenu = win.document.getElementById("browserContext");
  let popupShownPromise = BrowserTestUtils.waitForEvent(
    contentAreaContextMenu,
    "popupshown"
  );
  let tabmail = document.getElementById("tabmail");
  await synthesizeMouseAtCenterAndRetry(
    selector,
    { type: "mousedown", button: 2 },
    tabmail.selectedBrowser
  );
  await synthesizeMouseAtCenterAndRetry(
    selector,
    { type: "contextmenu" },
    tabmail.selectedBrowser
  );
  await popupShownPromise;
  return contentAreaContextMenu;
}

async function openContextMenuInPopup(extension, selector, win = window) {
  let contentAreaContextMenu =
    win.top.document.getElementById("browserContext");
  let stack = getBrowserActionPopup(extension, win);
  let browser = stack.querySelector("browser");
  let popupShownPromise = BrowserTestUtils.waitForEvent(
    contentAreaContextMenu,
    "popupshown"
  );
  await synthesizeMouseAtCenterAndRetry(
    selector,
    { type: "mousedown", button: 2 },
    browser
  );
  await synthesizeMouseAtCenterAndRetry(
    selector,
    { type: "contextmenu" },
    browser
  );
  await popupShownPromise;
  return contentAreaContextMenu;
}

async function closeExtensionContextMenu(
  itemToSelect,
  modifiers = {},
  win = window
) {
  let contentAreaContextMenu =
    win.top.document.getElementById("browserContext");
  let popupHiddenPromise = BrowserTestUtils.waitForEvent(
    contentAreaContextMenu,
    "popuphidden"
  );
  if (itemToSelect) {
    itemToSelect.closest("menupopup").activateItem(itemToSelect, modifiers);
  } else {
    contentAreaContextMenu.hidePopup();
  }
  await popupHiddenPromise;

  // Bug 1351638: parent menu fails to close intermittently, make sure it does.
  contentAreaContextMenu.hidePopup();
}

async function openSubmenu(submenuItem, win = window) {
  const submenu = submenuItem.menupopup;
  const shown = BrowserTestUtils.waitForEvent(submenu, "popupshown");
  submenuItem.openMenu(true);
  await shown;
  return submenu;
}

async function closeContextMenu(contextMenu) {
  let contentAreaContextMenu =
    contextMenu || document.getElementById("browserContext");
  let popupHiddenPromise = BrowserTestUtils.waitForEvent(
    contentAreaContextMenu,
    "popuphidden"
  );
  contentAreaContextMenu.hidePopup();
  await popupHiddenPromise;
}

async function getUtilsJS() {
  let response = await fetch(getRootDirectory(gTestPath) + "utils.js");
  return response.text();
}

async function checkContent(browser, expected) {
  await SpecialPowers.spawn(browser, [expected], expected => {
    let body = content.document.body;
    Assert.ok(body, "body");
    let computedStyle = content.getComputedStyle(body);

    if ("backgroundColor" in expected) {
      Assert.equal(
        computedStyle.backgroundColor,
        expected.backgroundColor,
        "backgroundColor"
      );
    }
    if ("color" in expected) {
      Assert.equal(computedStyle.color, expected.color, "color");
    }
    if ("foo" in expected) {
      Assert.equal(body.getAttribute("foo"), expected.foo, "foo");
    }
    if ("textContent" in expected) {
      // In message display, we only really want the message body, but the
      // document body also has headers. For the purposes of these tests,
      // we can just select an descendant node, since what really matters is
      // whether (or not) a script ran, not the exact result.
      body = body.querySelector(".moz-text-flowed") ?? body;
      Assert.equal(body.textContent, expected.textContent, "textContent");
    }
  });
}

function contentTabOpenPromise(tabmail, url) {
  return new Promise(resolve => {
    let tabMonitor = {
      onTabTitleChanged(aTab) {},
      onTabClosing(aTab) {},
      onTabPersist(aTab) {},
      onTabRestored(aTab) {},
      onTabSwitched(aNewTab, aOldTab) {},
      async onTabOpened(aTab) {
        let result = awaitBrowserLoaded(
          aTab.linkedBrowser,
          urlToMatch => urlToMatch == url
        ).then(() => aTab);

        let reporterListener = {
          QueryInterface: ChromeUtils.generateQI([
            "nsIWebProgressListener",
            "nsISupportsWeakReference",
          ]),
          onStateChange() {},
          onProgressChange() {},
          onLocationChange(
            /* in nsIWebProgress*/ aWebProgress,
            /* in nsIRequest*/ aRequest,
            /* in nsIURI*/ aLocation
          ) {
            if (aLocation.spec == url) {
              aTab.browser.removeProgressListener(reporterListener);
              tabmail.unregisterTabMonitor(tabMonitor);
              TestUtils.executeSoon(() => resolve(result));
            }
          },
          onStatusChange() {},
          onSecurityChange() {},
          onContentBlockingEvent() {},
        };
        aTab.browser.addProgressListener(reporterListener);
      },
    };
    tabmail.registerTabMonitor(tabMonitor);
  });
}

/**
 * @typedef ConfigData
 * @property {string} actionType - type of action button in underscore notation
 * @property {string} window - the window to perform the test in
 * @property {string} [testType] - supported tests are "open-with-mouse-click" and
 *   "open-with-menu-command"
 * @property {string} [default_area] - area to be used for the test
 * @property {boolean} [use_default_popup] - select if the default_popup should be
 *  used for the test
 * @property {boolean} [disable_button] - select if the button should be disabled
 * @property {Function} [backend_script] - custom backend script to be used for the
 *  test, will override the default backend_script of the selected test
 * @property {Function} [background_script] - custom background script to be used for the
 *  test, will override the default background_script of the selected test
 * @property {[string]} [permissions] - custom permissions to be used for the test,
 *  must not be specified together with testType
 */

/**
 * Creates an extension with an action button and either runs one of the default
 * tests, or loads a custom background script and a custom backend scripts to run
 * an arbitrary test.
 *
 * @param {ConfigData} configData - test configuration
 */
async function run_popup_test(configData) {
  if (!configData.actionType) {
    throw new Error("Mandatory configData.actionType is missing");
  }
  if (!configData.window) {
    throw new Error("Mandatory configData.window is missing");
  }

  // Get camelCase API names from action type.
  configData.apiName = configData.actionType.replace(/_([a-z])/g, function (g) {
    return g[1].toUpperCase();
  });
  configData.moduleName =
    configData.actionType == "action" ? "browserAction" : configData.apiName;

  let backend_script = configData.backend_script;

  let extensionDetails = {
    files: {
      "popup.html": `<!DOCTYPE html>
                      <html>
                        <head>
                          <title>Popup</title>
                        </head>
                        <body>
                          <p>Hello</p>
                          <script src="popup.js"></script>
                        </body>
                      </html>`,
      "popup.js": async function () {
        // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
        await new Promise(resolve => window.setTimeout(resolve, 1000));
        await browser.runtime.sendMessage("popup opened");
        await new Promise(resolve => window.setTimeout(resolve));
        window.close();
      },
      "utils.js": await getUtilsJS(),
      "helper.js": function () {
        window.actionType = browser.runtime.getManifest().description;
        // Get camelCase API names from action type.
        window.apiName = window.actionType.replace(/_([a-z])/g, function (g) {
          return g[1].toUpperCase();
        });
        window.getPopupOpenedPromise = function () {
          return new Promise(resolve => {
            const handleMessage = async (message, sender, sendResponse) => {
              if (message && message == "popup opened") {
                sendResponse();
                window.setTimeout(resolve);
                browser.runtime.onMessage.removeListener(handleMessage);
              }
            };
            browser.runtime.onMessage.addListener(handleMessage);
          });
        };
      },
    },
    manifest: {
      manifest_version: configData.manifest_version || 2,
      browser_specific_settings: {
        gecko: {
          id: `${configData.actionType}@mochi.test`,
        },
      },
      description: configData.actionType,
      background: { scripts: ["utils.js", "helper.js", "background.js"] },
    },
    useAddonManager: "temporary",
  };

  switch (configData.testType) {
    case "open-with-mouse-click":
      backend_script = async function (extension, configData) {
        let win = configData.window;

        await extension.startup();
        await promiseAnimationFrame(win);
        await new Promise(resolve => win.setTimeout(resolve));
        await extension.awaitMessage("ready");

        let buttonId = `${configData.actionType}_mochi_test-${configData.moduleName}-toolbarbutton`;
        let toolbarId;
        switch (configData.actionType) {
          case "compose_action":
            toolbarId = "composeToolbar2";
            if (configData.default_area == "formattoolbar") {
              toolbarId = "FormatToolbar";
            }
            break;
          case "action":
          case "browser_action":
            if (configData.default_windows?.join(",") === "messageDisplay") {
              toolbarId = "mail-bar3";
            } else {
              toolbarId = "unified-toolbar";
            }
            break;
          case "message_display_action":
            toolbarId = "header-view-toolbar";
            break;
          default:
            throw new Error(
              `Unsupported configData.actionType: ${configData.actionType}`
            );
        }

        let toolbar, button;
        if (toolbarId === "unified-toolbar") {
          toolbar = win.document.querySelector("unified-toolbar");
          button = win.document.querySelector(
            `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
          );
        } else {
          toolbar = win.document.getElementById(toolbarId);
          button = win.document.getElementById(buttonId);
        }
        ok(button, "Button created");
        ok(toolbar.contains(button), "Button added to toolbar");
        let label;
        if (toolbarId === "unified-toolbar") {
          const state = getState();
          const itemId = `ext-${configData.actionType}@mochi.test`;
          if (state.mail) {
            ok(
              state.mail.includes(itemId),
              "Button should be in unified toolbar mail space"
            );
          }
          ok(
            getDefaultItemIdsForSpace("mail").includes(itemId),
            "Button should be in default set for unified toolbar mail space"
          );
          ok(
            getAvailableItemIdsForSpace("mail").includes(itemId),
            "Button should be available in unified toolbar mail space"
          );

          let icon = button.querySelector(".button-icon");
          is(
            getComputedStyle(icon).content,
            `url("chrome://messenger/content/extension.svg")`,
            "Default icon"
          );
          label = button.querySelector(".button-label");
          is(label.textContent, "This is a test", "Correct label");
        } else {
          if (toolbar.hasAttribute("customizable")) {
            ok(
              toolbar.currentSet.split(",").includes(buttonId),
              `Button should have been added to currentSet property of toolbar ${toolbarId}`
            );
            ok(
              toolbar.getAttribute("currentset").split(",").includes(buttonId),
              `Button should have been added to currentset attribute of toolbar ${toolbarId}`
            );
          }
          ok(
            Services.xulStore
              .getValue(win.location.href, toolbarId, "currentset")
              .split(",")
              .includes(buttonId),
            `Button should have been added to currentset xulStore of toolbar ${toolbarId}`
          );

          let icon = button.querySelector(".toolbarbutton-icon");
          is(
            getComputedStyle(icon).listStyleImage,
            `url("chrome://messenger/content/extension.svg")`,
            "Default icon"
          );
          label = button.querySelector(".toolbarbutton-text");
          is(label.value, "This is a test", "Correct label");
        }

        if (
          !configData.use_default_popup &&
          configData?.manifest_version == 3
        ) {
          assertPersistentListeners(
            extension,
            configData.moduleName,
            "onClicked",
            {
              primed: false,
            }
          );
        }
        if (configData.terminateBackground) {
          await extension.terminateBackground({
            disableResetIdleForTest: true,
          });
          if (
            !configData.use_default_popup &&
            configData?.manifest_version == 3
          ) {
            assertPersistentListeners(
              extension,
              configData.moduleName,
              "onClicked",
              {
                primed: true,
              }
            );
          }
        }

        let clickedPromise;
        if (!configData.disable_button) {
          clickedPromise = extension.awaitMessage("actionButtonClicked");
        }
        EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, win);
        if (configData.disable_button) {
          // We're testing that nothing happens. Give it time to potentially happen.
          // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
          await new Promise(resolve => win.setTimeout(resolve, 500));
          // In case the background was terminated, it should not restart.
          // If it does, we will get an extra "ready" message and fail.
          // Listeners should still be primed.
          if (
            configData.terminateBackground &&
            configData?.manifest_version == 3
          ) {
            assertPersistentListeners(
              extension,
              configData.moduleName,
              "onClicked",
              {
                primed: true,
              }
            );
          }
        } else {
          let hasFiredBefore = await clickedPromise;
          await promiseAnimationFrame(win);
          await new Promise(resolve => win.setTimeout(resolve));
          if (toolbarId === "unified-toolbar") {
            is(
              win.document.querySelector(
                `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
              ),
              button
            );
            label = button.querySelector(".button-label");
            is(label.textContent, "New title", "Correct label");
          } else {
            is(win.document.getElementById(buttonId), button);
            label = button.querySelector(".toolbarbutton-text");
            is(label.value, "New title", "Correct label");
          }

          if (configData.terminateBackground) {
            // The onClicked event should have restarted the background script.
            await extension.awaitMessage("ready");
            // Could be undefined, but it must not be true
            is(false, !!hasFiredBefore);
          }
          if (
            !configData.use_default_popup &&
            configData?.manifest_version == 3
          ) {
            assertPersistentListeners(
              extension,
              configData.moduleName,
              "onClicked",
              {
                primed: false,
              }
            );
          }
        }

        // Check the open state of the action button.
        await TestUtils.waitForCondition(
          () => button.getAttribute("open") != "true",
          "Button should not have open state after the popup closed."
        );

        await extension.unload();
        await promiseAnimationFrame(win);
        await new Promise(resolve => win.setTimeout(resolve));

        ok(!win.document.getElementById(buttonId), "Button destroyed");

        if (toolbarId === "unified-toolbar") {
          const state = getState();
          const itemId = `ext-${configData.actionType}@mochi.test`;
          if (state.mail) {
            ok(
              !state.mail.includes(itemId),
              "Button should have been removed from unified toolbar mail space"
            );
          }
          ok(
            !getDefaultItemIdsForSpace("mail").includes(itemId),
            "Button should have been removed from default set for unified toolbar mail space"
          );
          ok(
            !getAvailableItemIdsForSpace("mail").includes(itemId),
            "Button should have no longer be available in unified toolbar mail space"
          );
        } else {
          ok(
            !Services.xulStore
              .getValue(win.top.location.href, toolbarId, "currentset")
              .split(",")
              .includes(buttonId),
            `Button should have been removed from currentset xulStore of toolbar ${toolbarId}`
          );
        }
      };
      if (configData.use_default_popup) {
        // With popup.
        extensionDetails.files["background.js"] = async function () {
          browser.test.log("popup background script ran");
          let popupPromise = window.getPopupOpenedPromise();
          browser.test.sendMessage("ready");
          await popupPromise;
          await browser[window.apiName].setTitle({ title: "New title" });
          browser.test.sendMessage("actionButtonClicked");
        };
      } else if (configData.disable_button) {
        // Without popup and disabled button.
        extensionDetails.files["background.js"] = async function () {
          browser.test.log("nopopup & button disabled background script ran");
          browser[window.apiName].onClicked.addListener(async (tab, info) => {
            browser.test.fail(
              "Should not have seen the onClicked event for a disabled button"
            );
          });
          browser[window.apiName].disable();
          browser.test.sendMessage("ready");
        };
      } else {
        // Without popup.
        extensionDetails.files["background.js"] = async function () {
          let hasFiredBefore = false;
          browser.test.log("nopopup background script ran");
          browser[window.apiName].onClicked.addListener(async (tab, info) => {
            browser.test.assertEq("object", typeof tab);
            browser.test.assertEq("object", typeof info);
            browser.test.assertEq(0, info.button);
            browser.test.assertTrue(Array.isArray(info.modifiers));
            browser.test.assertEq(0, info.modifiers.length);
            let [currentTab] = await browser.tabs.query({
              active: true,
              currentWindow: true,
            });
            browser.test.assertEq(
              currentTab.id,
              tab.id,
              "Should find the correct tab"
            );
            await browser[window.apiName].setTitle({ title: "New title" });
            await new Promise(resolve => window.setTimeout(resolve));
            browser.test.sendMessage("actionButtonClicked", hasFiredBefore);
            hasFiredBefore = true;
          });
          browser.test.sendMessage("ready");
        };
      }
      break;

    case "open-with-menu-command":
      extensionDetails.manifest.permissions = ["menus"];
      backend_script = async function (extension, configData) {
        let win = configData.window;
        let buttonId = `${configData.actionType}_mochi_test-${configData.moduleName}-toolbarbutton`;
        let menuId = "toolbar-context-menu";
        let isUnifiedToolbar = false;
        if (
          configData.actionType == "compose_action" &&
          configData.default_area == "formattoolbar"
        ) {
          menuId = "format-toolbar-context-menu";
        }
        if (configData.actionType == "message_display_action") {
          menuId = "header-toolbar-context-menu";
        }
        if (
          (configData.actionType == "browser_action" ||
            configData.actionType == "action") &&
          configData.default_windows?.join(",") !== "messageDisplay"
        ) {
          menuId = "unifiedToolbarMenu";
          isUnifiedToolbar = true;
        }
        const getButton = windowContent => {
          if (isUnifiedToolbar) {
            return windowContent.document.querySelector(
              `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
            );
          }
          return windowContent.document.getElementById(buttonId);
        };

        extension.onMessage("triggerClick", async () => {
          let button = getButton(win);
          let menu = win.document.getElementById(menuId);
          let onShownPromise = extension.awaitMessage("onShown");
          let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
          EventUtils.synthesizeMouseAtCenter(
            button,
            { type: "contextmenu" },
            win
          );
          await shownPromise;
          await onShownPromise;
          await new Promise(resolve => win.setTimeout(resolve));

          let menuitem = win.document.getElementById(
            `${configData.actionType}_mochi_test-menuitem-_testmenu`
          );
          Assert.ok(menuitem);
          menuitem.parentNode.activateItem(menuitem);

          // Sometimes, the popup will open then instantly disappear. It seems to
          // still be hiding after the previous appearance. If we wait a little bit,
          // this doesn't happen.
          // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
          await new Promise(r => win.setTimeout(r, 250));
          extension.sendMessage();
        });

        await extension.startup();
        await extension.awaitFinish();

        // Check the open state of the action button.
        let button = getButton(win);
        await TestUtils.waitForCondition(
          () => button.getAttribute("open") != "true",
          "Button should not have open state after the popup closed."
        );

        await extension.unload();
      };
      if (configData.use_default_popup) {
        // With popup.
        extensionDetails.files["background.js"] = async function () {
          browser.test.log("popup background script ran");
          await new Promise(resolve => {
            browser.menus.create(
              {
                id: "testmenu",
                title: `Open ${window.actionType}`,
                contexts: [window.actionType],
                command: `_execute_${window.actionType}`,
              },
              resolve
            );
          });

          await browser.menus.onShown.addListener((...args) => {
            browser.test.sendMessage("onShown", args);
          });

          let popupPromise = window.getPopupOpenedPromise();
          await window.sendMessage("triggerClick");
          await popupPromise;

          browser.test.notifyPass();
        };
      } else if (configData.disable_button) {
        // Without popup and disabled button.
        extensionDetails.files["background.js"] = async function () {
          browser.test.log("nopopup & button disabled background script ran");
          await new Promise(resolve => {
            browser.menus.create(
              {
                id: "testmenu",
                title: `Open ${window.actionType}`,
                contexts: [window.actionType],
                command: `_execute_${window.actionType}`,
              },
              resolve
            );
          });

          await browser.menus.onShown.addListener((...args) => {
            browser.test.sendMessage("onShown", args);
          });

          browser[window.apiName].onClicked.addListener(async (tab, info) => {
            browser.test.fail(
              "Should not have seen the onClicked event for a disabled button"
            );
          });

          await browser[window.apiName].disable();
          await window.sendMessage("triggerClick");
          browser.test.notifyPass();
        };
      } else {
        // Without popup.
        extensionDetails.files["background.js"] = async function () {
          browser.test.log("nopopup background script ran");
          await new Promise(resolve => {
            browser.menus.create(
              {
                id: "testmenu",
                title: `Open ${window.actionType}`,
                contexts: [window.actionType],
                command: `_execute_${window.actionType}`,
              },
              resolve
            );
          });

          await browser.menus.onShown.addListener((...args) => {
            browser.test.sendMessage("onShown", args);
          });

          let clickPromise = new Promise(resolve => {
            let listener = async (tab, info) => {
              browser[window.apiName].onClicked.removeListener(listener);
              browser.test.assertEq("object", typeof tab);
              browser.test.assertEq("object", typeof info);
              browser.test.assertEq(0, info.button);
              browser.test.assertTrue(Array.isArray(info.modifiers));
              browser.test.assertEq(0, info.modifiers.length);
              browser.test.log(`Tab ID is ${tab.id}`);
              resolve();
            };
            browser[window.apiName].onClicked.addListener(listener);
          });
          await window.sendMessage("triggerClick");
          await clickPromise;

          browser.test.notifyPass();
        };
      }
      break;
  }

  extensionDetails.manifest[configData.actionType] = {
    default_title: "This is a test",
  };
  if (configData.use_default_popup) {
    extensionDetails.manifest[configData.actionType].default_popup =
      "popup.html";
  }
  if (configData.default_area) {
    extensionDetails.manifest[configData.actionType].default_area =
      configData.default_area;
  }
  if (configData.hasOwnProperty("background")) {
    extensionDetails.files["background.js"] = configData.background_script;
  }
  if (configData.hasOwnProperty("permissions")) {
    extensionDetails.manifest.permissions = configData.permissions;
  }
  if (configData.default_windows) {
    extensionDetails.manifest[configData.actionType].default_windows =
      configData.default_windows;
  }

  let extension = ExtensionTestUtils.loadExtension(extensionDetails);
  await backend_script(extension, configData);
}

async function run_action_button_order_test(configs, window, actionType) {
  // Get camelCase API names from action type.
  let apiName = actionType.replace(/_([a-z])/g, function (g) {
    return g[1].toUpperCase();
  });

  function get_id(name) {
    return `${name}_mochi_test-${apiName}-toolbarbutton`;
  }

  function test_buttons(configs, window, toolbars) {
    for (let toolbarId of toolbars) {
      let expected = configs.filter(e => e.toolbar == toolbarId);
      let selector =
        toolbarId === "unified-toolbar"
          ? `#unifiedToolbarContent [extension$="@mochi.test"]`
          : `#${toolbarId} toolbarbutton[id$="${get_id("")}"]`;
      let buttons = window.document.querySelectorAll(selector);
      Assert.equal(
        expected.length,
        buttons.length,
        `Should find the correct number of buttons in ${toolbarId} toolbar`
      );
      for (let i = 0; i < buttons.length; i++) {
        if (toolbarId === "unified-toolbar") {
          Assert.equal(
            `${expected[i].name}@mochi.test`,
            buttons[i].getAttribute("extension"),
            `Should find the correct button at location #${i}`
          );
        } else {
          Assert.equal(
            get_id(expected[i].name),
            buttons[i].id,
            `Should find the correct button at location #${i}`
          );
        }
      }
    }
  }

  // Create extension data.
  let toolbars = new Set();
  for (let config of configs) {
    toolbars.add(config.toolbar);
    config.extensionData = {
      useAddonManager: "permanent",
      manifest: {
        applications: {
          gecko: {
            id: `${config.name}@mochi.test`,
          },
        },
        [actionType]: {
          default_title: config.name,
        },
      },
    };
    if (config.area) {
      config.extensionData.manifest[actionType].default_area = config.area;
    }
    if (config.default_windows) {
      config.extensionData.manifest[actionType].default_windows =
        config.default_windows;
    }
  }

  // Test order of buttons after first install.
  for (let config of configs) {
    config.extension = ExtensionTestUtils.loadExtension(config.extensionData);
    await config.extension.startup();
  }
  test_buttons(configs, window, toolbars);

  // Disable all buttons.
  for (let config of configs) {
    let addon = await AddonManager.getAddonByID(config.extension.id);
    await addon.disable();
  }
  test_buttons([], window, toolbars);

  // Re-enable all buttons in reversed order, displayed order should not change.
  for (let config of [...configs].reverse()) {
    let addon = await AddonManager.getAddonByID(config.extension.id);
    await addon.enable();
  }
  test_buttons(configs, window, toolbars);

  // Re-install all extensions in reversed order, displayed order should not change.
  for (let config of [...configs].reverse()) {
    config.extension2 = ExtensionTestUtils.loadExtension(config.extensionData);
    await config.extension2.startup();
  }
  test_buttons(configs, window, toolbars);

  // Remove all extensions.
  for (let config of [...configs].reverse()) {
    await config.extension.unload();
    await config.extension2.unload();
  }
  test_buttons([], window, toolbars);
}

/**
 * Helper method to switch to a cards view with vertical layout.
 */
async function ensure_cards_view() {
  const { threadTree, threadPane } =
    document.getElementById("tabmail").currentAbout3Pane;

  Services.prefs.setIntPref("mail.pane_config.dynamic", 2);
  Services.xulStore.setValue(
    "chrome://messenger/content/messenger.xhtml",
    "threadPane",
    "view",
    "cards"
  );
  threadPane.updateThreadView("cards");

  await BrowserTestUtils.waitForCondition(
    () => threadTree.getAttribute("rows") == "thread-card",
    "The tree view switched to a cards layout"
  );
}

/**
 * Helper method to switch to a table view with classic layout.
 */
async function ensure_table_view() {
  const { threadTree, threadPane } =
    document.getElementById("tabmail").currentAbout3Pane;

  Services.prefs.setIntPref("mail.pane_config.dynamic", 0);
  Services.xulStore.setValue(
    "chrome://messenger/content/messenger.xhtml",
    "threadPane",
    "view",
    "table"
  );
  threadPane.updateThreadView("table");

  await BrowserTestUtils.waitForCondition(
    () => threadTree.getAttribute("rows") == "thread-row",
    "The tree view switched to a table layout"
  );
}
