summaryrefslogtreecommitdiffstats
path: root/comm/mail/test/browser/shared-modules
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/test/browser/shared-modules')
-rw-r--r--comm/mail/test/browser/shared-modules/.eslintrc.js7
-rw-r--r--comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm204
-rw-r--r--comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm182
-rw-r--r--comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm240
-rw-r--r--comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm278
-rw-r--r--comm/mail/test/browser/shared-modules/ComposeHelpers.jsm2430
-rw-r--r--comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm423
-rw-r--r--comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm121
-rw-r--r--comm/mail/test/browser/shared-modules/DOMHelpers.jsm256
-rw-r--r--comm/mail/test/browser/shared-modules/EventUtils.jsm876
-rw-r--r--comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm3243
-rw-r--r--comm/mail/test/browser/shared-modules/JunkHelpers.jsm97
-rw-r--r--comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm58
-rw-r--r--comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm161
-rw-r--r--comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm226
-rw-r--r--comm/mail/test/browser/shared-modules/NNTPHelpers.jsm123
-rw-r--r--comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm25
-rw-r--r--comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm219
-rw-r--r--comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm329
-rw-r--r--comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm53
-rw-r--r--comm/mail/test/browser/shared-modules/PromptHelpers.jsm271
-rw-r--r--comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm391
-rw-r--r--comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm206
-rw-r--r--comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm82
-rw-r--r--comm/mail/test/browser/shared-modules/ViewHelpers.jsm85
-rw-r--r--comm/mail/test/browser/shared-modules/WindowHelpers.jsm1018
-rw-r--r--comm/mail/test/browser/shared-modules/controller.jsm60
-rw-r--r--comm/mail/test/browser/shared-modules/moz.build34
-rw-r--r--comm/mail/test/browser/shared-modules/utils.jsm130
29 files changed, 11828 insertions, 0 deletions
diff --git a/comm/mail/test/browser/shared-modules/.eslintrc.js b/comm/mail/test/browser/shared-modules/.eslintrc.js
new file mode 100644
index 0000000000..e57058ecb1
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+};
diff --git a/comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm b/comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm
new file mode 100644
index 0000000000..5cd8d6d6bc
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/AccountManagerHelpers.jsm
@@ -0,0 +1,204 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "openAccountProvisioner",
+ "openAccountSetup",
+ "openAccountSettings",
+ "open_advanced_settings",
+ "click_account_tree_row",
+ "get_account_tree_row",
+ "remove_account",
+ "wait_for_account_tree_load",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var fdh = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var wh = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { content_tab_e, open_content_tab_with_url, wait_for_content_tab_load } =
+ ChromeUtils.import("resource://testing-common/mozmill/ContentTabHelpers.jsm");
+
+var mc = fdh.mc;
+
+/**
+ * Waits until the Account Manager tree fully loads after first open.
+ */
+function wait_for_account_tree_load(tab) {
+ utils.waitFor(
+ () => tab.browser.contentWindow.currentAccount != null,
+ "Timeout waiting for currentAccount to become non-null"
+ );
+}
+
+async function openAccountSettings() {
+ return new Promise(resolve => {
+ let tab = open_content_tab_with_url("about:accountsettings");
+ wait_for_account_tree_load(tab);
+ resolve(tab);
+ });
+}
+
+/**
+ * Opens the Account Manager.
+ *
+ * @callback tabCallback
+ *
+ * @param {tabCallback} callback - The callback for the account manager tab that is opened.
+ */
+async function open_advanced_settings(callback) {
+ let tab = open_content_tab_with_url("about:accountsettings");
+ wait_for_account_tree_load(tab);
+ await callback(tab);
+ mc.window.document.getElementById("tabmail").closeTab(tab);
+}
+
+async function openAccountSetup() {
+ return new Promise(resolve => {
+ let tab = open_content_tab_with_url("about:accountsetup");
+ wait_for_content_tab_load(tab, "about:accountsetup", 10000);
+ resolve(tab);
+ });
+}
+
+async function openAccountProvisioner() {
+ return new Promise(resolve => {
+ let tab = open_content_tab_with_url("about:accountprovisioner");
+ wait_for_content_tab_load(tab, "about:accountprovisioner", 10000);
+ resolve(tab);
+ });
+}
+
+/**
+ * Click a row in the account settings tree.
+ *
+ * @param {object} tab - The account manager tab controller that opened.
+ * @param {number} rowIndex - The row to click.
+ */
+function click_account_tree_row(tab, rowIndex) {
+ utils.waitFor(
+ () => tab.browser.contentWindow.currentAccount != null,
+ "Timeout waiting for currentAccount to become non-null"
+ );
+
+ let tree = content_tab_e(tab, "accounttree");
+ tree.selectedIndex = rowIndex;
+
+ utils.waitFor(
+ () => tab.browser.contentWindow.pendingAccount == null,
+ "Timeout waiting for pendingAccount to become null"
+ );
+
+ // Ensure the page is fully loaded (e.g. onInit functions).
+ wh.wait_for_frame_load(
+ content_tab_e(tab, "contentFrame"),
+ tab.browser.contentWindow.pageURL(
+ tree.rows[rowIndex].getAttribute("PageTag")
+ )
+ );
+}
+
+/**
+ * Returns the index of the row in account tree corresponding to the wanted
+ * account and its settings pane.
+ *
+ * @param {number} accountKey - The key of the account to return.
+ * If 'null', the SMTP pane is returned.
+ * @param {number} paneId - The ID of the account settings pane to select.
+ *
+ *
+ * @returns {number} The row index of the account and pane. If it was not found return -1.
+ * Do not throw as callers may intentionally just check if a row exists.
+ * Just dump into the log so that a subsequent throw in
+ * click_account_tree_row has a useful context.
+ */
+function get_account_tree_row(accountKey, paneId, tab) {
+ let accountTree = content_tab_e(tab, "accounttree");
+ let row;
+ if (accountKey && paneId) {
+ row = accountTree.querySelector(`#${accountKey} [PageTag="${paneId}"]`);
+ } else if (accountKey) {
+ row = accountTree.querySelector(`#${accountKey}`);
+ }
+ return accountTree.rows.indexOf(row);
+}
+
+/**
+ * Remove an account via the account manager UI.
+ *
+ * @param {object} account - The account to remove.
+ * @param {object} tab - The account manager tab that opened.
+ * @param {boolean} removeAccount - Remove the account itself.
+ * @param {boolean} removeData - Remove the message data of the account.
+ */
+function remove_account(
+ account,
+ tab,
+ removeAccount = true,
+ removeData = false
+) {
+ let accountRow = get_account_tree_row(account.key, null, tab);
+ click_account_tree_row(tab, accountRow);
+
+ account = null;
+ // Use the Remove item in the Account actions menu.
+ let actionsButton = content_tab_e(tab, "accountActionsButton");
+ EventUtils.synthesizeMouseAtCenter(
+ actionsButton,
+ { clickCount: 1 },
+ actionsButton.ownerGlobal
+ );
+ let actionsDd = content_tab_e(tab, "accountActionsDropdown");
+ utils.waitFor(
+ () => actionsDd.state == "open" || actionsDd.state == "showing"
+ );
+ let remove = content_tab_e(tab, "accountActionsDropdownRemove");
+ EventUtils.synthesizeMouseAtCenter(
+ remove,
+ { clickCount: 1 },
+ remove.ownerGlobal
+ );
+ utils.waitFor(() => actionsDd.state == "closed");
+
+ let cdc = wh.wait_for_frame_load(
+ tab.browser.contentWindow.gSubDialog._topDialog._frame,
+ "chrome://messenger/content/removeAccount.xhtml"
+ );
+
+ // Account removal confirmation dialog. Select what to remove.
+ if (removeAccount) {
+ EventUtils.synthesizeMouseAtCenter(
+ cdc.window.document.getElementById("removeAccount"),
+ {},
+ cdc.window.document.getElementById("removeAccount").ownerGlobal
+ );
+ }
+ if (removeData) {
+ EventUtils.synthesizeMouseAtCenter(
+ cdc.window.document.getElementById("removeData"),
+ {},
+ cdc.window.document.getElementById("removeData").ownerGlobal
+ );
+ }
+
+ cdc.window.document.documentElement.querySelector("dialog").acceptDialog();
+ utils.waitFor(
+ () =>
+ !cdc.window.document.querySelector("dialog").getButton("accept").disabled,
+ "Timeout waiting for finish of account removal",
+ 5000,
+ 100
+ );
+ cdc.window.document.documentElement.querySelector("dialog").acceptDialog();
+}
diff --git a/comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm b/comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm
new file mode 100644
index 0000000000..cce1fd81a1
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/AddressBookHelpers.jsm
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "create_address_book",
+ "create_contact",
+ "create_ldap_address_book",
+ "create_mailing_list",
+ "delete_address_book",
+ "ensure_card_exists",
+ "ensure_no_card_exists",
+ "get_cards_in_all_address_books_for_email",
+ "get_mailing_list_from_address_book",
+ "load_contacts_into_address_book",
+];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var ABJS_PREFIX = "jsaddrbook://";
+var ABLDAP_PREFIX = "moz-abldapdirectory://";
+
+var collectedAddresses;
+
+// Ensure all the directories are initialised.
+MailServices.ab.directories;
+collectedAddresses = MailServices.ab.getDirectory(
+ "jsaddrbook://history.sqlite"
+);
+
+/**
+ * Make sure that there is a card for this email address
+ *
+ * @param emailAddress the address that should have a card
+ * @param displayName the display name the card should have
+ * @param preferDisplayName |true| if the card display name should override the
+ * header display name
+ */
+function ensure_card_exists(emailAddress, displayName, preferDisplayName) {
+ ensure_no_card_exists(emailAddress);
+ let card = create_contact(emailAddress, displayName, preferDisplayName);
+ collectedAddresses.addCard(card);
+}
+
+/**
+ * Make sure that there is no card for this email address
+ *
+ * @param emailAddress the address that should have no cards
+ */
+function ensure_no_card_exists(emailAddress) {
+ for (let ab of MailServices.ab.directories) {
+ try {
+ var card = ab.cardForEmailAddress(emailAddress);
+ if (card) {
+ ab.deleteCards([card]);
+ }
+ } catch (ex) {}
+ }
+}
+
+/**
+ * Return all address book cards for a particular email address
+ *
+ * @param aEmailAddress the address to search for
+ */
+function get_cards_in_all_address_books_for_email(aEmailAddress) {
+ var result = [];
+
+ for (let ab of MailServices.ab.directories) {
+ var card = ab.cardForEmailAddress(aEmailAddress);
+ if (card) {
+ result.push(card);
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Creates and returns a SQLite-backed address book.
+ *
+ * @param aName the name for the address book
+ * @returns the nsIAbDirectory address book
+ */
+function create_address_book(aName) {
+ let abPrefString = MailServices.ab.newAddressBook(aName, "", 101);
+ let abURI = Services.prefs.getCharPref(abPrefString + ".filename");
+ return MailServices.ab.getDirectory(ABJS_PREFIX + abURI);
+}
+
+/**
+ * Creates and returns an LDAP-backed address book.
+ * This function will automatically fill in a dummy
+ * LDAP URI if no URI is supplied.
+ *
+ * @param aName the name for the address book
+ * @param aURI an optional URI for the address book
+ * @returns the nsIAbDirectory address book
+ */
+function create_ldap_address_book(aName, aURI) {
+ if (!aURI) {
+ aURI = "ldap://dummyldap/??sub?(objectclass=*)";
+ }
+ let abPrefString = MailServices.ab.newAddressBook(aName, aURI, 0);
+ return MailServices.ab.getDirectory(ABLDAP_PREFIX + abPrefString);
+}
+
+/**
+ * Creates and returns an address book contact
+ *
+ * @param aEmailAddress the e-mail address for this contact
+ * @param aDisplayName the display name for the contact
+ * @param aPreferDisplayName set to true if the card display name should
+ * override the header display name
+ */
+function create_contact(aEmailAddress, aDisplayName, aPreferDisplayName) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.primaryEmail = aEmailAddress;
+ card.displayName = aDisplayName;
+ card.setProperty("PreferDisplayName", !!aPreferDisplayName);
+ return card;
+}
+
+/* Creates and returns a mailing list
+ * @param aMailingListName the display name for the new mailing list
+ */
+function create_mailing_list(aMailingListName) {
+ var mailList = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ mailList.isMailList = true;
+ mailList.dirName = aMailingListName;
+ return mailList;
+}
+
+/* Finds and returns a mailing list with a given dirName within a
+ * given address book.
+ * @param aAddressBook the address book to search
+ * @param aDirName the dirName of the mailing list
+ */
+function get_mailing_list_from_address_book(aAddressBook, aDirName) {
+ for (let list of aAddressBook.childNodes) {
+ if (list.dirName == aDirName) {
+ return list;
+ }
+ }
+ throw Error("Could not find a mailing list with dirName " + aDirName);
+}
+
+/* Given some address book, adds a collection of contacts to that
+ * address book.
+ * @param aAddressBook an address book to add the contacts to
+ * @param aContacts a collection of nsIAbCards, or contacts,
+ * where each contact has members "email"
+ * and "displayName"
+ *
+ * Example:
+ * [{email: 'test@example.com', displayName: 'Sammy Jenkis'}]
+ */
+function load_contacts_into_address_book(aAddressBook, aContacts) {
+ for (let i = 0; i < aContacts.length; i++) {
+ let contact = aContacts[i];
+ if (!(contact instanceof Ci.nsIAbCard)) {
+ contact = create_contact(contact.email, contact.displayName, true);
+ }
+
+ aContacts[i] = aAddressBook.addCard(contact);
+ }
+}
+
+/**
+ * Deletes an address book.
+ */
+function delete_address_book(aAddrBook) {
+ MailServices.ab.deleteAddressBook(aAddrBook.URI);
+}
diff --git a/comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm b/comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm
new file mode 100644
index 0000000000..971b25fa77
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/AttachmentHelpers.jsm
@@ -0,0 +1,240 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "create_body_part",
+ "create_deleted_attachment",
+ "create_detached_attachment",
+ "create_enclosure_attachment",
+ "gMockFilePicker",
+ "gMockFilePickReg",
+ "select_attachments",
+];
+
+var { MockObjectReplacer } = ChromeUtils.import(
+ "resource://testing-common/mozmill/MockObjectHelpers.jsm"
+);
+
+var gMockFilePickReg = new MockObjectReplacer(
+ "@mozilla.org/filepicker;1",
+ MockFilePickerConstructor
+);
+
+function MockFilePickerConstructor() {
+ return gMockFilePicker;
+}
+
+var gMockFilePicker = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFilePicker"]),
+ defaultExtension: "",
+ filterIndex: null,
+ displayDirectory: null,
+ returnFiles: [],
+ addToRecentDocs: false,
+
+ get defaultString() {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ },
+
+ get fileURL() {
+ return null;
+ },
+
+ get file() {
+ if (this.returnFiles.length >= 1) {
+ return this.returnFiles[0];
+ }
+ return null;
+ },
+
+ get files() {
+ let self = this;
+ return {
+ index: 0,
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]),
+ hasMoreElements() {
+ return this.index < self.returnFiles.length;
+ },
+ getNext() {
+ return self.returnFiles[this.index++];
+ },
+ [Symbol.iterator]() {
+ return self.returnFiles.values();
+ },
+ };
+ },
+
+ init(aParent, aTitle, aMode) {},
+
+ appendFilters(aFilterMask) {},
+
+ appendFilter(aTitle, aFilter) {},
+
+ open(aFilePickerShownCallback) {
+ aFilePickerShownCallback.done(Ci.nsIFilePicker.returnOK);
+ },
+
+ set defaultString(aVal) {},
+};
+
+/**
+ * Create a body part with attachments for the message generator
+ *
+ * @param body the text of the main body of the message
+ * @param attachments an array of attachment objects (as strings)
+ * @param boundary an optional string defining the boundary of the parts
+ * @returns an object suitable for passing as the |bodyPart| for create_message
+ */
+function create_body_part(body, attachments, boundary) {
+ if (!boundary) {
+ boundary = "------------CHOPCHOP";
+ }
+
+ return {
+ contentTypeHeaderValue: 'multipart/mixed;\r\n boundary="' + boundary + '"',
+ toMessageString() {
+ let str =
+ "This is a multi-part message in MIME format.\r\n" +
+ "--" +
+ boundary +
+ "\r\n" +
+ "Content-Type: text/plain; charset=ISO-8859-1; " +
+ "format=flowed\r\n" +
+ "Content-Transfer-Encoding: 7bit\r\n\r\n" +
+ body +
+ "\r\n\r\n";
+
+ for (let i = 0; i < attachments.length; i++) {
+ str += "--" + boundary + "\r\n" + attachments[i] + "\r\n";
+ }
+
+ str += "--" + boundary + "--";
+ return str;
+ },
+ };
+}
+
+function help_create_detached_deleted_attachment(filename, type) {
+ return (
+ "You deleted an attachment from this message. The original MIME " +
+ "headers for the attachment were:\r\n" +
+ "Content-Type: " +
+ type +
+ ";\r\n" +
+ ' name="' +
+ filename +
+ '"\r\n' +
+ "Content-Transfer-Encoding: 7bit\r\n" +
+ "Content-Disposition: attachment;\r\n" +
+ ' filename="' +
+ filename +
+ '"\r\n\r\n'
+ );
+}
+
+/**
+ * Create the raw data for a detached attachment
+ *
+ * @param file an nsIFile for the external file for the attachment
+ * @param type the content type
+ * @returns a string representing the attachment
+ */
+function create_detached_attachment(file, type) {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let url = fileHandler.getURLSpecFromActualFile(file);
+ let filename = file.leafName;
+
+ let str =
+ 'Content-Type: text/plain;\r\n name="' +
+ filename +
+ '"\r\n' +
+ 'Content-Disposition: attachment; filename="' +
+ filename +
+ '"\r\n' +
+ "X-Mozilla-External-Attachment-URL: " +
+ url +
+ "\r\n" +
+ 'X-Mozilla-Altered: AttachmentDetached; date="' +
+ 'Wed Oct 06 17:28:24 2010"\r\n\r\n';
+
+ str += help_create_detached_deleted_attachment(filename, type);
+ return str;
+}
+
+/**
+ * Create the raw data for a deleted attachment
+ *
+ * @param filename the "original" filename
+ * @param type the content type
+ * @returns a string representing the attachment
+ */
+function create_deleted_attachment(filename, type) {
+ let str =
+ 'Content-Type: text/x-moz-deleted; name="Deleted: ' +
+ filename +
+ '"\r\n' +
+ "Content-Transfer-Encoding: 8bit\r\n" +
+ 'Content-Disposition: inline; filename="Deleted: ' +
+ filename +
+ '"\r\n' +
+ 'X-Mozilla-Altered: AttachmentDeleted; date="' +
+ 'Wed Oct 06 17:28:24 2010"\r\n\r\n';
+ str += help_create_detached_deleted_attachment(filename, type);
+ return str;
+}
+
+/**
+ * Create the raw data for a feed enclosure attachment.
+ *
+ * @param filename the filename
+ * @param type the content type
+ * @param url the remote link url
+ * @param size the optional size (use > 1 for real size)
+ * @returns a string representing the attachment
+ */
+function create_enclosure_attachment(filename, type, url, size) {
+ return (
+ "Content-Type: " +
+ type +
+ '; name="' +
+ filename +
+ (size ? '"; size=' + size : '"') +
+ "\r\n" +
+ "X-Mozilla-External-Attachment-URL: " +
+ url +
+ "\r\n" +
+ 'Content-Disposition: attachment; filename="' +
+ filename +
+ '"\r\n\r\n' +
+ "This MIME attachment is stored separately from the message."
+ );
+}
+
+/**
+ * A helper function that selects either one, or a continuous range
+ * of items in the attachment list.
+ *
+ * @param aController a composer window controller
+ * @param aIndexStart the index of the first item to select
+ * @param aIndexEnd (optional) the index of the last item to select
+ */
+function select_attachments(aController, aIndexStart, aIndexEnd) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ bucket.clearSelection();
+
+ if (aIndexEnd !== undefined) {
+ let startItem = bucket.getItemAtIndex(aIndexStart);
+ let endItem = bucket.getItemAtIndex(aIndexEnd);
+ bucket.selectItemRange(startItem, endItem);
+ } else {
+ bucket.selectedIndex = aIndexStart;
+ }
+
+ bucket.focus();
+ return [...bucket.selectedItems];
+}
diff --git a/comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm b/comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm
new file mode 100644
index 0000000000..a8c937e770
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/CloudfileHelpers.jsm
@@ -0,0 +1,278 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "gMockCloudfileManager",
+ "MockCloudfileAccount",
+ "getFile",
+ "collectFiles",
+ "CloudFileTestProvider",
+];
+
+var fdh = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+var kDefaults = {
+ type: "default",
+ displayName: "default",
+ iconURL: "chrome://messenger/content/extension.svg",
+ accountKey: null,
+ managementURL: "",
+ reuseUploads: true,
+ authErr: cloudFileAccounts.constants.authErr,
+ offlineErr: cloudFileAccounts.constants.offlineErr,
+ uploadErr: cloudFileAccounts.constants.uploadErr,
+ uploadWouldExceedQuota: cloudFileAccounts.constants.uploadWouldExceedQuota,
+ uploadExceedsFileLimit: cloudFileAccounts.constants.uploadExceedsFileLimit,
+ uploadCancelled: cloudFileAccounts.constants.uploadCancelled,
+};
+
+function getFile(aFilename, aRoot) {
+ var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(aRoot);
+ file.append(aFilename);
+ Assert.ok(file.exists, "File " + aFilename + " does not exist.");
+ return file;
+}
+
+/**
+ * Helper function for getting the nsIFile's for some files located
+ * in a subdirectory of the test directory.
+ *
+ * @param aFiles an array of filename strings for files underneath the test
+ * file directory.
+ * @param aFileRoot the file who's parent directory we should start looking
+ * for aFiles in.
+ *
+ * Example:
+ * let files = collectFiles(['./data/testFile1', './data/testFile2'],
+ * __file__);
+ */
+function collectFiles(aFiles, aFileRoot) {
+ return aFiles.map(filename => getFile(filename, aFileRoot));
+}
+
+function MockCloudfileAccount() {
+ for (let someDefault in kDefaults) {
+ this[someDefault] = kDefaults[someDefault];
+ }
+}
+
+MockCloudfileAccount.prototype = {
+ _nextId: 1,
+ _uploads: new Map(),
+
+ init(aAccountKey, aOverrides = {}) {
+ for (let override in aOverrides) {
+ this[override] = aOverrides[override];
+ }
+ this.accountKey = aAccountKey;
+
+ Services.prefs.setCharPref(
+ "mail.cloud_files.accounts." + aAccountKey + ".displayName",
+ aAccountKey
+ );
+ Services.prefs.setCharPref(
+ "mail.cloud_files.accounts." + aAccountKey + ".type",
+ aAccountKey
+ );
+ cloudFileAccounts._accounts.set(aAccountKey, this);
+ },
+
+ renameFile(window, uploadId, newName) {
+ if (this.renameError) {
+ throw Components.Exception(
+ this.renameError.message,
+ this.renameError.result
+ );
+ }
+
+ let upload = this._uploads.get(uploadId);
+ upload.url = `https://www.example.com/${this.accountKey}/${newName}`;
+ upload.name = newName;
+ return upload;
+ },
+
+ isReusedUpload() {
+ return false;
+ },
+
+ uploadFile(window, aFile) {
+ if (this.uploadError) {
+ return Promise.reject(
+ Components.Exception(this.uploadError.message, this.uploadError.result)
+ );
+ }
+
+ return new Promise((resolve, reject) => {
+ let upload = {
+ // Values used in the WebExtension CloudFile type.
+ id: this._nextId++,
+ url: this.urlForFile(aFile),
+ name: aFile.leafName,
+ // Properties of the local file.
+ path: aFile.path,
+ size: aFile.exists() ? aFile.fileSize : 0,
+ // Use aOverrides to set these.
+ serviceIcon: this.serviceIcon || this.iconURL,
+ serviceName: this.serviceName || this.displayName,
+ serviceUrl: this.serviceUrl || "",
+ downloadPasswordProtected: this.downloadPasswordProtected || false,
+ downloadLimit: this.downloadLimit || 0,
+ downloadExpiryDate: this.downloadExpiryDate || null,
+ // Usage tracking.
+ immutable: false,
+ };
+ this._uploads.set(upload.id, upload);
+ gMockCloudfileManager.inProgressUploads.add({
+ resolve,
+ reject,
+ resolveData: upload,
+ });
+ });
+ },
+
+ urlForFile(aFile) {
+ return `https://www.example.com/${this.accountKey}/${aFile.leafName}`;
+ },
+
+ cancelFileUpload(window, aUploadId) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ deleteFile(window, aUploadId) {
+ return new Promise(resolve => fdh.mc.window.setTimeout(resolve));
+ },
+};
+
+var gMockCloudfileManager = {
+ _mock_map: {},
+
+ register(aID, aOverrides) {
+ if (!aID) {
+ aID = "default";
+ }
+
+ if (!aOverrides) {
+ aOverrides = {};
+ }
+
+ cloudFileAccounts.registerProvider(aID, {
+ type: aID,
+ displayName: aID,
+ iconURL: "chrome://messenger/content/extension.svg",
+ initAccount(accountKey, aAccountOverrides = {}) {
+ let account = new MockCloudfileAccount();
+ for (let override in aOverrides) {
+ if (!aAccountOverrides.hasOwnProperty(override)) {
+ aAccountOverrides[override] = aOverrides[override];
+ }
+ }
+ account.init(accountKey, aAccountOverrides);
+ return account;
+ },
+ });
+ },
+
+ unregister(aID) {
+ if (!aID) {
+ aID = "default";
+ }
+
+ cloudFileAccounts.unregisterProvider(aID);
+ },
+
+ inProgressUploads: new Set(),
+ resolveUploads() {
+ let uploads = [];
+ for (let upload of this.inProgressUploads.values()) {
+ uploads.push(upload.resolveData);
+ upload.resolve(upload.resolveData);
+ }
+ this.inProgressUploads.clear();
+ return uploads;
+ },
+ rejectUploads() {
+ for (let upload of this.inProgressUploads.values()) {
+ upload.reject(
+ Components.Exception(
+ "Upload error.",
+ cloudFileAccounts.constants.uploadErr
+ )
+ );
+ }
+ this.inProgressUploads.clear();
+ },
+};
+
+class CloudFileTestProvider {
+ constructor(name = "CloudFileTestProvider") {
+ this.extension = null;
+ this.name = name;
+ }
+
+ get providerType() {
+ return `ext-${this.extension.id}`;
+ }
+
+ /**
+ * Register an extension based cloudFile provider.
+ *
+ * @param testScope - scope of the test, mostly "this"
+ * @param [background] - optional background script, overriding the default
+ */
+ async register(testScope, background) {
+ if (!testScope) {
+ throw new Error("Missing testScope for CloudFileTestProvider.init().");
+ }
+
+ async function default_background() {
+ function fileListener(account, { id, name, data }, tab, relatedFileInfo) {
+ return { url: "https://example.com/" + name };
+ }
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ }
+
+ this.extension = testScope.ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background || default_background,
+ },
+ manifest: {
+ cloud_file: {
+ name: this.name,
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: `${this.name}@mochi.test` } },
+ background: { scripts: ["background.js"] },
+ },
+ });
+
+ await this.extension.startup();
+ }
+
+ async unregister() {
+ cloudFileAccounts.unregisterProvider(this.providerType);
+ await this.extension.unload();
+ }
+
+ async createAccount(displayName) {
+ let account = await cloudFileAccounts.createAccount(this.providerType);
+ cloudFileAccounts.setDisplayName(account, displayName);
+ return account;
+ }
+
+ removeAccount(aKeyOrAccount) {
+ return cloudFileAccounts.removeAccount(aKeyOrAccount);
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/ComposeHelpers.jsm b/comm/mail/test/browser/shared-modules/ComposeHelpers.jsm
new file mode 100644
index 0000000000..0d32769760
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/ComposeHelpers.jsm
@@ -0,0 +1,2430 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "add_attachments",
+ "add_cloud_attachments",
+ "rename_selected_cloud_attachment",
+ "convert_selected_to_cloud_attachment",
+ "assert_previous_text",
+ "async_wait_for_compose_window",
+ "clear_recipients",
+ "close_compose_window",
+ "create_msg_attachment",
+ "delete_attachment",
+ "get_compose_body",
+ "get_first_pill",
+ "get_msg_source",
+ "open_compose_from_draft",
+ "open_compose_new_mail",
+ "open_compose_with_edit_as_new",
+ "open_compose_with_forward",
+ "open_compose_with_forward_as_attachments",
+ "open_compose_with_reply",
+ "open_compose_with_reply_to_all",
+ "open_compose_with_reply_to_list",
+ "save_compose_message",
+ "setup_msg_contents",
+ "type_in_composer",
+ "wait_for_compose_window",
+ "FormatHelper",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { get_about_message, mc } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { gMockCloudfileManager } = ChromeUtils.import(
+ "resource://testing-common/mozmill/CloudfileHelpers.jsm"
+);
+var windowHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+var { get_notification } = ChromeUtils.import(
+ "resource://testing-common/mozmill/NotificationBoxHelpers.jsm"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var kTextNodeType = 3;
+
+/**
+ * Opens the compose window by starting a new message
+ *
+ * @param aController the controller for the mail:3pane from which to spawn
+ * the compose window. If left blank, defaults to mc.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ *
+ */
+function open_compose_new_mail(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "n",
+ { shiftKey: false, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by replying to a selected message and waits for it
+ * to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_reply(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "r",
+ { shiftKey: false, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by replying to all for a selected message and waits
+ * for it to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_reply_to_all(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "R",
+ { shiftKey: true, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by replying to list for a selected message and waits for it
+ * to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_reply_to_list(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "l",
+ { shiftKey: true, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by forwarding the selected messages as attachments
+ * and waits for it to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_forward_as_attachments(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ aController.window.goDoCommand("cmd_forwardAttachment");
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by editing the selected message as new
+ * and waits for it to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_edit_as_new(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ aController.window.goDoCommand("cmd_editAsNew");
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Opens the compose window by forwarding the selected message and waits for it
+ * to load.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_with_forward(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ windowHelper.plan_for_new_window("msgcompose");
+ EventUtils.synthesizeKey(
+ "l",
+ { shiftKey: false, accelKey: true },
+ aController.window
+ );
+
+ return wait_for_compose_window();
+}
+
+/**
+ * Open draft editing by clicking the "Edit" on the draft notification bar
+ * of the selected message.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+function open_compose_from_draft(win = get_about_message()) {
+ windowHelper.plan_for_new_window("msgcompose");
+ let box = get_notification(win, "mail-notification-top", "draftMsgContent");
+ EventUtils.synthesizeMouseAtCenter(
+ box.buttonContainer.firstElementChild,
+ {},
+ win
+ );
+ return wait_for_compose_window();
+}
+
+/**
+ * Saves the message being composed and waits for the save to complete.
+ *
+ * @param {Window} win - A messengercompose.xhtml window.
+ */
+async function save_compose_message(win) {
+ let savePromise = BrowserTestUtils.waitForEvent(win, "aftersave");
+ win.document.querySelector("#button-save").click();
+ await savePromise;
+}
+
+/**
+ * Closes the requested compose window.
+ *
+ * @param aController the controller whose window is to be closed.
+ * @param aShouldPrompt (optional) true: check that the prompt to save appears
+ * false: check there's no prompt to save
+ */
+function close_compose_window(aController, aShouldPrompt) {
+ if (aShouldPrompt === undefined) {
+ // caller doesn't care if we get a prompt
+ windowHelper.close_window(aController);
+ return;
+ }
+
+ windowHelper.plan_for_window_close(aController);
+ if (aShouldPrompt) {
+ windowHelper.plan_for_modal_dialog(
+ "commonDialogWindow",
+ function (controller) {
+ controller.window.document
+ .querySelector("dialog")
+ .getButton("extra1")
+ .doCommand();
+ }
+ );
+ // Try to close, we should get a prompt to save.
+ aController.window.goDoCommand("cmd_close");
+ windowHelper.wait_for_modal_dialog();
+ } else {
+ aController.window.goDoCommand("cmd_close");
+ }
+ windowHelper.wait_for_window_close();
+}
+
+/**
+ * Waits for a new compose window to open. This assumes you have already called
+ * "windowHelper.plan_for_new_window("msgcompose");" and the command to open
+ * the compose window itself.
+ *
+ * @returns {MozmillController} The loaded window of type "msgcompose"
+ * wrapped in a MozmillController.
+ */
+async function async_wait_for_compose_window(aController, aPromise) {
+ let replyWindow = await aPromise;
+ return _wait_for_compose_window(aController, replyWindow);
+}
+
+function wait_for_compose_window(aController) {
+ let replyWindow = windowHelper.wait_for_new_window("msgcompose");
+ return _wait_for_compose_window(aController, replyWindow);
+}
+
+function _wait_for_compose_window(aController, replyWindow) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ utils.waitFor(
+ () => Services.focus.activeWindow == replyWindow.window,
+ "waiting for the compose window to have focus"
+ );
+ utils.waitFor(
+ () => replyWindow.window.composeEditorReady,
+ "waiting for the compose editor to be ready"
+ );
+ utils.sleep(0);
+
+ return replyWindow;
+}
+
+/**
+ * Fills in the given message recipient/subject/body into the right widgets.
+ *
+ * @param aCwc Compose window controller.
+ * @param aAddr Recipient to fill in.
+ * @param aSubj Subject to fill in.
+ * @param aBody Message body to fill in.
+ * @param inputID The input field to fill in.
+ */
+function setup_msg_contents(
+ aCwc,
+ aAddr,
+ aSubj,
+ aBody,
+ inputID = "toAddrInput"
+) {
+ let pillcount = function () {
+ return aCwc.window.document.querySelectorAll("mail-address-pill").length;
+ };
+ let targetCount = pillcount();
+ if (aAddr.trim()) {
+ targetCount += aAddr.split(",").filter(s => s.trim()).length;
+ }
+
+ let input = aCwc.window.document.getElementById(inputID);
+ utils.sleep(1000);
+ input.focus();
+ EventUtils.sendString(aAddr, aCwc.window);
+ input.focus();
+
+ EventUtils.synthesizeKey("VK_RETURN", {}, aCwc.window);
+ aCwc.window.document.getElementById("msgSubject").focus();
+ EventUtils.sendString(aSubj, aCwc.window);
+ aCwc.window.document.getElementById("messageEditor").focus();
+ EventUtils.sendString(aBody, aCwc.window);
+
+ // Wait for the pill(s) to be created.
+ utils.waitFor(
+ () => pillcount() == targetCount,
+ `Creating pill for: ${aAddr}`
+ );
+}
+
+/**
+ * Remove all recipients.
+ *
+ * @param aController Compose window controller.
+ */
+function clear_recipients(aController) {
+ for (let pill of aController.window.document.querySelectorAll(
+ "mail-address-pill"
+ )) {
+ pill.toggleAttribute("selected", true);
+ }
+ aController.window.document
+ .getElementById("recipientsContainer")
+ .removeSelectedPills();
+}
+
+/**
+ * Return the first available recipient pill.
+ *
+ * @param aController - Compose window controller.
+ */
+function get_first_pill(aController) {
+ return aController.window.document.querySelector("mail-address-pill");
+}
+
+/**
+ * Create and return an nsIMsgAttachment for the passed URL.
+ *
+ * @param aUrl the URL for this attachment (either a file URL or a web URL)
+ * @param aSize (optional) the file size of this attachment, in bytes
+ */
+function create_msg_attachment(aUrl, aSize) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ attachment.url = aUrl;
+ if (aSize) {
+ attachment.size = aSize;
+ }
+
+ return attachment;
+}
+
+/**
+ * Add an attachment to the compose window.
+ *
+ * @param aController the controller of the composition window in question
+ * @param aUrl the URL for this attachment (either a file URL or a web URL)
+ * @param aSize (optional) - the file size of this attachment, in bytes
+ * @param aWaitAdded (optional) - True to wait for the attachments to be fully added, false otherwise.
+ */
+function add_attachments(aController, aUrls, aSizes, aWaitAdded = true) {
+ if (!Array.isArray(aUrls)) {
+ aUrls = [aUrls];
+ }
+
+ if (!Array.isArray(aSizes)) {
+ aSizes = [aSizes];
+ }
+
+ let attachments = [];
+
+ for (let [i, url] of aUrls.entries()) {
+ attachments.push(create_msg_attachment(url, aSizes[i]));
+ }
+
+ let attachmentsDone = false;
+ function collectAddedAttachments(event) {
+ Assert.equal(event.detail.length, attachments.length);
+ attachmentsDone = true;
+ }
+
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ if (aWaitAdded) {
+ bucket.addEventListener("attachments-added", collectAddedAttachments, {
+ once: true,
+ });
+ }
+ aController.window.AddAttachments(attachments);
+ if (aWaitAdded) {
+ utils.waitFor(() => attachmentsDone, "Attachments adding didn't finish");
+ }
+ utils.sleep(0);
+}
+
+/**
+ * Rename the selected cloud (filelink) attachment
+ *
+ * @param aController The controller of the composition window in question.
+ * @param aName The requested new name for the attachment.
+ *
+ */
+function rename_selected_cloud_attachment(aController, aName) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ let attachmentRenamed = false;
+ let upload = null;
+ let seenAlert = null;
+
+ function getRenamedUpload(event) {
+ upload = event.target.cloudFileUpload;
+ attachmentRenamed = true;
+ }
+
+ /** @implements {nsIPromptService} */
+ let mockPromptService = {
+ value: "",
+ prompt(window, title, message, rv) {
+ rv.value = this.value;
+ return true;
+ },
+ alert(window, title, message) {
+ seenAlert = { title, message };
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ };
+
+ bucket.addEventListener("attachment-renamed", getRenamedUpload, {
+ once: true,
+ });
+
+ let originalPromptService = Services.prompt;
+ Services.prompt = mockPromptService;
+ Services.prompt.value = aName;
+ aController.window.RenameSelectedAttachment();
+
+ utils.waitFor(
+ () => attachmentRenamed || seenAlert,
+ "Couldn't rename attachment"
+ );
+ Services.prompt = originalPromptService;
+
+ utils.sleep(0);
+ if (seenAlert) {
+ return seenAlert;
+ }
+
+ return upload;
+}
+
+/**
+ * Convert the selected attachment to a cloud (filelink) attachment
+ *
+ * @param aController The controller of the composition window in question.
+ * @param aProvider The provider account to upload the selected attachment to.
+ * @param aWaitUploaded (optional) - True to wait for the attachments to be uploaded, false otherwise.
+ */
+function convert_selected_to_cloud_attachment(
+ aController,
+ aProvider,
+ aWaitUploaded = true
+) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ let uploads = [];
+ let attachmentsSelected =
+ aController.window.gAttachmentBucket.selectedItems.length;
+ let attachmentsSubmitted = 0;
+ let attachmentsConverted = 0;
+
+ Assert.equal(
+ attachmentsSelected,
+ 1,
+ "Exactly one attachment should be scheduled for conversion."
+ );
+
+ function collectConvertingAttachments(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ "chrome://global/skin/icons/loading.png",
+ "Icon should be the spinner during conversion."
+ );
+
+ attachmentsSubmitted++;
+ if (attachmentsSubmitted == attachmentsSelected) {
+ bucket.removeEventListener(
+ "attachment-uploading",
+ collectConvertingAttachments
+ );
+ bucket.removeEventListener(
+ "attachment-moving",
+ collectConvertingAttachments
+ );
+ }
+ }
+
+ function collectConvertedAttachment(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ item.cloudIcon,
+ "Cloud icon should be used after conversion has finished."
+ );
+
+ attachmentsConverted++;
+ if (attachmentsConverted == attachmentsSelected) {
+ item.removeEventListener(
+ "attachment-uploaded",
+ collectConvertedAttachment
+ );
+ item.removeEventListener("attachment-moved", collectConvertedAttachment);
+ }
+ }
+
+ bucket.addEventListener("attachment-uploading", collectConvertingAttachments);
+ bucket.addEventListener("attachment-moving", collectConvertingAttachments);
+ aController.window.convertSelectedToCloudAttachment(aProvider);
+ utils.waitFor(
+ () => attachmentsSubmitted == attachmentsSelected,
+ "Couldn't start converting all attachments"
+ );
+
+ if (aWaitUploaded) {
+ bucket.addEventListener("attachment-uploaded", collectConvertedAttachment);
+ bucket.addEventListener("attachment-moved", collectConvertedAttachment);
+
+ uploads = gMockCloudfileManager.resolveUploads();
+ utils.waitFor(
+ () => attachmentsConverted == attachmentsSelected,
+ "Attachments uploading didn't finish"
+ );
+ }
+
+ utils.sleep(0);
+ return uploads;
+}
+
+/**
+ * Add a cloud (filelink) attachment to the compose window.
+ *
+ * @param aController - The controller of the composition window in question.
+ * @param aProvider - The provider account to upload to, with files to be uploaded.
+ * @param [aWaitUploaded] - True to wait for the attachments to be uploaded,
+ * false otherwise.
+ * @param [aExpectedAlerts] - The number of expected alert prompts.
+ */
+function add_cloud_attachments(
+ aController,
+ aProvider,
+ aWaitUploaded = true,
+ aExpectedAlerts = 0
+) {
+ let bucket = aController.window.document.getElementById("attachmentBucket");
+ let uploads = [];
+ let seenAlerts = [];
+
+ let attachmentsAdded = 0;
+ let attachmentsSubmitted = 0;
+ let attachmentsUploaded = 0;
+
+ function collectAddedAttachments(event) {
+ attachmentsAdded = event.detail.length;
+ if (!aExpectedAlerts) {
+ bucket.addEventListener(
+ "attachment-uploading",
+ collectUploadingAttachments
+ );
+ }
+ }
+
+ function collectUploadingAttachments(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ "chrome://global/skin/icons/loading.png",
+ "Icon should be the spinner during upload."
+ );
+
+ attachmentsSubmitted++;
+ if (attachmentsSubmitted == attachmentsAdded) {
+ bucket.removeEventListener(
+ "attachment-uploading",
+ collectUploadingAttachments
+ );
+ }
+ }
+
+ function collectUploadedAttachments(event) {
+ let item = event.target;
+ let img = item.querySelector("img.attachmentcell-icon");
+ Assert.equal(
+ img.src,
+ item.cloudIcon,
+ "Cloud icon should be used after upload has finished."
+ );
+
+ attachmentsUploaded++;
+ if (attachmentsUploaded == attachmentsAdded) {
+ bucket.removeEventListener(
+ "attachment-uploaded",
+ collectUploadedAttachments
+ );
+ }
+ }
+
+ /** @implements {nsIPromptService} */
+ let mockPromptService = {
+ alert(window, title, message) {
+ seenAlerts.push({ title, message });
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ };
+
+ bucket.addEventListener("attachments-added", collectAddedAttachments, {
+ once: true,
+ });
+
+ let originalPromptService = Services.prompt;
+ Services.prompt = mockPromptService;
+ aController.window.attachToCloudNew(aProvider);
+ utils.waitFor(
+ () =>
+ (!aExpectedAlerts &&
+ attachmentsAdded > 0 &&
+ attachmentsAdded == attachmentsSubmitted) ||
+ (aExpectedAlerts && seenAlerts.length == aExpectedAlerts),
+ "Couldn't attach attachments for upload"
+ );
+
+ Services.prompt = originalPromptService;
+ if (seenAlerts.length > 0) {
+ return seenAlerts;
+ }
+
+ if (aWaitUploaded) {
+ bucket.addEventListener("attachment-uploaded", collectUploadedAttachments);
+ uploads = gMockCloudfileManager.resolveUploads();
+ utils.waitFor(
+ () => attachmentsAdded == attachmentsUploaded,
+ "Attachments uploading didn't finish"
+ );
+ }
+ utils.sleep(0);
+ return uploads;
+}
+
+/**
+ * Delete an attachment from the compose window
+ *
+ * @param aComposeWindow the composition window in question
+ * @param aIndex the index of the attachment in the attachment pane
+ */
+function delete_attachment(aComposeWindow, aIndex) {
+ let bucket =
+ aComposeWindow.window.document.getElementById("attachmentBucket");
+ let node = bucket.querySelectorAll("richlistitem.attachmentItem")[aIndex];
+
+ EventUtils.synthesizeMouseAtCenter(node, {}, node.ownerGlobal);
+ aComposeWindow.window.RemoveSelectedAttachment();
+}
+
+/**
+ * Helper function returns the message body element of a composer window.
+ *
+ * @param aController the controller for a compose window.
+ */
+function get_compose_body(aController) {
+ let mailBody = aController.window.document
+ .getElementById("messageEditor")
+ .contentDocument.querySelector("body");
+ if (!mailBody) {
+ throw new Error("Compose body not found!");
+ }
+ return mailBody;
+}
+
+/**
+ * Given some compose window controller, type some text into that composer,
+ * pressing enter after each line except for the last.
+ *
+ * @param aController a compose window controller.
+ * @param aText an array of strings to type.
+ */
+function type_in_composer(aController, aText) {
+ // If we have any typing to do, let's do it.
+ let frame = aController.window.document.getElementById("messageEditor");
+ for (let [i, aLine] of aText.entries()) {
+ frame.focus();
+ EventUtils.sendString(aLine, aController.window);
+ if (i < aText.length - 1) {
+ frame.focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, aController.window);
+ }
+ }
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart is a text node which
+ * has a value matching the last value of the aText string array, and has
+ * a br node immediately preceding it. Repeated for each subsequent string
+ * of the aText array (working from end to start).
+ *
+ * @param aStart the first node to check
+ * @param aText an array of strings that should be checked for in reverse
+ * order (so the last element of the array should be the first
+ * text node encountered, the second last element of the array
+ * should be the next text node encountered, etc).
+ */
+function assert_previous_text(aStart, aText) {
+ let textNode = aStart;
+ for (let i = aText.length - 1; i >= 0; --i) {
+ if (textNode.nodeType != kTextNodeType) {
+ throw new Error(
+ "Expected a text node! Node type was: " + textNode.nodeType
+ );
+ }
+
+ if (textNode.nodeValue != aText[i]) {
+ throw new Error(
+ "Unexpected inequality - " + textNode.nodeValue + " != " + aText[i]
+ );
+ }
+
+ // We expect a BR preceding each text node automatically, except
+ // for the last one that we reach.
+ if (i > 0) {
+ let br = textNode.previousSibling;
+
+ if (br.localName != "br") {
+ throw new Error(
+ "Expected a BR node - got a " + br.localName + "instead."
+ );
+ }
+
+ textNode = br.previousSibling;
+ }
+ }
+ return textNode;
+}
+
+/**
+ * Helper to get the raw contents of a message. It only reads the first 64KiB.
+ *
+ * @param aMsgHdr nsIMsgDBHdr addressing a message which will be returned as text.
+ * @param aCharset Charset to use to decode the message.
+ *
+ * @returns String with the message source.
+ */
+async function get_msg_source(aMsgHdr, aCharset = "") {
+ let msgUri = aMsgHdr.folder.getUriForMsg(aMsgHdr);
+
+ let content = await new Promise((resolve, reject) => {
+ let streamListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+ sis: Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ ),
+ content: "",
+ onDataAvailable(request, inputStream, offset, count) {
+ this.sis.init(inputStream);
+ this.content += this.sis.read(count);
+ },
+ onStartRequest(request) {},
+ onStopRequest(request, statusCode) {
+ this.sis.close();
+ if (Components.isSuccessCode(statusCode)) {
+ resolve(this.content);
+ } else {
+ reject(new Error(statusCode));
+ }
+ },
+ };
+ MailServices.messageServiceFromURI(msgUri).streamMessage(
+ msgUri,
+ streamListener,
+ null,
+ null,
+ false,
+ "",
+ false
+ );
+ });
+
+ if (!aCharset) {
+ return content;
+ }
+
+ let buffer = Uint8Array.from(content, c => c.charCodeAt(0));
+ return new TextDecoder(aCharset).decode(buffer);
+}
+
+/**
+ * Helper class for performing formatted editing on the composition message.
+ */
+class FormatHelper {
+ /**
+ * Create the helper for the given composition window.
+ *
+ * @param {Window} win - The composition window.
+ */
+ constructor(win) {
+ this.window = win;
+ /** The Format menu. */
+ this.formatMenu = this._getById("formatMenuPopup");
+
+ /** The Font sub menu of {@link FormatHelper#formatMenu}. */
+ this.fontMenu = this._getById("fontFaceMenuPopup");
+ /** The menu items below the Font menu. */
+ this.fontMenuItems = Array.from(this.fontMenu.querySelectorAll("menuitem"));
+ /** The (font) Size sub menu of {@link FormatHelper#formatMenu}. */
+ this.sizeMenu = this._getById("fontSizeMenuPopup");
+ /** The menu items below the Size menu. */
+ this.sizeMenuItems = Array.from(
+ // Items without a value are the increase/decrease items.
+ this.sizeMenu.querySelectorAll("menuitem[value]")
+ );
+ /** The Text Style sub menu of {@link FormatHelper#formatMenu}. */
+ this.styleMenu = this._getById("fontStyleMenuPopup");
+ /** The menu items below the Text Style menu. */
+ this.styleMenuItems = Array.from(
+ this.styleMenu.querySelectorAll("menuitem")
+ );
+ /** The Paragraph (state) sub menu of {@link FormatHelper#formatMenu}. */
+ this.paragraphStateMenu = this._getById("paragraphMenuPopup");
+ /** The menu items below the Paragraph menu. */
+ this.paragraphStateMenuItems = Array.from(
+ this.paragraphStateMenu.querySelectorAll("menuitem")
+ );
+
+ /** The toolbar paragraph state selector button. */
+ this.paragraphStateSelector = this._getById("ParagraphSelect");
+ /** The toolbar paragraph state selector menu. */
+ this.paragraphStateSelectorMenu = this._getById("ParagraphPopup");
+ /** The toolbar font face selector button. */
+ this.fontSelector = this._getById("FontFaceSelect");
+ /** The toolbar font face selector menu. */
+ this.fontSelectorMenu = this._getById("FontFacePopup");
+ /** The toolbar font size selector button. */
+ this.sizeSelector = this._getById("AbsoluteFontSizeButton");
+ /** The toolbar font size selector menu. */
+ this.sizeSelectorMenu = this._getById("AbsoluteFontSizeButtonPopup");
+ /** The menu items below the toolbar font size selector. */
+ this.sizeSelectorMenuItems = Array.from(
+ this.sizeSelectorMenu.querySelectorAll("menuitem")
+ );
+
+ /** The toolbar foreground color selector. */
+ this.colorSelector = this._getById("TextColorButton");
+ /** The Format foreground color item. */
+ this.colorMenuItem = this._getById("fontColor");
+
+ /** The toolbar increase font size button. */
+ this.increaseSizeButton = this._getById("IncreaseFontSizeButton");
+ /** The toolbar decrease font size button. */
+ this.decreaseSizeButton = this._getById("DecreaseFontSizeButton");
+ /** The increase font size menu item. */
+ this.increaseSizeMenuItem = this._getById("menu_increaseFontSize");
+ /** The decrease font size menu item. */
+ this.decreaseSizeMenuItem = this._getById("menu_decreaseFontSize");
+
+ /** The toolbar bold button. */
+ this.boldButton = this._getById("boldButton");
+ /** The toolbar italic button. */
+ this.italicButton = this._getById("italicButton");
+ /** The toolbar underline button. */
+ this.underlineButton = this._getById("underlineButton");
+
+ /** The toolbar remove text styling button. */
+ this.removeStylingButton = this._getById("removeStylingButton");
+ /** The remove text styling menu item. */
+ this.removeStylingMenuItem = this._getById("removeStylesMenuitem");
+
+ this.messageEditor = this._getById("messageEditor");
+ /** The Window of the message content. */
+ this.messageWindow = this.messageEditor.contentWindow;
+ /** The Document of the message content. */
+ this.messageDocument = this.messageEditor.contentDocument;
+ /** The Body of the message content. */
+ this.messageBody = this.messageDocument.body;
+
+ let styleDataMap = new Map([
+ ["bold", { tag: "B" }],
+ ["italic", { tag: "I" }],
+ ["underline", { tag: "U" }],
+ ["strikethrough", { tag: "STRIKE" }],
+ ["superscript", { tag: "SUP" }],
+ ["subscript", { tag: "SUB" }],
+ ["tt", { tag: "TT" }],
+ // ["nobreak", { tag: "NOBR" }], // Broken after bug 1806330. Why?
+ ["em", { tag: "EM", linked: "italic" }],
+ ["strong", { tag: "STRONG", linked: "bold" }],
+ ["cite", { tag: "CITE", implies: "italic" }],
+ ["abbr", { tag: "ABBR" }],
+ ["acronym", { tag: "ACRONYM" }],
+ ["code", { tag: "CODE", implies: "tt" }],
+ ["samp", { tag: "SAMP", implies: "tt" }],
+ ["var", { tag: "VAR", implies: "italic" }],
+ ]);
+ styleDataMap.forEach((data, name) => {
+ data.item = this.getStyleMenuItem(name);
+ data.name = name;
+ });
+ styleDataMap.forEach((data, name, map) => {
+ // Reference the object rather than the name.
+ if (data.linked) {
+ data.linked = map.get(data.linked);
+ Assert.ok(data.linked, `Found linked for ${name}`);
+ }
+ if (data.implies) {
+ data.implies = map.get(data.implies);
+ Assert.ok(data.implies, `Found implies for ${name}`);
+ }
+ });
+ /**
+ * @typedef StyleData
+ * @property {string} name - The style name.
+ * @property {string} tag - The tagName for the corresponding HTML element.
+ * @property {MozMenuItem} item - The corresponding menu item in the
+ * styleMenu.
+ * @property {StyleData} [linked] - The style that is linked to this style.
+ * If this style is set, the linked style is shown as also set. If the
+ * linked style is unset, so is this style.
+ * @property {StyleData} [implies] - The style that is implied by this
+ * style. If this style is set, the implied style is shown as also set.
+ */
+ /**
+ * Data for the various text styles. Maps from the style name to its data.
+ *
+ * @type {Map<string, StyleData>}
+ */
+ this.styleDataMap = styleDataMap;
+
+ /**
+ * A list of common font families available in Thunderbird. Excludes the
+ * Variable Width ("") and Fixed Width ("monospace") fonts.
+ *
+ * @type {[string]}
+ */
+ this.commonFonts = [
+ "Helvetica, Arial, sans-serif",
+ "Times New Roman, Times, serif",
+ "Courier New, Courier, monospace",
+ ];
+
+ /** The default font size that corresponds to no <font> being applied. */
+ this.NO_SIZE = 3;
+ /** The maximum font size. */
+ this.MAX_SIZE = 6;
+ /** The minimum font size. */
+ this.MIN_SIZE = 1;
+ }
+
+ _getById(id) {
+ return this.window.document.getElementById(id);
+ }
+
+ /**
+ * Move focus to the message area. The message needs to be focused for most
+ * of the interactive methods to work.
+ */
+ focusMessage() {
+ EventUtils.synthesizeMouseAtCenter(this.messageEditor, {}, this.window);
+ }
+
+ /**
+ * Type some text into the message area.
+ *
+ * @param {string} text - A string of printable characters to type.
+ */
+ async typeInMessage(text) {
+ EventUtils.sendString(text, this.messageWindow);
+ // Wait one loop to be similar to a user.
+ await TestUtils.waitForTick();
+ }
+
+ /**
+ * Simulate pressing enter/return in the message area.
+ *
+ * @param {boolean} [shift = false] - Whether to hold shift at the same time.
+ */
+ async typeEnterInMessage(shift = false) {
+ EventUtils.synthesizeKey(
+ "VK_RETURN",
+ { shiftKey: shift },
+ this.messageWindow
+ );
+ await TestUtils.waitForTick();
+ }
+
+ /**
+ * Delete the current selection in the message window (using backspace).
+ */
+ async deleteSelection() {
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, this.messageWindow);
+ await TestUtils.waitForTick();
+ }
+
+ /**
+ * Select the entire message.
+ */
+ async selectAll() {
+ let selection = this.messageWindow.getSelection();
+ selection.removeAllRanges();
+
+ let changePromise = BrowserTestUtils.waitForEvent(
+ this.messageDocument,
+ "selectionchange"
+ );
+
+ selection.selectAllChildren(this.messageDocument.body);
+
+ await changePromise;
+ }
+
+ /**
+ * Select the first paragraph in the message.
+ */
+ async selectFirstParagraph() {
+ let selection = this.messageWindow.getSelection();
+ selection.removeAllRanges();
+
+ let changePromise = BrowserTestUtils.waitForEvent(
+ this.messageDocument,
+ "selectionchange"
+ );
+
+ let paragraph = this.messageDocument.body.querySelector("p");
+ Assert.ok(paragraph, "Have at least one paragraph");
+ selection.selectAllChildren(paragraph);
+
+ await changePromise;
+ }
+
+ /**
+ * Delete the entire message.
+ *
+ * Note, this currently deletes the paragraph state (see Bug 1715076).
+ */
+ async deleteAll() {
+ await this.selectAll();
+ await this.deleteSelection();
+ }
+
+ /**
+ * Empty the message paragraph.
+ */
+ async emptyParagraph() {
+ await this.selectFirstParagraph();
+ await this.deleteSelection();
+ let p = this.messageDocument.body.querySelector("p");
+ Assert.equal(p.textContent, "", "should have emptied p");
+ }
+
+ /**
+ * Tags that correspond to inline styling (in upper case).
+ *
+ * @type {[string]}
+ */
+ static inlineStyleTags = [
+ "B",
+ "I",
+ "U",
+ "STRIKE",
+ "SUP",
+ "SUB",
+ "TT",
+ "NOBR",
+ "EM",
+ "STRONG",
+ "CITE",
+ "ABBR",
+ "ACRONYM",
+ "CODE",
+ "SAMP",
+ "VAR",
+ ];
+ /**
+ * Tags that correspond to block scopes (in upper case).
+ *
+ * @type {[string]}
+ */
+ static blockTags = [
+ "P",
+ "PRE",
+ "ADDRESS",
+ "H1",
+ "H2",
+ "H3",
+ "H4",
+ "H5",
+ "H6",
+ ];
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered a block.
+ */
+ static isBlock(node) {
+ return this.blockTags.includes(node.tagName);
+ }
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered inline styling.
+ */
+ static isInlineStyle(node) {
+ return this.inlineStyleTags.includes(node.tagName);
+ }
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered a font node.
+ */
+ static isFont(node) {
+ return node.tagName === "FONT";
+ }
+
+ /**
+ * @param {Node} node - The node to test.
+ *
+ * @returns {boolean} Whether the node is considered a break.
+ */
+ static isBreak(node) {
+ return node.tagName === "BR";
+ }
+
+ /**
+ * A leaf of the message body. Actual leaves of the HTMLBodyElement will have
+ * a corresponding Leaf (corresponding to the "break", "text" and "empty"
+ * types), with the exception of empty block elements. These leaves are
+ * ordered with respect to the corresponding childNode ordering. In addition,
+ * every block element will have two corresponding leaves: one for the start
+ * of the block ("block-start") that is ordered just before its children; and
+ * one for the end of the block ("block-end") that is ordered just after its
+ * children. Essentially, you can think of the opening and closing tags of the
+ * block as leaves of the message body.
+ *
+ * @typedef Leaf
+ * @property {"break"|"block-start"|"block-end"|"text"|"empty"} type -
+ * The leaf type.
+ * @property {Node} node - The associated node in the document.
+ */
+
+ /**
+ * Get the first leaf below the given node with respect to Leaf ordering.
+ *
+ * @param {Node} node - The node to fetch the first leaf of.
+ *
+ * @returns {Leaf} - The first leaf below the node.
+ */
+ static firstLeaf(node) {
+ while (true) {
+ // Starting the block scope.
+ if (this.isBlock(node)) {
+ return { type: "block-start", node };
+ }
+ let child = node.firstChild;
+ if (child) {
+ node = child;
+ } else {
+ break;
+ }
+ }
+ if (Text.isInstance(node)) {
+ return { type: "text", node };
+ } else if (this.isBreak(node)) {
+ return { type: "break", node };
+ }
+ return { type: "empty", node };
+ }
+
+ /**
+ * Get the next Leaf that follows the given Leaf in the ordering.
+ *
+ * @param {Node} root - The root of the tree to find leaves from.
+ * @param {Leaf} leaf - The leaf to search from.
+ *
+ * @returns {Leaf|null} - The next Leaf under the root that follows the given
+ * Leaf, or null if the given leaf was the last one.
+ */
+ static nextLeaf(root, leaf) {
+ if (leaf.type === "block-start") {
+ // Enter within the block scope.
+ let child = leaf.node.firstChild;
+ if (!child) {
+ return { type: "block-end", node };
+ }
+ return this.firstLeaf(child);
+ }
+ // Find the next branch of the tree.
+ let node = leaf.node;
+ let sibling;
+ while (true) {
+ if (node === root) {
+ return null;
+ }
+ // Move to the next branch, if there is one.
+ sibling = node.nextSibling;
+ if (sibling) {
+ break;
+ }
+ // Otherwise, move back up the current branch.
+ node = node.parentNode;
+ // Leaving the block scope.
+ if (this.isBlock(node)) {
+ return { type: "block-end", node };
+ }
+ }
+ // Travel to the first leaf of the branch.
+ return this.firstLeaf(sibling);
+ }
+
+ /**
+ * Select some text in the message body.
+ *
+ * Note, the start and end values refer to offsets from the start of the
+ * message, and they count the spaces *between* string characters in the
+ * message.
+ *
+ * A single newline will also count 1 towards the offset. This can refer to
+ * either the start or end of a block (such as a <p>), or an explicit line
+ * break (<br>). Note, as an exception, line breaks that do not produce a new
+ * line visually (breaks at the end of a block, or breaks in the body scope
+ * between a text node and the start of a block) do not count.
+ *
+ * You can either choose to select in a forward direction or a backward
+ * direction. When no end parameter is given, this corresponds to if a user
+ * approaches a position in the message by moving the text cursor forward or
+ * backward (using the arrow keys). Otherwise, this refers to the direction in
+ * which the selection was formed (using shift + arrow keys or dragging).
+ *
+ * @param {number} start - The position to start selecting from.
+ * @param {number|null} [end = null] - The position to end selecting from,
+ * after start, or null to select the same position as the start.
+ * @param {boolean} [forward = true] - Whether to select in the forward or
+ * backward direction.
+ */
+ async selectTextRange(start, end = null, forward = true) {
+ let selectionTargets = [{ position: start }];
+ if (end !== null) {
+ Assert.ok(
+ end >= start,
+ `End of selection (${end}) should be after the start (${start})`
+ );
+ selectionTargets.push({ position: end });
+ }
+
+ let cls = this.constructor;
+ let root = this.messageBody;
+ let prevLeaf = null;
+ let leaf = cls.firstLeaf(root);
+ let total = 0;
+ // NOTE: Only the leaves of the root will contribute to the total, which is
+ // why we only need to traverse them.
+ // Search the tree until we find the target nodes, or run out of leaves.
+ while (leaf && selectionTargets.some(target => !target.node)) {
+ // Look ahead at the next leaf.
+ let nextLeaf = cls.nextLeaf(root, leaf);
+ switch (leaf.type) {
+ case "text":
+ // Each character in the text content counts towards the total.
+ let textLength = leaf.node.textContent.length;
+ total += textLength;
+
+ for (let target of selectionTargets) {
+ if (target.node) {
+ continue;
+ }
+ if (total === target.position) {
+ // If the next leaf is a text node, then the start of the
+ // selection is between the end of this node and the start of
+ // the next node. If selecting forward, we prefer the end of the
+ // first node. Otherwise, we prefer the start of the next node.
+ // If the next node is not a text node (such as a break or the end
+ // of a block), we end at the current node.
+ if (forward || nextLeaf?.type !== "text") {
+ target.node = leaf.node;
+ target.offset = textLength;
+ }
+ // Else, let the next (text) leaf set the node and offset.
+ } else if (total > target.position) {
+ target.node = leaf.node;
+ // Difference between the selection start and the start of the
+ // node.
+ target.offset = target.position - total + textLength;
+ }
+ }
+ break;
+ case "block-start":
+ // Block start is a newline if the previous leaf was a text node in
+ // the body scope.
+ // Note that it is sufficient to test if the previous leaf was a text
+ // node, because if such a text node was not in the body scope we
+ // would have visited "block-end" in-between.
+ // If the body scope ended in a break we would have already have a
+ // newline, so there is no need to double count it.
+ if (prevLeaf?.type === "text") {
+ // If the total was already equal to a target.position, then the
+ // previous text node would have handled it in the
+ // (total === target.position)
+ // case above.
+ // So we can safely increase the total and let the next leaf handle
+ // it.
+ total += 1;
+ }
+ break;
+ case "block-end":
+ // Only create a newline if non-empty.
+ if (prevLeaf?.type !== "block-start") {
+ for (let target of selectionTargets) {
+ if (!target.node && total === target.position) {
+ // This should only happen for blocks that contain no text, such
+ // as a block that only contains a break.
+ target.node = leaf.node;
+ target.offset = leaf.node.childNodes.length - 1;
+ }
+ }
+ // Let the next leaf handle it.
+ total += 1;
+ }
+ break;
+ case "break":
+ // Only counts as a newline if it is not trailing in the body or block
+ // scope.
+ if (nextLeaf && nextLeaf.type !== "block-end") {
+ for (let target of selectionTargets) {
+ if (!target.node && total === target.position) {
+ // This should only happen for breaks that are at the start of a
+ // block.
+ // The break has no content, so the parent is used as the
+ // target.
+ let parentNode = leaf.node.parentNode;
+ target.node = parentNode;
+ let index = 0;
+ while (parentNode[index] !== leaf.node) {
+ index += 1;
+ }
+ target.offset = index;
+ }
+ }
+ total += 1;
+ }
+ break;
+ // Ignore type === "empty"
+ }
+ prevLeaf = leaf;
+ leaf = nextLeaf;
+ }
+
+ Assert.ok(
+ selectionTargets.every(target => target.node),
+ `Found selection from ${start} to ${end === null ? start : end}`
+ );
+
+ // Clear the current selection.
+ let selection = this.messageWindow.getSelection();
+ selection.removeAllRanges();
+
+ // Create the new one.
+ let range = this.messageDocument.createRange();
+ range.setStart(selectionTargets[0].node, selectionTargets[0].offset);
+ if (end !== null) {
+ range.setEnd(selectionTargets[1].node, selectionTargets[1].offset);
+ } else {
+ range.setEnd(selectionTargets[0].node, selectionTargets[0].offset);
+ }
+
+ let changePromise = BrowserTestUtils.waitForEvent(
+ this.messageDocument,
+ "selectionchange"
+ );
+ selection.addRange(range);
+
+ await changePromise;
+ }
+
+ /**
+ * Select the given text and delete it. See selectTextRange to know how to set
+ * the parameters.
+ *
+ * @param {number} start - The position to start selecting from.
+ * @param {number} end - The position to end selecting from, after start.
+ */
+ async deleteTextRange(start, end) {
+ await this.selectTextRange(start, end);
+ await this.deleteSelection();
+ }
+
+ /**
+ * @typedef BlockSummary
+ * @property {string} block - The tag name of the node.
+ * @property {(StyledTextSummary|string)[]} content - The regions of styled
+ * text content, ordered the same as in the document structure. String
+ * entries are equivalent to StyledTextSummary object with no set styling
+ * properties.
+ */
+
+ /**
+ * @typedef StyledTextSummary
+ * @property {string} text - The text for this region.
+ * @property {Set<string>} [tags] - The tags applied to this region, if any.
+ * When passing in an object, you can use an Array of strings instead, which
+ * will be converted into a Set when needed.
+ * @property {string} [font] - The font family applied to this region, if any.
+ * @property {number} [size] - The font size applied to this region, if any.
+ * @property {string} [color] - The font color applied to this region, if any.
+ */
+
+ /**
+ * Test if the two sets of tags are equal. undefined tags count as an empty
+ * set.
+ *
+ * @param {Set<string>|undefined} tags - A set of tags.
+ * @param {Set<string>|undefined} cmp - A set to compare against.
+ *
+ * @returns {boolean} - Whether the two sets are equal.
+ */
+ static equalTags(tags, cmp) {
+ if (!tags || tags.size === 0) {
+ return !cmp || cmp.size === 0;
+ }
+ if (!cmp) {
+ return false;
+ }
+ if (tags.size !== cmp.size) {
+ return false;
+ }
+ for (let t of tags) {
+ if (!cmp.has(t)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get a summary of the message body content.
+ *
+ * Note that the summary will exclude break nodes that do not produce a
+ * newline. That is break nodes between a text node and either:
+ * + the end of the body,
+ * + the start of a block, or
+ * + the end of a block.
+ *
+ * @returns {(BlockSummary|StyledTextSummary)[]} - A summary of the body
+ * content.
+ */
+ getMessageBodyContent() {
+ let cls = this.constructor;
+ let bodyNode = this.messageBody;
+ let bodyContent = [];
+ let blockNode = null;
+ let blockContent = null;
+ let prevLeaf = null;
+ let leaf = cls.firstLeaf(bodyNode);
+ // NOTE: Only the leaves of the body will contribute to the content, which
+ // is why we only need to traverse them.
+ while (leaf) {
+ // Look ahead at the next leaf.
+ let nextLeaf = cls.nextLeaf(bodyNode, leaf);
+ let isText = leaf.type === "text";
+ let isBreak = leaf.type === "break";
+ let isEmpty = leaf.type === "empty";
+ // Ignore a break node between a text node and either:
+ // + the end of the body,
+ // + the start of a block, or
+ // + the end of a block.
+ let ignoreBreak =
+ prevLeaf?.type === "text" &&
+ (!nextLeaf ||
+ nextLeaf.type === "block-start" ||
+ nextLeaf.type === "block-end");
+ if (leaf.type === "block-start") {
+ if (blockNode) {
+ throw new Error(
+ `Unexpected ${leaf.node.tagName} within a ${blockNode.tagName}`
+ );
+ }
+ // Set the block to add content to.
+ let block = { block: leaf.node.tagName, content: [] };
+ blockNode = leaf.node;
+ blockContent = block.content;
+ // Add to the content of the body.
+ bodyContent.push(block);
+ } else if (leaf.type === "block-end") {
+ if (!blockNode) {
+ throw new Error(`Unexpected block end for ${leaf.node.tagName}`);
+ }
+ // Remove the block to add content to.
+ blockNode = null;
+ blockContent = null;
+ } else if (isText || isEmpty || (isBreak && !ignoreBreak)) {
+ let tags;
+ let font;
+ let size;
+ let color;
+ let ancestorBlock = blockNode || bodyNode;
+ for (
+ // If empty, then we include the styling of the empty element.
+ let ancestor = isEmpty ? leaf.node : leaf.node.parentNode;
+ ancestor !== ancestorBlock;
+ ancestor = ancestor.parentNode
+ ) {
+ if (cls.isInlineStyle(ancestor)) {
+ if (!tags) {
+ tags = new Set();
+ }
+ tags.add(ancestor.tagName);
+ } else if (cls.isFont(ancestor)) {
+ // Prefer attributes from closest <font> ancestor.
+ if (font === undefined && ancestor.hasAttribute("face")) {
+ font = ancestor.getAttribute("face");
+ }
+ if (size === undefined && ancestor.hasAttribute("size")) {
+ size = Number(ancestor.getAttribute("size"));
+ }
+ if (color === undefined && ancestor.hasAttribute("color")) {
+ color = ancestor.getAttribute("color");
+ }
+ } else {
+ throw new Error(`Unknown format element ${ancestor.tagName}`);
+ }
+ }
+ let text;
+ if (isBreak) {
+ text = "<BR>";
+ } else if (isText) {
+ text = leaf.node.textContent;
+ } else {
+ // Empty styling elements.
+ text = "";
+ }
+
+ let content = blockContent || bodyContent;
+ let merged = false;
+ if (content.length) {
+ let prevSummary = content[content.length - 1];
+ // NOTE: prevSummary may be a block if this leaf lives in the body
+ // scope. We don't merge in that case.
+ if (
+ !prevSummary.block &&
+ cls.equalTags(prevSummary.tags, tags) &&
+ prevSummary.font === font &&
+ prevSummary.size === size &&
+ prevSummary.color === color
+ ) {
+ // Merge into the previous text if this region has the same text
+ // tags applied to it.
+ prevSummary.text += text;
+ merged = true;
+ }
+ }
+ if (!merged) {
+ let summary = { text };
+ summary.tags = tags;
+ summary.font = font;
+ summary.size = size;
+ summary.color = color;
+ content.push(summary);
+ }
+ }
+ prevLeaf = leaf;
+ leaf = nextLeaf;
+ }
+
+ if (blockNode) {
+ throw new Error(`Unexpected end of body within a ${blockNode.tagName}`);
+ }
+
+ return bodyContent;
+ }
+
+ /**
+ * Test that the current message body matches the given content.
+ *
+ * Note that the test is performed against a simplified version of the message
+ * body, where adjacent equivalent styling tags are merged together, and <BR>
+ * elements that do not produce a newline are ignored (see
+ * {@link FormatHelper#getMessageBodyContent}). This is to capture what the
+ * message would appear as to a user, rather than the exact details of the
+ * document structure.
+ *
+ * To represent breaks between text regions, simply include a "<BR>" in the
+ * expected text string. As such, the test cannot distinguish between a "<BR>"
+ * textContent and a break element, so do not use "<BR>" within the typed text
+ * of the message.
+ *
+ * @param {(BlockSummary|StyledTextSummary|string)[]} content - The expected
+ * content, ordered the same as in the document structure. BlockSummary
+ * objects represent blocks, and will have their own content.
+ * StyledTextSummary objects represent styled text directly in the body
+ * scope, and string objects represent un-styled text directly in the body
+ * scope.
+ * @param {string} assertMessage - A description of the test.
+ */
+ assertMessageBodyContent(content, assertMessage) {
+ let cls = this.constructor;
+
+ function message(message, below, index) {
+ return `${message} (at index ${index} below ${below})`;
+ }
+
+ function getDifference(node, expect, below, index) {
+ if (typeof expect === "string") {
+ expect = { text: expect };
+ }
+ if (expect.text !== undefined) {
+ // StyledTextSummary
+ if (node.text === undefined) {
+ return message("Is not a (styled) text region", below, index);
+ }
+ if (node.text !== expect.text) {
+ return message(
+ `Different text "${node.text}" vs "${expect.text}"`,
+ below,
+ index
+ );
+ }
+ if (Array.isArray(expect.tags)) {
+ expect.tags = new Set(expect.tags);
+ }
+ if (!cls.equalTags(node.tags, expect.tags)) {
+ function tagsToString(tags) {
+ if (!tags) {
+ return "NONE";
+ }
+ return Array.from(tags).join(",");
+ }
+ let have = tagsToString(node.tags);
+ let wanted = tagsToString(expect.tags);
+ return message(`Different tags ${have} vs ${wanted}`, below, index);
+ }
+ if (node.font !== expect.font) {
+ return message(
+ `Different font "${node.font}" vs "${expect.font}"`,
+ below,
+ index
+ );
+ }
+ if (node.size !== expect.size) {
+ return message(
+ `Different size ${node.size} vs ${expect.size}`,
+ below,
+ index
+ );
+ }
+ if (node.color !== expect.color) {
+ return message(
+ `Different color ${node.color} vs ${expect.color}`,
+ below,
+ index
+ );
+ }
+ return null;
+ } else if (expect.block !== undefined) {
+ if (node.block === undefined) {
+ return message("Is not a block", below, index);
+ }
+ if (node.block !== expect.block) {
+ return message(
+ `Different block names ${node.block} vs ${expect.block}`,
+ below,
+ index
+ );
+ }
+ let i;
+ for (i = 0; i < expect.content.length; i++) {
+ if (i >= node.content.length) {
+ return message("Missing child", node.block, i);
+ }
+ let childDiff = getDifference(
+ node.content[i],
+ expect.content[i],
+ node.block,
+ i
+ );
+ if (childDiff !== null) {
+ return childDiff;
+ }
+ }
+ if (i !== node.content.length) {
+ let extra = "";
+ for (; i < node.content.length; i++) {
+ let child = node.content[i];
+ if (child.text !== undefined) {
+ extra += child.text;
+ } else {
+ extra += `<${child.block}/>`;
+ }
+ }
+ return message(`Has extra children: ${extra}`, node.block, i);
+ }
+ return null;
+ }
+ throw new Error(message("Unrecognised object", below, index));
+ }
+
+ let expectBlock = { block: "BODY", content };
+ let bodyBlock = { block: "BODY", content: this.getMessageBodyContent() };
+
+ // We use a single Assert so that we can bail early if there is a
+ // difference. Only show the first difference found.
+ Assert.equal(
+ getDifference(bodyBlock, expectBlock, "HTML", 0),
+ null,
+ `${assertMessage}: Should be no difference in body content`
+ );
+ }
+
+ /**
+ * For debugging, print the message body content, as produced by
+ * {@link FormatHelper#getMessageBodyContent}.
+ */
+ dumpMessageBodyContent() {
+ function printTextSummary(textSummary, indent = "") {
+ let str = `${indent}<text`;
+ for (let prop in textSummary) {
+ let value = textSummary[prop];
+ switch (prop) {
+ case "text":
+ continue;
+ case "tags":
+ value = value ? Array.from(value).join(",") : undefined;
+ break;
+ }
+ if (value !== undefined) {
+ str += ` ${prop}="${value}"`;
+ }
+ }
+ str += `>${textSummary.text}</text>`;
+ console.log(str);
+ }
+
+ function printBlockSummary(blockSummary) {
+ console.log(`<${blockSummary.block}>`);
+ for (let textSummary of blockSummary.content) {
+ printTextSummary(textSummary, " ");
+ }
+ console.log(`</${blockSummary.block}>`);
+ }
+
+ for (let summary of this.getMessageBodyContent()) {
+ if (summary.block !== undefined) {
+ printBlockSummary(summary);
+ } else {
+ printTextSummary(summary);
+ }
+ }
+ }
+
+ /**
+ * Test that the message body contains a single paragraph block with the
+ * given content. See {@link FormatHelper#assertMessageBodyContent}.
+ *
+ * @param {(StyledTextSummary|string)[]} content - The expected content of the
+ * paragraph.
+ * @param {string} assertMessage - A description of the test.
+ */
+ assertMessageParagraph(content, assertMessage) {
+ this.assertMessageBodyContent([{ block: "P", content }], assertMessage);
+ }
+
+ /**
+ * Attempt to show a menu. The menu must be closed when calling.
+ *
+ * NOTE: this fails to open a native application menu on mac/osx because it is
+ * handled and restricted by the OS.
+ *
+ * @param {MozMenuPopup} menu - The menu to show.
+ *
+ * @returns {boolean} Whether the menu was opened. Otherwise, the menu is still
+ * closed.
+ */
+ async _openMenuOnce(menu) {
+ menu = menu.parentNode;
+ // NOTE: Calling openMenu(true) on a closed menu will put the menu in the
+ // "showing" state. But this can be cancelled (for some unknown reason) and
+ // the menu will be put back in the "hidden" state. Therefore we listen to
+ // both popupshown and popuphidden. See bug 1720174.
+ // NOTE: This only seems to happen for some platforms, specifically this
+ // sometimes occurs for the linux64 build on the try server.
+ // FIXME: Use only BrowserEventUtils.waitForEvent(menu, "popupshown")
+ let eventPromise = new Promise(resolve => {
+ let listener = event => {
+ menu.removeEventListener("popupshown", listener);
+ menu.removeEventListener("popuphidden", listener);
+ resolve(event.type);
+ };
+ menu.addEventListener("popupshown", listener);
+ menu.addEventListener("popuphidden", listener);
+ });
+ menu.openMenu(true);
+ let eventType = await eventPromise;
+ return eventType == "popupshown";
+ }
+
+ /**
+ * Show a menu. The menu must be closed when calling.
+ *
+ * @param {MozMenuPopup} menu - The menu to show.
+ */
+ async _openMenu(menu) {
+ if (!(await this._openMenuOnce(menu))) {
+ // If opening failed, try one more time. See bug 1720174.
+ Assert.ok(
+ await this._openMenuOnce(menu),
+ `Opening ${menu.id} should succeed on a second attempt`
+ );
+ }
+ }
+
+ /**
+ * Hide a menu. The menu must be open when calling.
+ *
+ * @param {MozMenuPopup} menu - The menu to hide.
+ */
+ async _closeMenu(menu) {
+ menu = menu.parentNode;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.openMenu(false);
+ await hiddenPromise;
+ }
+
+ /**
+ * Select a menu item from an open menu. This will also close the menu.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ * @param {MozMenuPopup} menu - The open menu that the item belongs to.
+ */
+ async _selectFromOpenMenu(item, menu) {
+ menu = menu.parentNode;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.menupopup.activateItem(item);
+ await hiddenPromise;
+ }
+
+ /**
+ * Open a menu, select one of its items and close the menu.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ * @param {MozMenuPopup} menu - The menu to open, that item belongs to.
+ */
+ async _selectFromClosedMenu(item, menu) {
+ if (item.disabled) {
+ await TestUtils.waitForCondition(
+ () => !item.disabled,
+ `Waiting for "${item.label}" to be enabled`
+ );
+ }
+ await this._openMenu(menu);
+ await this._selectFromOpenMenu(item, menu);
+ }
+
+ /**
+ * Close the [Format menu]{@link FormatHelper#formatMenu}, without selecting
+ * anything.
+ *
+ * Note, any open sub menus are also closed.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ */
+ async closeFormatMenu() {
+ // Closing format menu closes the sub menu.
+ await this._closeMenu(this.formatMenu);
+ }
+
+ /**
+ * Select an item directly below the
+ * [Format menu]{@link FormatHelper#formatMenu}.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ */
+ async selectFromFormatMenu(item) {
+ await this._openMenu(this.formatMenu);
+ await this._selectFromOpenMenu(item, this.formatMenu);
+ }
+
+ /**
+ * Open the [Format menu]{@link FormatHelper#formatMenu} and open one of its
+ * sub-menus, without selecting anything.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ *
+ * @param {MozMenuPopup} menu - A closed menu below the Format menu to open.
+ */
+ async openFormatSubMenu(menu) {
+ if (
+ !(await this._openMenuOnce(this.formatMenu)) ||
+ !(await this._openMenuOnce(menu))
+ ) {
+ // If opening failed, try one more time. See bug 1720174.
+ // NOTE: failing to open the sub-menu can cause the format menu to also
+ // close. But we still make sure the format menu is closed before trying
+ // again.
+ if (this.formatMenu.state == "open") {
+ await this._closeMenu(this.formatMenu);
+ }
+ Assert.ok(
+ await this._openMenuOnce(this.formatMenu),
+ "Opening format menu should succeed on a second attempt"
+ );
+ Assert.ok(
+ await this._openMenuOnce(menu),
+ `Opening format sub-menu ${menu.id} should succeed on a second attempt`
+ );
+ }
+ }
+
+ /**
+ * Select an item from a sub-menu of the
+ * [Format menu]{@link FormatHelper#formatMenu}. The menu is opened before
+ * selecting.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx because the Format
+ * menu is part of the native application menu, which cannot be activated
+ * through mochitests.
+ *
+ * @param {MozMenuItem} item - The item to select.
+ * @param {MozMenuPopup} menu - The Format sub-menu that the item belongs to.
+ */
+ async selectFromFormatSubMenu(item, menu) {
+ if (item.disabled) {
+ await TestUtils.waitForCondition(
+ () => !item.disabled,
+ `Waiting for "${item.label}" to be enabled`
+ );
+ }
+ await this.openFormatSubMenu(menu);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ this.formatMenu,
+ "popuphidden"
+ );
+ // Selecting from the submenu also closes the parent menu.
+ await this._selectFromOpenMenu(item, menu);
+ await hiddenPromise;
+ }
+
+ /**
+ * Run a test with the format sub menu open. Before each test attempt, the
+ * [Format menu]{@link FormatHelper#formatMenu} is opened and so is the given
+ * sub-menu. After each attempt, the menu is closed.
+ *
+ * Note, the Format menu must be closed before calling.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {MozMenuPopup} menu - A closed menu below the Format menu to open.
+ * @param {Function} test - A test to run, without arguments, when the menu is
+ * open. Should return a truthy value on success.
+ * @param {string} message - The message to use when asserting the success of
+ * the test.
+ * @param {boolean} [wait] - Whether to retry until the test passes.
+ */
+ async assertWithFormatSubMenu(menu, test, message, wait = false) {
+ let performTest = async () => {
+ await this.openFormatSubMenu(menu);
+ let pass = test();
+ await this.closeFormatMenu();
+ return pass;
+ };
+ if (wait) {
+ await TestUtils.waitForCondition(performTest, message);
+ } else {
+ Assert.ok(await performTest(), message);
+ }
+ }
+
+ /**
+ * Select a paragraph state for the editor, using toolbar selector.
+ *
+ * @param {string} state - The state to select.
+ */
+ async selectParagraphState(state) {
+ await this._selectFromClosedMenu(
+ this.paragraphStateSelectorMenu.querySelector(
+ `menuitem[value="${state}"]`
+ ),
+ this.paragraphStateSelectorMenu
+ );
+ }
+
+ /**
+ * Get the menu item corresponding to the given state, that lives in the
+ * [Paragraph sub-menu]{@link FormatHelper#paragraphStateMenu} below the
+ * Format menu.
+ *
+ * @param {string} state - A state.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given state.
+ */
+ getParagraphStateMenuItem(state) {
+ return this.paragraphStateMenu.querySelector(`menuitem[value="${state}"]`);
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given paragraph state.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {string|null} state - The expected paragraph state, or null if the
+ * state should be shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownParagraphState(state, message) {
+ if (state === null) {
+ // In mixed state.
+ // getAttribute("value") currently returns "", rather than null, so test
+ // for hasAttribute instead.
+ await TestUtils.waitForCondition(
+ () => !this.paragraphStateSelector.hasAttribute("value"),
+ `${message}: Selector has no value`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => this.paragraphStateSelector.value === state,
+ `${message}: Selector has the value "${state}"`
+ );
+ }
+
+ await this.assertWithFormatSubMenu(
+ this.paragraphStateMenu,
+ () =>
+ this.paragraphStateMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === state)
+ ),
+ `${message}: Only state="${state}" menu item should be checked`
+ );
+ }
+
+ /**
+ * Select a font family for the editor, using the toolbar selector.
+ *
+ * @param {string} font - The font family to select.
+ */
+ async selectFont(font) {
+ await this._selectFromClosedMenu(
+ this.fontSelectorMenu.querySelector(`menuitem[value="${font}"]`),
+ this.fontSelectorMenu
+ );
+ }
+
+ /**
+ * Get the menu item corresponding to the given font family, that lives in
+ * the [Font sub-menu]{@link FormatHelper#fontMenu} below the Format menu.
+ *
+ * @param {string} font - A font family.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given font
+ * family.
+ */
+ getFontMenuItem(font) {
+ return this.fontMenu.querySelector(`menuitem[value="${font}"]`);
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given font family.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {string|null} font - The expected font family, or null if the state
+ * should be shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownFont(font, message) {
+ if (font === null) {
+ // In mixed state.
+ // getAttribute("value") currently returns "", rather than null, so test
+ // for hasAttribute instead.
+ await TestUtils.waitForCondition(
+ () => !this.fontSelector.hasAttribute("value"),
+ `${message}: Selector has no value`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => this.fontSelector.value === font,
+ `${message}: Selector value is "${font}"`
+ );
+ }
+
+ await this.assertWithFormatSubMenu(
+ this.fontMenu,
+ () =>
+ this.fontMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === font)
+ ),
+ `${message}: Only font="${font}" menu item should be checked`
+ );
+ }
+
+ /**
+ * Select a font size for the editor, using the toolbar selector.
+ *
+ * @param {number} size - The font size to select.
+ */
+ async selectSize(size) {
+ await this._selectFromClosedMenu(
+ this.sizeSelectorMenu.querySelector(`menuitem[value="${size}"]`),
+ this.sizeSelectorMenu
+ );
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given font size.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {number|null} size - The expected font size, or null if the state
+ * should be shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownSize(size, message) {
+ size = size?.toString();
+ // Test in Format Menu.
+ await this.assertWithFormatSubMenu(
+ this.sizeMenu,
+ () =>
+ this.sizeMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === size)
+ ),
+ `${message}: Only size=${size} Format menu item should be checked`
+ // Don't have to wait for size menu.
+ );
+ // Test the same in the Toolbar selector.
+ await this._openMenu(this.sizeSelectorMenu);
+ Assert.ok(
+ this.sizeSelectorMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") === (item.value === size)
+ ),
+ `${message}: Only size=${size} Toolbar menu item should be checked`
+ );
+ await this._closeMenu(this.sizeSelectorMenu);
+ }
+
+ /**
+ * Get the menu item corresponding to the given font size, that lives in
+ * the [Size sub-menu]{@link FormatHelper#sizeMenu} below the Format menu.
+ *
+ * @param {number} size - A font size.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given font
+ * size.
+ */
+ getSizeMenuItem(size) {
+ return this.sizeMenu.querySelector(`menuitem[value="${size}"]`);
+ }
+
+ /**
+ * Select the given color when the color picker dialog is opened.
+ *
+ * Note, the dialog will have to be opened separately to this method. Normally
+ * after this method, but before awaiting on the promise.
+ *
+ * @property {string|null} - The color to choose, or null to choose the default.
+ *
+ * @returns {Promise} - The promise to await on once the dialog is triggered.
+ */
+ async selectColorInDialog(color) {
+ return BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://messenger/content/messengercompose/EdColorPicker.xhtml",
+ {
+ callback: async win => {
+ if (color === null) {
+ win.document.getElementById("DefaultColorButton").click();
+ } else {
+ win.document.getElementById("ColorInput").value = color;
+ }
+ win.document.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ }
+
+ /**
+ * Select a font color for the editor, using the toolbar selector.
+ *
+ * @param {string} font - The font color to select.
+ */
+ async selectColor(color) {
+ let selector = this.selectColorInDialog(color);
+ this.colorSelector.click();
+ await selector;
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given font color.
+ *
+ * @param {{value: string, rgb: [number]}|""|null} color - The expected font
+ * color. You should supply both the value, as set in the test, and its
+ * corresponding RGB numbers. Alternatively, give "" to assert the default
+ * color, or null to assert that the font color is shown as mixed.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownColor(color, message) {
+ if (color === "") {
+ color = { value: "", rgb: [0, 0, 0] };
+ }
+
+ let rgbRegex = /^rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)$/;
+ let testOnce = foundColor => {
+ if (color === null) {
+ return foundColor === "mixed";
+ }
+ // color can either be the value or an rgb.
+ let foundRgb = rgbRegex.exec(foundColor);
+ if (foundRgb) {
+ foundRgb = foundRgb.slice(1).map(s => Number(s));
+ return (
+ foundRgb[0] === color.rgb[0] &&
+ foundRgb[1] === color.rgb[1] &&
+ foundRgb[2] === color.rgb[2]
+ );
+ }
+ return foundColor === color.value;
+ };
+
+ let name = color === null ? '"mixed"' : `"${color.value}"`;
+ let foundColor = this.colorSelector.getAttribute("color");
+ if (testOnce(foundColor)) {
+ Assert.ok(
+ true,
+ `${message}: Found color "${foundColor}" should match ${name}`
+ );
+ return;
+ }
+ await TestUtils.waitForCondition(() => {
+ let colorNow = this.colorSelector.getAttribute("color");
+ if (colorNow !== foundColor) {
+ foundColor = colorNow;
+ return true;
+ }
+ return false;
+ }, `${message}: Waiting for the color to change from ${foundColor}`);
+ Assert.ok(
+ testOnce(foundColor),
+ `${message}: Changed color "${foundColor}" should match ${name}`
+ );
+ }
+
+ /**
+ * Get the menu item corresponding to the given style, that lives in the
+ * [Text Style sub-menu]{@link FormatHelper#styleMenu} below the Format menu.
+ *
+ * @param {string} style - A style.
+ *
+ * @returns {MozMenuItem} - The menu item used for selecting the given style.
+ */
+ getStyleMenuItem(style) {
+ return this.styleMenu.querySelector(`menuitem[observes="cmd_${style}"]`);
+ }
+
+ /**
+ * Select the given style from the [Style menu]{@link FormatHelper#styleMenu}.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * @param {StyleData} style - The style data for the style to select.
+ */
+ async selectStyle(styleData) {
+ await this.selectFromFormatSubMenu(styleData.item, this.styleMenu);
+ }
+
+ /**
+ * Assert that the editor UI (eventually) shows the given text styles.
+ *
+ * Note, this method does not currently work on mac/osx.
+ *
+ * Implied styles (see {@link StyleData#linked} and {@linj StyleData#implies})
+ * will be automatically checked for from the given styles.
+ *
+ * @param {[(StyleData|string)]|StyleData|string|null} styleSet - The styles
+ * to assert as shown. If none should be shown, given null. Otherwise,
+ * styles can either be specified by their style name (as used in
+ * {@link FormatHelper#styleDataMap}) or by the style data directly. Either
+ * an array of styles can be passed, or a single style.
+ * @param {string} message - A message to use in assertions.
+ */
+ async assertShownStyles(styleSet, message) {
+ let expectItems = [];
+ let expectString;
+ let isBold = false;
+ let isItalic = false;
+ let isUnderline = false;
+ if (styleSet) {
+ expectString = "Only ";
+ let first = true;
+ let addSingleStyle = data => {
+ if (!data) {
+ return;
+ }
+ isBold = isBold || data.name === "bold";
+ isItalic = isItalic || data.name === "italic";
+ isUnderline = isUnderline || data.name === "underline";
+ expectItems.push(data.item);
+ if (first) {
+ first = false;
+ } else {
+ expectString += ", ";
+ }
+ expectString += data.name;
+ };
+ let addStyle = style => {
+ if (typeof style === "string") {
+ style = this.styleDataMap.get(style);
+ }
+ addSingleStyle(style);
+ addSingleStyle(style.linked);
+ addSingleStyle(style.implies);
+ };
+
+ if (Array.isArray(styleSet)) {
+ styleSet.forEach(style => addStyle(style));
+ } else {
+ addStyle(styleSet);
+ }
+ } else {
+ expectString = "None";
+ }
+ await this.assertWithFormatSubMenu(
+ this.styleMenu,
+ () => {
+ let checkedIds = this.styleMenuItems
+ .filter(i => i.getAttribute("checked") === "true")
+ .map(m => m.id);
+ if (expectItems.length != checkedIds.length) {
+ dump(
+ `Expected: ${expectItems.map(i => i.id)}, Actual: ${checkedIds}\n`
+ );
+ }
+ return this.styleMenuItems.every(
+ item =>
+ (item.getAttribute("checked") === "true") ===
+ expectItems.includes(item)
+ );
+ },
+ `${message}: ${expectString} should be checked`,
+ true
+ );
+
+ // Check the toolbar buttons.
+ Assert.equal(
+ this.boldButton.checked,
+ isBold,
+ `${message}: Bold button should be ${isBold ? "" : "un"}checked`
+ );
+ Assert.equal(
+ this.italicButton.checked,
+ isItalic,
+ `${message}: Italic button should be ${isItalic ? "" : "un"}checked`
+ );
+ Assert.equal(
+ this.underlineButton.checked,
+ isUnderline,
+ `${message}: Underline button should be ${isUnderline ? "" : "un"}checked`
+ );
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm b/comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm
new file mode 100644
index 0000000000..3d7ba3ee2c
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/ContentTabHelpers.jsm
@@ -0,0 +1,423 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "open_content_tab_with_url",
+ "open_content_tab_with_click",
+ "plan_for_content_tab_load",
+ "wait_for_content_tab_load",
+ "assert_content_tab_has_favicon",
+ "content_tab_e",
+ "get_content_tab_element_display",
+ "assert_content_tab_element_hidden",
+ "assert_content_tab_element_visible",
+ "wait_for_content_tab_element_display",
+ "get_element_by_text",
+ "assert_content_tab_text_present",
+ "assert_content_tab_text_absent",
+ "NotificationWatcher",
+ "get_notification_bar_for_tab",
+ "updateBlocklist",
+ "setAndUpdateBlocklist",
+ "resetBlocklist",
+ "gMockExtProtSvcReg",
+ "gMockExtProtSvc",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var folderDisplayHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { MockObjectReplacer } = ChromeUtils.import(
+ "resource://testing-common/mozmill/MockObjectHelpers.jsm"
+);
+var wh = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+
+var FAST_TIMEOUT = 1000;
+var FAST_INTERVAL = 100;
+var EXT_PROTOCOL_SVC_CID = "@mozilla.org/uriloader/external-protocol-service;1";
+
+var mc = folderDisplayHelper.mc;
+
+var _originalBlocklistURL = null;
+
+var gMockExtProtSvcReg = new MockObjectReplacer(
+ EXT_PROTOCOL_SVC_CID,
+ MockExtProtConstructor
+);
+
+/**
+ * gMockExtProtocolSvc allows us to capture (most if not all) attempts to
+ * open links in the default browser.
+ */
+var gMockExtProtSvc = {
+ _loadedURLs: [],
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+
+ externalProtocolHandlerExists(aProtocolScheme) {},
+
+ getApplicationDescription(aScheme) {},
+
+ getProtocolHandlerInfo(aProtocolScheme) {},
+
+ getProtocolHandlerInfoFromOS(aProtocolScheme, aFound) {},
+
+ isExposedProtocol(aProtocolScheme) {},
+
+ loadURI(aURI, aWindowContext) {
+ this._loadedURLs.push(aURI.spec);
+ },
+
+ setProtocolHandlerDefaults(aHandlerInfo, aOSHandlerExists) {},
+
+ urlLoaded(aURL) {
+ return this._loadedURLs.includes(aURL);
+ },
+};
+
+function MockExtProtConstructor() {
+ return gMockExtProtSvc;
+}
+
+/* Allows for planning / capture of notification events within
+ * content tabs, for example: plugin crash notifications, theme
+ * install notifications.
+ */
+var ALERT_TIMEOUT = 10000;
+
+var NotificationWatcher = {
+ planForNotification(aController) {
+ this.alerted = false;
+ aController.window.document.addEventListener(
+ "AlertActive",
+ this.alertActive
+ );
+ },
+ waitForNotification(aController) {
+ if (!this.alerted) {
+ utils.waitFor(
+ () => this.alerted,
+ "Timeout waiting for alert",
+ ALERT_TIMEOUT,
+ 100
+ );
+ }
+ // Double check the notification box has finished animating.
+ let notificationBox = mc.window.document
+ .getElementById("tabmail")
+ .selectedTab.panel.querySelector("notificationbox");
+ if (notificationBox && notificationBox._animating) {
+ utils.waitFor(
+ () => !notificationBox._animating,
+ "Timeout waiting for notification box animation to finish",
+ ALERT_TIMEOUT,
+ 100
+ );
+ }
+
+ aController.window.document.removeEventListener(
+ "AlertActive",
+ this.alertActive
+ );
+ },
+ alerted: false,
+ alertActive() {
+ NotificationWatcher.alerted = true;
+ },
+};
+
+/**
+ * Opens a content tab with the given URL.
+ *
+ * @param {string} aURL - The URL to load.
+ * @param {string} [aLinkHandler=null] - See specialTabs.contentTabType.openTab.
+ * @param {boolean} [aBackground=false] Whether the tab is opened in the background.
+ *
+ * @returns {object} The newly-opened tab.
+ */
+function open_content_tab_with_url(
+ aURL,
+ aLinkHandler = null,
+ aBackground = false
+) {
+ let tabmail = mc.window.document.getElementById("tabmail");
+ let preCount = tabmail.tabContainer.allTabs.length;
+ tabmail.openTab("contentTab", {
+ url: aURL,
+ background: aBackground,
+ linkHandler: aLinkHandler,
+ });
+ utils.waitFor(
+ () => tabmail.tabContainer.allTabs.length == preCount + 1,
+ "Timeout waiting for the content tab to open with URL: " + aURL,
+ FAST_TIMEOUT,
+ FAST_INTERVAL
+ );
+
+ // We append new tabs at the end, so check the last one.
+ let expectedNewTab = tabmail.tabInfo[preCount];
+ folderDisplayHelper.assert_selected_tab(expectedNewTab);
+ wait_for_content_tab_load(expectedNewTab, aURL);
+ return expectedNewTab;
+}
+
+/**
+ * Opens a content tab with a click on the given element. The tab is expected to
+ * be opened in the foreground. The element is expected to be associated with
+ * the given controller.
+ *
+ * @param aElem The element to click or a function that causes the tab to open.
+ * @param aExpectedURL The URL that is expected to be opened (string).
+ * @param [aController] The controller the element is associated with. Defaults
+ * to |mc|.
+ * @param [aTabType] Optional tab type to expect (string).
+ * @returns The newly-opened tab.
+ */
+function open_content_tab_with_click(
+ aElem,
+ aExpectedURL,
+ aController,
+ aTabType = "contentTab"
+) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+
+ let preCount =
+ aController.window.document.getElementById("tabmail").tabContainer.allTabs
+ .length;
+ if (typeof aElem != "function") {
+ EventUtils.synthesizeMouseAtCenter(aElem, {}, aElem.ownerGlobal);
+ } else {
+ aElem();
+ }
+
+ utils.waitFor(
+ () =>
+ aController.window.document.getElementById("tabmail").tabContainer.allTabs
+ .length ==
+ preCount + 1,
+ "Timeout waiting for the content tab to open",
+ FAST_TIMEOUT,
+ FAST_INTERVAL
+ );
+
+ // We append new tabs at the end, so check the last one.
+ let expectedNewTab =
+ aController.window.document.getElementById("tabmail").tabInfo[preCount];
+ folderDisplayHelper.assert_selected_tab(expectedNewTab);
+ folderDisplayHelper.assert_tab_mode_name(expectedNewTab, aTabType);
+ wait_for_content_tab_load(expectedNewTab, aExpectedURL);
+ return expectedNewTab;
+}
+
+/**
+ * Call this before triggering a page load that you are going to wait for using
+ * |wait_for_content_tab_load|. This ensures that if a page is already displayed
+ * in the given tab that state is sufficiently cleaned up so it doesn't trick us
+ * into thinking that there is no need to wait.
+ *
+ * @param [aTab] optional tab, defaulting to the current tab.
+ */
+function plan_for_content_tab_load(aTab) {
+ if (aTab === undefined) {
+ aTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ }
+ aTab.pageLoaded = false;
+}
+
+/**
+ * Waits for the given content tab to load completely with the given URL. This
+ * is expected to be accompanied by a |plan_for_content_tab_load| right before
+ * the action triggering the page load takes place.
+ *
+ * Note that you cannot call |plan_for_content_tab_load| if you're opening a new
+ * tab. That is fine, because pageLoaded is initially false.
+ *
+ * @param [aTab] Optional tab, defaulting to the current tab.
+ * @param aURL The URL being loaded in the tab.
+ * @param [aTimeout] Optional time to wait for the load.
+ */
+function wait_for_content_tab_load(aTab, aURL, aTimeout) {
+ if (aTab === undefined) {
+ aTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ }
+
+ function isLoadedChecker() {
+ // Require that the progress listener think that the page is loaded.
+ if (!aTab.pageLoaded) {
+ return false;
+ }
+ // Also require that our tab infrastructure thinks that the page is loaded.
+ return !aTab.busy;
+ }
+
+ utils.waitFor(
+ isLoadedChecker,
+ "Timeout waiting for the content tab page to load.",
+ aTimeout
+ );
+ // The above may return immediately, meaning the event queue might not get a
+ // chance. Give it a chance now.
+ utils.sleep(0);
+ // Finally, require that the tab's browser thinks that no page is being loaded.
+ wh.wait_for_browser_load(aTab.browser, aURL);
+}
+
+/**
+ * Gets the element with the given ID from the content tab's displayed page.
+ */
+function content_tab_e(aTab, aId) {
+ return aTab.browser.contentDocument.getElementById(aId);
+}
+
+/**
+ * Assert that the given content tab has the given URL loaded as a favicon.
+ */
+function assert_content_tab_has_favicon(aTab, aURL) {
+ Assert.equal(aTab.favIconUrl, aURL, "Checking tab favicon");
+}
+
+/**
+ * Returns the current "display" style property of an element.
+ */
+function get_content_tab_element_display(aTab, aElem) {
+ let style = aTab.browser.contentWindow.getComputedStyle(aElem);
+ return style.getPropertyValue("display");
+}
+
+/**
+ * Asserts that the given element is hidden from view on the page.
+ */
+function assert_content_tab_element_hidden(aTab, aElem) {
+ let display = get_content_tab_element_display(aTab, aElem);
+ Assert.equal(display, "none", "Element should be hidden");
+}
+
+/**
+ * Asserts that the given element is visible on the page.
+ */
+function assert_content_tab_element_visible(aTab, aElem) {
+ let display = get_content_tab_element_display(aTab, aElem);
+ Assert.notEqual(display, "none", "Element should be visible");
+}
+
+/**
+ * Waits for the element's display property indicate it is visible.
+ */
+function wait_for_content_tab_element_display(aTab, aElem) {
+ function isValue() {
+ return get_content_tab_element_display(aTab, aElem) != "none";
+ }
+ try {
+ utils.waitFor(isValue);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Timeout waiting for element to become visible"
+ );
+ } else {
+ throw e;
+ }
+ }
+}
+
+/**
+ * Finds element in document fragment, containing only the specified text
+ * as its textContent value.
+ *
+ * @param aRootNode Root node of the node tree where search should start.
+ * @param aText The string to search.
+ */
+function get_element_by_text(aRootNode, aText) {
+ // Check every node existing.
+ let nodes = aRootNode.querySelectorAll("*");
+ for (let node of nodes) {
+ // We ignore surrounding whitespace.
+ if (node.textContent.trim() == aText) {
+ return node;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Finds element containing only the specified text in the content tab's page.
+ */
+function get_content_tab_element_by_text(aTab, aText) {
+ let doc = aTab.browser.contentDocument.documentElement;
+ return get_element_by_text(doc, aText);
+}
+
+/**
+ * Asserts that the given text is present on the content tab's page.
+ */
+function assert_content_tab_text_present(aTab, aText) {
+ Assert.ok(
+ get_content_tab_element_by_text(aTab, aText),
+ `String "${aText}" should be on the content tab's page`
+ );
+}
+
+/**
+ * Asserts that the given text is absent on the content tab's page.
+ */
+function assert_content_tab_text_absent(aTab, aText) {
+ Assert.ok(
+ !get_content_tab_element_by_text(aTab, aText),
+ `String "${aText}" should not be on the content tab's page`
+ );
+}
+
+/**
+ * Returns the notification bar for a tab if one is currently visible,
+ * null if otherwise.
+ */
+function get_notification_bar_for_tab(aTab) {
+ let notificationBoxEls = mc.window.document
+ .getElementById("tabmail")
+ .selectedTab.panel.querySelector("notificationbox");
+ if (!notificationBoxEls) {
+ return null;
+ }
+
+ return notificationBoxEls;
+}
+
+function updateBlocklist(aController, aCallback) {
+ let observer = function () {
+ Services.obs.removeObserver(observer, "blocklist-updated");
+ aController.window.setTimeout(aCallback, 0);
+ };
+ Services.obs.addObserver(observer, "blocklist-updated");
+ Services.blocklist.QueryInterface(Ci.nsITimerCallback).notify(null);
+}
+
+function setAndUpdateBlocklist(aController, aURL, aCallback) {
+ if (!_originalBlocklistURL) {
+ _originalBlocklistURL = Services.prefs.getCharPref(
+ "extensions.blocklist.url"
+ );
+ }
+ Services.prefs.setCharPref("extensions.blocklist.url", aURL);
+ updateBlocklist(aController, aCallback);
+}
+
+function resetBlocklist(aController, aCallback) {
+ Services.prefs.setCharPref("extensions.blocklist.url", _originalBlocklistURL);
+ updateBlocklist(aController, aCallback);
+}
diff --git a/comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm b/comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm
new file mode 100644
index 0000000000..01adbb0cda
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/CustomizationHelpers.jsm
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["CustomizeDialogHelper"];
+
+var wh = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var USE_SHEET_PREF = "toolbar.customization.usesheet";
+
+/**
+ * Initialize the help for a customization dialog
+ *
+ * @param {} aToolbarId
+ * the ID of the toolbar to be customized
+ * @param {} aOpenElementId
+ * the ID of the element to be clicked on to open the dialog
+ * @param {} aWindowType
+ * the windowType of the window containing the dialog to be opened
+ */
+function CustomizeDialogHelper(aToolbarId, aOpenElementId, aWindowType) {
+ this._toolbarId = aToolbarId;
+ this._openElementId = aOpenElementId;
+ this._windowType = aWindowType;
+ this._openInWindow = !Services.prefs.getBoolPref(USE_SHEET_PREF);
+}
+
+CustomizeDialogHelper.prototype = {
+ /**
+ * Open a customization dialog by clicking on a given element.
+ *
+ * @param {} aController
+ * the controller object of the window for which the customization
+ * dialog should be opened
+ * @returns a controller for the customization dialog
+ */
+ async open(aController) {
+ aController.window.document.getElementById(this._openElementId).click();
+
+ let ctc;
+ // Depending on preferences the customization dialog is
+ // either a normal window or embedded into a sheet.
+ if (!this._openInWindow) {
+ ctc = wh.wait_for_frame_load(
+ aController.window.document.getElementById(
+ "customizeToolbarSheetIFrame"
+ ),
+ "chrome://messenger/content/customizeToolbar.xhtml"
+ );
+ } else {
+ ctc = wh.wait_for_existing_window(this._windowType);
+ }
+ utils.sleep(500);
+ return ctc;
+ },
+
+ /**
+ * Close the customization dialog.
+ *
+ * @param {} aCtc
+ * the controller object of the customization dialog which should be closed
+ */
+ close(aCtc) {
+ if (this._openInWindow) {
+ wh.plan_for_window_close(aCtc);
+ }
+
+ let doneButton = aCtc.window.document.getElementById("donebutton");
+ EventUtils.synthesizeMouseAtCenter(doneButton, {}, doneButton.ownerGlobal);
+ utils.sleep(0);
+ // XXX There should be an equivalent for testing the closure of
+ // XXX the dialog embedded in a sheet, but I do not know how.
+ if (this._openInWindow) {
+ wh.wait_for_window_close();
+ Assert.ok(aCtc.window.closed, "The customization dialog is not closed.");
+ }
+ },
+
+ /**
+ * Restore the default buttons in the header pane toolbar
+ * by clicking the corresponding button in the palette dialog
+ * and check if it worked.
+ *
+ * @param {} aController
+ * the controller object of the window for which the customization
+ * dialog should be opened
+ */
+ async restoreDefaultButtons(aController) {
+ let ctc = await this.open(aController);
+ let restoreButton = ctc.window.document
+ .getElementById("main-box")
+ .querySelector("[oncommand*='overlayRestoreDefaultSet();']");
+
+ EventUtils.synthesizeMouseAtCenter(
+ restoreButton,
+ {},
+ restoreButton.ownerGlobal
+ );
+ utils.sleep(0);
+
+ this.close(ctc);
+
+ let toolbar = aController.window.document.getElementById(this._toolbarId);
+ let defaultSet = toolbar.getAttribute("defaultset");
+
+ Assert.equal(toolbar.currentSet, defaultSet);
+ },
+};
diff --git a/comm/mail/test/browser/shared-modules/DOMHelpers.jsm b/comm/mail/test/browser/shared-modules/DOMHelpers.jsm
new file mode 100644
index 0000000000..719de9c381
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/DOMHelpers.jsm
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "assert_element_visible",
+ "element_visible_recursive",
+ "assert_element_not_visible",
+ "wait_for_element",
+ "assert_next_nodes",
+ "assert_previous_nodes",
+ "wait_for_element_enabled",
+ "check_element_visible",
+ "wait_for_element_visible",
+ "wait_for_element_invisible",
+ "collapse_panes",
+];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "mc",
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+/**
+ * This function takes either a string or an elementlibs.Elem, and returns
+ * whether it is hidden or not (simply by poking at its hidden property). It
+ * doesn't try to do anything smart, like is it not into view, or whatever.
+ *
+ * @param aElt The element to query.
+ * @returns Whether the element is visible or not.
+ */
+function element_visible(aElt) {
+ let e;
+ if (typeof aElt == "string") {
+ e = lazy.mc.window.document.getElementById(aElt);
+ } else {
+ e = aElt;
+ }
+ return !e.hidden;
+}
+
+/**
+ * Assert that en element's visible.
+ *
+ * @param aElt The element, an ID or an elementlibs.Elem
+ * @param aWhy The error message in case of failure
+ */
+function assert_element_visible(aElt, aWhy) {
+ Assert.ok(element_visible(aElt), aWhy);
+}
+
+/**
+ * Returns if a element is visible by traversing all parent elements and check
+ * that all are visible.
+ *
+ * @param aElem The element to be checked
+ */
+function element_visible_recursive(aElem) {
+ if (aElem.hidden || aElem.collapsed) {
+ return false;
+ }
+ let parent = aElem.parentNode;
+ if (parent == null) {
+ return true;
+ }
+
+ // #tabpanelcontainer and its parent #tabmail-tabbox have the same selectedPanel.
+ // Don't ask me why, it's just the way it is.
+ if (
+ "selectedPanel" in parent &&
+ parent.selectedPanel != aElem &&
+ aElem.id != "tabpanelcontainer"
+ ) {
+ return false;
+ }
+ return element_visible_recursive(parent);
+}
+
+/**
+ * Assert that en element's not visible.
+ *
+ * @param aElt The element, an ID or an elementlibs.Elem
+ * @param aWhy The error message in case of failure
+ */
+function assert_element_not_visible(aElt, aWhy) {
+ Assert.ok(!element_visible(aElt), aWhy);
+}
+
+/**
+ * Wait for and return an element matching a particular CSS selector.
+ *
+ * @param aParent the node to begin searching from
+ * @param aSelector the CSS selector to search with
+ */
+function wait_for_element(aParent, aSelector) {
+ let target = null;
+ utils.waitFor(function () {
+ target = aParent.querySelector(aSelector);
+ return target != null;
+ }, "Timed out waiting for a target for selector: " + aSelector);
+
+ return target;
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart and the aNum next
+ * siblings of aStart are nodes of type aNodeType.
+ *
+ * @param aNodeType the type of node to look for, example: "br".
+ * @param aStart the first node to check.
+ * @param aNum the number of sibling br nodes to check for.
+ */
+function assert_next_nodes(aNodeType, aStart, aNum) {
+ let node = aStart;
+ for (let i = 0; i < aNum; ++i) {
+ node = node.nextSibling;
+ if (node.localName != aNodeType) {
+ throw new Error(
+ "The node should be followed by " +
+ aNum +
+ " nodes of " +
+ "type " +
+ aNodeType
+ );
+ }
+ }
+ return node;
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart and the aNum previous
+ * siblings of aStart are nodes of type aNodeType.
+ *
+ * @param aNodeType the type of node to look for, example: "br".
+ * @param aStart the first node to check.
+ * @param aNum the number of sibling br nodes to check for.
+ */
+function assert_previous_nodes(aNodeType, aStart, aNum) {
+ let node = aStart;
+ for (let i = 0; i < aNum; ++i) {
+ node = node.previousSibling;
+ if (node.localName != aNodeType) {
+ throw new Error(
+ "The node should be preceded by " +
+ aNum +
+ " nodes of " +
+ "type " +
+ aNodeType
+ );
+ }
+ }
+ return node;
+}
+
+/**
+ * Given some element, wait for that element to be enabled or disabled,
+ * depending on the value of aEnabled.
+ *
+ * @param aController the controller parent of the element
+ * @param aNode the element to check.
+ * @param aEnabled whether or not the node should be enabled, or disabled.
+ */
+function wait_for_element_enabled(aController, aElement, aEnabled) {
+ if (!("disabled" in aElement)) {
+ throw new Error(
+ "Element does not appear to have disabled property; id=" + aElement.id
+ );
+ }
+
+ utils.waitFor(
+ () => aElement.disabled != aEnabled,
+ "Element should have eventually been " +
+ (aEnabled ? "enabled" : "disabled") +
+ "; id=" +
+ aElement.id
+ );
+}
+
+function check_element_visible(aController, aId) {
+ let element = aController.window.document.getElementById(aId);
+ if (!element) {
+ return false;
+ }
+
+ while (element) {
+ if (
+ element.hidden ||
+ element.collapsed ||
+ element.clientWidth == 0 ||
+ element.clientHeight == 0 ||
+ aController.window.getComputedStyle(element).display == "none"
+ ) {
+ return false;
+ }
+ element = element.parentElement;
+ }
+ return true;
+}
+
+/**
+ * Wait for a particular element to become fully visible.
+ *
+ * @param aController the controller parent of the element
+ * @param aId id of the element to wait for
+ */
+function wait_for_element_visible(aController, aId) {
+ utils.waitFor(function () {
+ return check_element_visible(aController, aId);
+ }, "Timed out waiting for element with ID=" + aId + " to become visible");
+}
+
+/**
+ * Wait for a particular element to become fully invisible.
+ *
+ * @param aController the controller parent of the element
+ * @param aId id of the element to wait for
+ */
+function wait_for_element_invisible(aController, aId) {
+ utils.waitFor(function () {
+ return !check_element_visible(aController, aId);
+ }, "Timed out waiting for element with ID=" + aId + " to become invisible");
+}
+
+/**
+ * Helper to collapse panes separated by splitters. If aElement is a splitter
+ * itself, then this splitter is collapsed, otherwise all splitters that are
+ * direct children of aElement are collapsed.
+ *
+ * @param aElement The splitter or container
+ * @param aShouldBeCollapsed If true, collapse the pane
+ */
+function collapse_panes(aElement, aShouldBeCollapsed) {
+ let state = aShouldBeCollapsed ? "collapsed" : "open";
+ if (aElement.localName == "splitter") {
+ aElement.setAttribute("state", state);
+ } else {
+ for (let n of aElement.childNodes) {
+ if (n.localName == "splitter") {
+ n.setAttribute("state", state);
+ }
+ }
+ }
+ // Spin the event loop once to let other window elements redraw.
+ utils.sleep(50);
+}
diff --git a/comm/mail/test/browser/shared-modules/EventUtils.jsm b/comm/mail/test/browser/shared-modules/EventUtils.jsm
new file mode 100644
index 0000000000..ad21f1b670
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/EventUtils.jsm
@@ -0,0 +1,876 @@
+// Export all available functions for Mozmill
+var EXPORTED_SYMBOLS = [
+ "sendChar",
+ "sendString",
+ "synthesizeMouse",
+ "synthesizeMouseAtCenter",
+ "synthesizeMouseScroll",
+ "synthesizeKey",
+ "synthesizeMouseExpectEvent",
+];
+
+function computeButton(aEvent) {
+ if (typeof aEvent.button != "undefined") {
+ return aEvent.button;
+ }
+ return aEvent.type == "contextmenu" ? 2 : 0;
+}
+
+function computeButtons(aEvent, utils) {
+ if (typeof aEvent.buttons != "undefined") {
+ return aEvent.buttons;
+ }
+
+ if (typeof aEvent.button != "undefined") {
+ return utils.MOUSE_BUTTONS_NOT_SPECIFIED;
+ }
+
+ if (typeof aEvent.type != "undefined" && aEvent.type != "mousedown") {
+ return utils.MOUSE_BUTTONS_NO_BUTTON;
+ }
+
+ return utils.MOUSE_BUTTONS_NOT_SPECIFIED;
+}
+
+/**
+ * Parse the key modifier flags from aEvent. Used to share code between
+ * synthesizeMouse and synthesizeKey.
+ */
+function _parseModifiers(aEvent) {
+ var hwindow = Services.appShell.hiddenDOMWindow;
+
+ var mval = 0;
+ if (aEvent.shiftKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_SHIFT;
+ }
+ if (aEvent.ctrlKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+ if (aEvent.altKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_ALT;
+ }
+ if (aEvent.metaKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_META;
+ }
+ if (aEvent.accelKey) {
+ mval |= hwindow.navigator.platform.includes("Mac")
+ ? Ci.nsIDOMWindowUtils.MODIFIER_META
+ : Ci.nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+
+ return mval;
+}
+
+/**
+ * Send the char aChar to the focused element. This method handles casing of
+ * chars (sends the right charcode, and sends a shift key for uppercase chars).
+ * No other modifiers are handled at this point.
+ *
+ * For now this method only works for ASCII characters and emulates the shift
+ * key state on US keyboard layout.
+ */
+function sendChar(aChar, aWindow) {
+ var hasShift;
+ // Emulate US keyboard layout for the shiftKey state.
+ switch (aChar) {
+ case "!":
+ case "@":
+ case "#":
+ case "$":
+ case "%":
+ case "^":
+ case "&":
+ case "*":
+ case "(":
+ case ")":
+ case "_":
+ case "+":
+ case "{":
+ case "}":
+ case ":":
+ case '"':
+ case "|":
+ case "<":
+ case ">":
+ case "?":
+ hasShift = true;
+ break;
+ default:
+ hasShift =
+ aChar.toLowerCase() != aChar.toUpperCase() &&
+ aChar == aChar.toUpperCase();
+ break;
+ }
+ synthesizeKey(aChar, { shiftKey: hasShift }, aWindow);
+}
+
+/**
+ * Send the string aStr to the focused element.
+ *
+ * For now this method only works for ASCII characters and emulates the shift
+ * key state on US keyboard layout.
+ */
+function sendString(aStr, aWindow) {
+ for (var i = 0; i < aStr.length; ++i) {
+ sendChar(aStr.charAt(i), aWindow);
+ }
+}
+
+/**
+ * Synthesize a mouse event on a target. The actual client point is determined
+ * by taking the aTarget's client box and offseting it by aOffsetX and
+ * aOffsetY. This allows mouse clicks to be simulated by calling this method.
+ *
+ * aEvent is an object which may contain the properties:
+ * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`,
+ * `button`, `type`.
+ * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`.
+ *
+ * If the type is specified, an mouse event of that type is fired. Otherwise,
+ * a mousedown followed by a mouseup is performed.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ *
+ * Returns whether the event had preventDefault() called on it.
+ */
+function synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) {
+ var rect = aTarget.getBoundingClientRect();
+ return synthesizeMouseAtPoint(
+ rect.left + aOffsetX,
+ rect.top + aOffsetY,
+ aEvent,
+ aWindow
+ );
+}
+
+/*
+ * Synthesize a mouse event at a particular point in aWindow.
+ *
+ * aEvent is an object which may contain the properties:
+ * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`,
+ * `button`, `type`.
+ * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`.
+ *
+ * If the type is specified, an mouse event of that type is fired. Otherwise,
+ * a mousedown followed by a mouseup is performed.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseAtPoint(left, top, aEvent, aWindow) {
+ var utils = aWindow.windowUtils;
+ var defaultPrevented = false;
+
+ if (utils) {
+ var button = computeButton(aEvent);
+ var clickCount = aEvent.clickCount || 1;
+ var modifiers = _parseModifiers(aEvent, aWindow);
+ var pressure = "pressure" in aEvent ? aEvent.pressure : 0;
+
+ // aWindow might be cross-origin from us.
+ var MouseEvent = aWindow.MouseEvent;
+
+ // Default source to mouse.
+ var inputSource =
+ "inputSource" in aEvent
+ ? aEvent.inputSource
+ : MouseEvent.MOZ_SOURCE_MOUSE;
+ // Compute a pointerId if needed.
+ var id;
+ if ("id" in aEvent) {
+ id = aEvent.id;
+ } else {
+ var isFromPen = inputSource === MouseEvent.MOZ_SOURCE_PEN;
+ id = isFromPen
+ ? utils.DEFAULT_PEN_POINTER_ID
+ : utils.DEFAULT_MOUSE_POINTER_ID;
+ }
+
+ var isDOMEventSynthesized =
+ "isSynthesized" in aEvent ? aEvent.isSynthesized : true;
+ var isWidgetEventSynthesized =
+ "isWidgetEventSynthesized" in aEvent
+ ? aEvent.isWidgetEventSynthesized
+ : false;
+ if ("type" in aEvent && aEvent.type) {
+ defaultPrevented = utils.sendMouseEvent(
+ aEvent.type,
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ computeButtons(aEvent, utils),
+ id
+ );
+ } else {
+ utils.sendMouseEvent(
+ "mousedown",
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ computeButtons(Object.assign({ type: "mousedown" }, aEvent), utils),
+ id
+ );
+ utils.sendMouseEvent(
+ "mouseup",
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ computeButtons(Object.assign({ type: "mouseup" }, aEvent), utils),
+ id
+ );
+ }
+ }
+
+ return defaultPrevented;
+}
+
+// Call synthesizeMouse with coordinates at the center of aTarget.
+function synthesizeMouseAtCenter(aTarget, aEvent, aWindow) {
+ var rect = aTarget.getBoundingClientRect();
+ return synthesizeMouse(
+ aTarget,
+ rect.width / 2,
+ rect.height / 2,
+ aEvent,
+ aWindow
+ );
+}
+
+/**
+ * Synthesize a mouse scroll event on a target. The actual client point is determined
+ * by taking the aTarget's client box and offsetting it by aOffsetX and
+ * aOffsetY.
+ *
+ * aEvent is an object which may contain the properties:
+ * shiftKey, ctrlKey, altKey, metaKey, accessKey, button, type, axis, delta, hasPixels
+ *
+ * If the type is specified, a mouse scroll event of that type is fired. Otherwise,
+ * "DOMMouseScroll" is used.
+ *
+ * If the axis is specified, it must be one of "horizontal" or "vertical". If not specified,
+ * "vertical" is used.
+ *
+ * 'delta' is the amount to scroll by (can be positive or negative). It must
+ * be specified.
+ *
+ * 'hasPixels' specifies whether kHasPixels should be set in the scrollFlags.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseScroll(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) {
+ var utils = aWindow.windowUtils;
+ if (utils) {
+ // See nsMouseScrollFlags in nsGUIEvent.h
+ const kIsVertical = 0x02;
+ const kIsHorizontal = 0x04;
+ const kHasPixels = 0x08;
+
+ var button = aEvent.button || 0;
+ var modifiers = _parseModifiers(aEvent);
+
+ var rect = aTarget.getBoundingClientRect();
+
+ var left = rect.left;
+ var top = rect.top;
+
+ var type = ("type" in aEvent && aEvent.type) || "DOMMouseScroll";
+ var axis = aEvent.axis || "vertical";
+ var scrollFlags = axis == "horizontal" ? kIsHorizontal : kIsVertical;
+ if (aEvent.hasPixels) {
+ scrollFlags |= kHasPixels;
+ }
+ utils.sendMouseScrollEvent(
+ type,
+ left + aOffsetX,
+ top + aOffsetY,
+ button,
+ scrollFlags,
+ aEvent.delta,
+ modifiers
+ );
+ }
+}
+
+/**
+ * Synthesize a key event. It is targeted at whatever would be targeted by an
+ * actual keypress by the user, typically the focused element.
+ *
+ * aKey should be:
+ * - key value (recommended). If you specify a non-printable key name,
+ * append "KEY_" prefix. Otherwise, specifying a printable key, the
+ * key value should be specified.
+ * - keyCode name starting with "VK_" (e.g., VK_RETURN). This is available
+ * only for compatibility with legacy API. Don't use this with new tests.
+ *
+ * aEvent is an object which may contain the properties:
+ * - code: If you emulates a physical keyboard's key event, this should be
+ * specified.
+ * - repeat: If you emulates auto-repeat, you should set the count of repeat.
+ * This method will automatically synthesize keydown (and keypress).
+ * - location: If you want to specify this, you can specify this explicitly.
+ * However, if you don't specify this value, it will be computed
+ * from code value.
+ * - type: Basically, you shouldn't specify this. Then, this function will
+ * synthesize keydown (, keypress) and keyup.
+ * If keydown is specified, this only fires keydown (and keypress if
+ * it should be fired).
+ * If keyup is specified, this only fires keyup.
+ * - altKey, altGraphKey, ctrlKey, capsLockKey, fnKey, fnLockKey, numLockKey,
+ * metaKey, osKey, scrollLockKey, shiftKey, symbolKey, symbolLockKey:
+ * Basically, you shouldn't use these attributes. nsITextInputProcessor
+ * manages modifier key state when you synthesize modifier key events.
+ * However, if some of these attributes are true, this function activates
+ * the modifiers only during dispatching the key events.
+ * Note that if some of these values are false, they are ignored (i.e.,
+ * not inactivated with this function).
+ * - keyCode: Must be 0 - 255 (0xFF). If this is specified explicitly,
+ * .keyCode value is initialized with this value.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ * aCallback is optional, use the callback for receiving notifications of TIP.
+ */
+function synthesizeKey(aKey, aEvent, aWindow, aCallback) {
+ var TIP = _getTIP(aWindow, aCallback);
+ if (!TIP) {
+ return;
+ }
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+ var modifiers = _emulateToActivateModifiers(TIP, aEvent, aWindow);
+ var keyEventDict = _createKeyboardEventDictionary(aKey, aEvent, aWindow);
+ var keyEvent = new KeyboardEvent("", keyEventDict.dictionary);
+ var dispatchKeydown =
+ !("type" in aEvent) || aEvent.type === "keydown" || !aEvent.type;
+ var dispatchKeyup =
+ !("type" in aEvent) || aEvent.type === "keyup" || !aEvent.type;
+
+ try {
+ if (dispatchKeydown) {
+ TIP.keydown(keyEvent, keyEventDict.flags);
+ if ("repeat" in aEvent && aEvent.repeat > 1) {
+ keyEventDict.dictionary.repeat = true;
+ var repeatedKeyEvent = new KeyboardEvent("", keyEventDict.dictionary);
+ for (var i = 1; i < aEvent.repeat; i++) {
+ TIP.keydown(repeatedKeyEvent, keyEventDict.flags);
+ }
+ }
+ }
+ if (dispatchKeyup) {
+ TIP.keyup(keyEvent, keyEventDict.flags);
+ }
+ } finally {
+ _emulateToInactivateModifiers(TIP, modifiers, aWindow);
+ }
+}
+
+var _gSeenEvent = false;
+
+/**
+ * Indicate that an event with an original target of aExpectedTarget and
+ * a type of aExpectedEvent is expected to be fired, or not expected to
+ * be fired.
+ */
+function _expectEvent(aExpectedTarget, aExpectedEvent, aTestName) {
+ if (!aExpectedTarget || !aExpectedEvent) {
+ return null;
+ }
+
+ _gSeenEvent = false;
+
+ var type =
+ aExpectedEvent.charAt(0) == "!"
+ ? aExpectedEvent.substring(1)
+ : aExpectedEvent;
+ var eventHandler = function (event) {
+ var epassed =
+ !_gSeenEvent && event.target == aExpectedTarget && event.type == type;
+ if (!epassed) {
+ throw new Error(
+ aTestName + " " + type + " event target " + (_gSeenEvent ? "twice" : "")
+ );
+ }
+ _gSeenEvent = true;
+ };
+
+ aExpectedTarget.addEventListener(type, eventHandler);
+ return eventHandler;
+}
+
+/**
+ * Check if the event was fired or not. The event handler aEventHandler
+ * will be removed.
+ */
+function _checkExpectedEvent(
+ aExpectedTarget,
+ aExpectedEvent,
+ aEventHandler,
+ aTestName
+) {
+ if (aEventHandler) {
+ var expectEvent = aExpectedEvent.charAt(0) != "!";
+ var type = expectEvent ? aExpectedEvent : aExpectedEvent.substring(1);
+ aExpectedTarget.removeEventListener(type, aEventHandler);
+ var desc = type + " event";
+ if (expectEvent) {
+ desc += " not";
+ }
+ if (_gSeenEvent != expectEvent) {
+ throw new Error(aTestName + ": " + desc + " fired.");
+ }
+ }
+
+ _gSeenEvent = false;
+}
+
+/**
+ * Similar to synthesizeMouse except that a test is performed to see if an
+ * event is fired at the right target as a result.
+ *
+ * aExpectedTarget - the expected originalTarget of the event.
+ * aExpectedEvent - the expected type of the event, such as 'select'.
+ * aTestName - the test name when outputting results
+ *
+ * To test that an event is not fired, use an expected type preceded by an
+ * exclamation mark, such as '!select'. This might be used to test that a
+ * click on a disabled element doesn't fire certain events for instance.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseExpectEvent(
+ aTarget,
+ aOffsetX,
+ aOffsetY,
+ aEvent,
+ aExpectedTarget,
+ aExpectedEvent,
+ aTestName,
+ aWindow
+) {
+ var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName);
+ synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow);
+ _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName);
+}
+
+/**
+ * The functions that follow were copied from
+ * mozilla-central/testing/mochitest/tests/SimpleTest/EventUtils.js
+ */
+
+var TIPMap = new WeakMap();
+
+function _getTIP(aWindow, aCallback) {
+ var tip;
+ if (TIPMap.has(aWindow)) {
+ tip = TIPMap.get(aWindow);
+ } else {
+ tip = Cc["@mozilla.org/text-input-processor;1"].createInstance(
+ Ci.nsITextInputProcessor
+ );
+ TIPMap.set(aWindow, tip);
+ }
+ if (!tip.beginInputTransactionForTests(aWindow, aCallback)) {
+ tip = null;
+ TIPMap.delete(aWindow);
+ }
+ return tip;
+}
+
+function _getKeyboardEvent(aWindow) {
+ if (typeof KeyboardEvent != "undefined") {
+ try {
+ // See if the object can be instantiated; sometimes this yields
+ // 'TypeError: can't access dead object' or 'KeyboardEvent is not a constructor'.
+ new KeyboardEvent("", {});
+ return KeyboardEvent;
+ } catch (ex) {}
+ }
+ return aWindow.KeyboardEvent;
+}
+
+/* eslint-disable complexity */
+function _guessKeyNameFromKeyCode(aKeyCode, aWindow) {
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+ switch (aKeyCode) {
+ case KeyboardEvent.DOM_VK_CANCEL:
+ return "Cancel";
+ case KeyboardEvent.DOM_VK_HELP:
+ return "Help";
+ case KeyboardEvent.DOM_VK_BACK_SPACE:
+ return "Backspace";
+ case KeyboardEvent.DOM_VK_TAB:
+ return "Tab";
+ case KeyboardEvent.DOM_VK_CLEAR:
+ return "Clear";
+ case KeyboardEvent.DOM_VK_RETURN:
+ return "Enter";
+ case KeyboardEvent.DOM_VK_SHIFT:
+ return "Shift";
+ case KeyboardEvent.DOM_VK_CONTROL:
+ return "Control";
+ case KeyboardEvent.DOM_VK_ALT:
+ return "Alt";
+ case KeyboardEvent.DOM_VK_PAUSE:
+ return "Pause";
+ case KeyboardEvent.DOM_VK_EISU:
+ return "Eisu";
+ case KeyboardEvent.DOM_VK_ESCAPE:
+ return "Escape";
+ case KeyboardEvent.DOM_VK_CONVERT:
+ return "Convert";
+ case KeyboardEvent.DOM_VK_NONCONVERT:
+ return "NonConvert";
+ case KeyboardEvent.DOM_VK_ACCEPT:
+ return "Accept";
+ case KeyboardEvent.DOM_VK_MODECHANGE:
+ return "ModeChange";
+ case KeyboardEvent.DOM_VK_PAGE_UP:
+ return "PageUp";
+ case KeyboardEvent.DOM_VK_PAGE_DOWN:
+ return "PageDown";
+ case KeyboardEvent.DOM_VK_END:
+ return "End";
+ case KeyboardEvent.DOM_VK_HOME:
+ return "Home";
+ case KeyboardEvent.DOM_VK_LEFT:
+ return "ArrowLeft";
+ case KeyboardEvent.DOM_VK_UP:
+ return "ArrowUp";
+ case KeyboardEvent.DOM_VK_RIGHT:
+ return "ArrowRight";
+ case KeyboardEvent.DOM_VK_DOWN:
+ return "ArrowDown";
+ case KeyboardEvent.DOM_VK_SELECT:
+ return "Select";
+ case KeyboardEvent.DOM_VK_PRINT:
+ return "Print";
+ case KeyboardEvent.DOM_VK_EXECUTE:
+ return "Execute";
+ case KeyboardEvent.DOM_VK_PRINTSCREEN:
+ return "PrintScreen";
+ case KeyboardEvent.DOM_VK_INSERT:
+ return "Insert";
+ case KeyboardEvent.DOM_VK_DELETE:
+ return "Delete";
+ case KeyboardEvent.DOM_VK_WIN:
+ return "OS";
+ case KeyboardEvent.DOM_VK_CONTEXT_MENU:
+ return "ContextMenu";
+ case KeyboardEvent.DOM_VK_SLEEP:
+ return "Standby";
+ case KeyboardEvent.DOM_VK_F1:
+ return "F1";
+ case KeyboardEvent.DOM_VK_F2:
+ return "F2";
+ case KeyboardEvent.DOM_VK_F3:
+ return "F3";
+ case KeyboardEvent.DOM_VK_F4:
+ return "F4";
+ case KeyboardEvent.DOM_VK_F5:
+ return "F5";
+ case KeyboardEvent.DOM_VK_F6:
+ return "F6";
+ case KeyboardEvent.DOM_VK_F7:
+ return "F7";
+ case KeyboardEvent.DOM_VK_F8:
+ return "F8";
+ case KeyboardEvent.DOM_VK_F9:
+ return "F9";
+ case KeyboardEvent.DOM_VK_F10:
+ return "F10";
+ case KeyboardEvent.DOM_VK_F11:
+ return "F11";
+ case KeyboardEvent.DOM_VK_F12:
+ return "F12";
+ case KeyboardEvent.DOM_VK_F13:
+ return "F13";
+ case KeyboardEvent.DOM_VK_F14:
+ return "F14";
+ case KeyboardEvent.DOM_VK_F15:
+ return "F15";
+ case KeyboardEvent.DOM_VK_F16:
+ return "F16";
+ case KeyboardEvent.DOM_VK_F17:
+ return "F17";
+ case KeyboardEvent.DOM_VK_F18:
+ return "F18";
+ case KeyboardEvent.DOM_VK_F19:
+ return "F19";
+ case KeyboardEvent.DOM_VK_F20:
+ return "F20";
+ case KeyboardEvent.DOM_VK_F21:
+ return "F21";
+ case KeyboardEvent.DOM_VK_F22:
+ return "F22";
+ case KeyboardEvent.DOM_VK_F23:
+ return "F23";
+ case KeyboardEvent.DOM_VK_F24:
+ return "F24";
+ case KeyboardEvent.DOM_VK_NUM_LOCK:
+ return "NumLock";
+ case KeyboardEvent.DOM_VK_SCROLL_LOCK:
+ return "ScrollLock";
+ case KeyboardEvent.DOM_VK_VOLUME_MUTE:
+ return "AudioVolumeMute";
+ case KeyboardEvent.DOM_VK_VOLUME_DOWN:
+ return "AudioVolumeDown";
+ case KeyboardEvent.DOM_VK_VOLUME_UP:
+ return "AudioVolumeUp";
+ case KeyboardEvent.DOM_VK_META:
+ return "Meta";
+ case KeyboardEvent.DOM_VK_ALTGR:
+ return "AltGraph";
+ case KeyboardEvent.DOM_VK_ATTN:
+ return "Attn";
+ case KeyboardEvent.DOM_VK_CRSEL:
+ return "CrSel";
+ case KeyboardEvent.DOM_VK_EXSEL:
+ return "ExSel";
+ case KeyboardEvent.DOM_VK_EREOF:
+ return "EraseEof";
+ case KeyboardEvent.DOM_VK_PLAY:
+ return "Play";
+ default:
+ return "Unidentified";
+ }
+}
+/* eslint-enable complexity */
+
+function _createKeyboardEventDictionary(aKey, aKeyEvent, aWindow) {
+ var result = { dictionary: null, flags: 0 };
+ var keyCodeIsDefined = "keyCode" in aKeyEvent;
+ var keyCode =
+ keyCodeIsDefined && aKeyEvent.keyCode >= 0 && aKeyEvent.keyCode <= 255
+ ? aKeyEvent.keyCode
+ : 0;
+ var keyName = "Unidentified";
+ if (aKey.indexOf("KEY_") == 0) {
+ keyName = aKey.substr("KEY_".length);
+ result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY;
+ } else if (aKey.indexOf("VK_") == 0) {
+ keyCode = _getKeyboardEvent(aWindow)["DOM_" + aKey];
+ if (!keyCode) {
+ throw new Error("Unknown key: " + aKey);
+ }
+ keyName = _guessKeyNameFromKeyCode(keyCode, aWindow);
+ result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY;
+ } else if (aKey != "") {
+ keyName = aKey;
+ if (!keyCodeIsDefined) {
+ keyCode = _computeKeyCodeFromChar(aKey.charAt(0), aWindow);
+ }
+ if (!keyCode) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEYCODE_ZERO;
+ }
+ result.flags |= Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY;
+ }
+ var locationIsDefined = "location" in aKeyEvent;
+ if (locationIsDefined && aKeyEvent.location === 0) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEY_LOCATION_STANDARD;
+ }
+ result.dictionary = {
+ key: keyName,
+ code: "code" in aKeyEvent ? aKeyEvent.code : "",
+ location: locationIsDefined ? aKeyEvent.location : 0,
+ repeat: "repeat" in aKeyEvent ? aKeyEvent.repeat === true : false,
+ keyCode,
+ };
+ return result;
+}
+
+function _emulateToActivateModifiers(aTIP, aKeyEvent, aWindow) {
+ if (!aKeyEvent) {
+ return null;
+ }
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+
+ var modifiers = {
+ normal: [
+ { key: "Alt", attr: "altKey" },
+ { key: "AltGraph", attr: "altGraphKey" },
+ { key: "Control", attr: "ctrlKey" },
+ { key: "Fn", attr: "fnKey" },
+ { key: "Meta", attr: "metaKey" },
+ { key: "OS", attr: "osKey" },
+ { key: "Shift", attr: "shiftKey" },
+ { key: "Symbol", attr: "symbolKey" },
+ {
+ key: aWindow.navigator.platform.includes("Mac") ? "Meta" : "Control",
+ attr: "accelKey",
+ },
+ ],
+ lockable: [
+ { key: "CapsLock", attr: "capsLockKey" },
+ { key: "FnLock", attr: "fnLockKey" },
+ { key: "NumLock", attr: "numLockKey" },
+ { key: "ScrollLock", attr: "scrollLockKey" },
+ { key: "SymbolLock", attr: "symbolLockKey" },
+ ],
+ };
+
+ for (let i = 0; i < modifiers.normal.length; i++) {
+ if (!aKeyEvent[modifiers.normal[i].attr]) {
+ continue;
+ }
+ if (aTIP.getModifierState(modifiers.normal[i].key)) {
+ continue; // already activated.
+ }
+ let event = new KeyboardEvent("", { key: modifiers.normal[i].key });
+ aTIP.keydown(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ modifiers.normal[i].activated = true;
+ }
+ for (let i = 0; i < modifiers.lockable.length; i++) {
+ if (!aKeyEvent[modifiers.lockable[i].attr]) {
+ continue;
+ }
+ if (aTIP.getModifierState(modifiers.lockable[i].key)) {
+ continue; // already activated.
+ }
+ let event = new KeyboardEvent("", { key: modifiers.lockable[i].key });
+ aTIP.keydown(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ aTIP.keyup(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ modifiers.lockable[i].activated = true;
+ }
+ return modifiers;
+}
+
+function _emulateToInactivateModifiers(aTIP, aModifiers, aWindow) {
+ if (!aModifiers) {
+ return;
+ }
+ var KeyboardEvent = _getKeyboardEvent(aWindow);
+ for (let i = 0; i < aModifiers.normal.length; i++) {
+ if (!aModifiers.normal[i].activated) {
+ continue;
+ }
+ let event = new KeyboardEvent("", { key: aModifiers.normal[i].key });
+ aTIP.keyup(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ }
+ for (let i = 0; i < aModifiers.lockable.length; i++) {
+ if (!aModifiers.lockable[i].activated) {
+ continue;
+ }
+ if (!aTIP.getModifierState(aModifiers.lockable[i].key)) {
+ continue; // who already inactivated this?
+ }
+ let event = new KeyboardEvent("", { key: aModifiers.lockable[i].key });
+ aTIP.keydown(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ aTIP.keyup(
+ event,
+ aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ }
+}
+
+/* eslint-disable complexity */
+function _computeKeyCodeFromChar(aChar, aWindow) {
+ if (aChar.length != 1) {
+ return 0;
+ }
+ var KeyEvent = _getKeyboardEvent(aWindow);
+ if (aChar >= "a" && aChar <= "z") {
+ return KeyEvent.DOM_VK_A + aChar.charCodeAt(0) - "a".charCodeAt(0);
+ }
+ if (aChar >= "A" && aChar <= "Z") {
+ return KeyEvent.DOM_VK_A + aChar.charCodeAt(0) - "A".charCodeAt(0);
+ }
+ if (aChar >= "0" && aChar <= "9") {
+ return KeyEvent.DOM_VK_0 + aChar.charCodeAt(0) - "0".charCodeAt(0);
+ }
+ // returns US keyboard layout's keycode
+ switch (aChar) {
+ case "~":
+ case "`":
+ return KeyEvent.DOM_VK_BACK_QUOTE;
+ case "!":
+ return KeyEvent.DOM_VK_1;
+ case "@":
+ return KeyEvent.DOM_VK_2;
+ case "#":
+ return KeyEvent.DOM_VK_3;
+ case "$":
+ return KeyEvent.DOM_VK_4;
+ case "%":
+ return KeyEvent.DOM_VK_5;
+ case "^":
+ return KeyEvent.DOM_VK_6;
+ case "&":
+ return KeyEvent.DOM_VK_7;
+ case "*":
+ return KeyEvent.DOM_VK_8;
+ case "(":
+ return KeyEvent.DOM_VK_9;
+ case ")":
+ return KeyEvent.DOM_VK_0;
+ case "-":
+ case "_":
+ return KeyEvent.DOM_VK_SUBTRACT;
+ case "+":
+ case "=":
+ return KeyEvent.DOM_VK_EQUALS;
+ case "{":
+ case "[":
+ return KeyEvent.DOM_VK_OPEN_BRACKET;
+ case "}":
+ case "]":
+ return KeyEvent.DOM_VK_CLOSE_BRACKET;
+ case "|":
+ case "\\":
+ return KeyEvent.DOM_VK_BACK_SLASH;
+ case ":":
+ case ";":
+ return KeyEvent.DOM_VK_SEMICOLON;
+ case "'":
+ case '"':
+ return KeyEvent.DOM_VK_QUOTE;
+ case "<":
+ case ",":
+ return KeyEvent.DOM_VK_COMMA;
+ case ">":
+ case ".":
+ return KeyEvent.DOM_VK_PERIOD;
+ case "?":
+ case "/":
+ return KeyEvent.DOM_VK_SLASH;
+ case "\n":
+ return KeyEvent.DOM_VK_RETURN;
+ case " ":
+ return KeyEvent.DOM_VK_SPACE;
+ default:
+ return 0;
+ }
+}
+/* eslint-enable complexity */
diff --git a/comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm b/comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm
new file mode 100644
index 0000000000..a90cbcf457
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/FolderDisplayHelpers.jsm
@@ -0,0 +1,3243 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "add_message_to_folder",
+ "add_message_sets_to_folders",
+ "add_to_toolbar",
+ "archive_messages",
+ "archive_selected_messages",
+ "assert_attachment_list_focused",
+ "assert_collapsed",
+ "assert_default_window_size",
+ "assert_displayed",
+ "assert_expanded",
+ "assert_folder_at_index_as",
+ "assert_folder_child_in_view",
+ "assert_folder_collapsed",
+ "assert_folder_displayed",
+ "assert_folder_expanded",
+ "assert_folder_mode",
+ "assert_folder_not_visible",
+ "assert_folder_selected",
+ "assert_folder_selected_and_displayed",
+ "assert_folder_tree_focused",
+ "assert_folder_tree_view_row_count",
+ "assert_folder_visible",
+ "assert_folders_selected",
+ "assert_folders_selected_and_displayed",
+ "assert_mail_view",
+ "assert_message_not_in_view",
+ "assert_message_pane_focused",
+ "assert_message_pane_hidden",
+ "assert_message_pane_visible",
+ "assert_messages_in_view",
+ "assert_messages_not_in_view",
+ "assert_messages_summarized",
+ "assert_multimessage_pane_focused",
+ "assert_not_selected_tab",
+ "assert_not_showing_unread_only",
+ "assert_not_shown",
+ "assert_nothing_selected",
+ "assert_number_of_tabs_open",
+ "assert_pane_layout",
+ "assert_selected",
+ "assert_selected_and_displayed",
+ "assert_selected_tab",
+ "assert_showing_unread_only",
+ "assert_summary_contains_N_elts",
+ "assert_tab_has_title",
+ "assert_tab_mode_name",
+ "assert_tab_titled_from",
+ "assert_thread_tree_focused",
+ "assert_visible",
+ "be_in_folder",
+ "click_tree_row",
+ "close_message_window",
+ "close_popup",
+ "close_tab",
+ "collapse_all_threads",
+ "collapse_folder",
+ "create_encrypted_smime_message",
+ "create_encrypted_openpgp_message",
+ "create_folder",
+ "create_message",
+ "create_thread",
+ "create_virtual_folder",
+ "delete_messages",
+ "delete_via_popup",
+ "display_message_in_folder_tab",
+ "empty_folder",
+ "enter_folder",
+ "expand_all_threads",
+ "expand_folder",
+ "FAKE_SERVER_HOSTNAME",
+ "focus_folder_tree",
+ "focus_message_pane",
+ "focus_multimessage_pane",
+ "focus_thread_tree",
+ "gDefaultWindowHeight",
+ "gDefaultWindowWidth",
+ "get_about_3pane",
+ "get_about_message",
+ "get_smart_folder_named",
+ "get_special_folder",
+ "inboxFolder",
+ "kClassicMailLayout",
+ "kVerticalMailLayout",
+ "kWideMailLayout",
+ "make_display_grouped",
+ "make_display_threaded",
+ "make_display_unthreaded",
+ "make_message_sets_in_folders",
+ "mc",
+ "middle_click_on_folder",
+ "middle_click_on_row",
+ "msgGen",
+ "normalize_for_json",
+ "open_folder_in_new_tab",
+ "open_folder_in_new_window",
+ "open_message_from_file",
+ "open_selected_message",
+ "open_selected_message_in_new_tab",
+ "open_selected_message_in_new_window",
+ "open_selected_messages",
+ "plan_for_message_display",
+ "plan_to_wait_for_folder_events",
+ "press_delete",
+ "press_enter",
+ "remove_from_toolbar",
+ "reset_close_message_on_delete",
+ "reset_context_menu_background_tabs",
+ "reset_open_message_behavior",
+ "restore_default_window_size",
+ "right_click_on_folder",
+ "right_click_on_row",
+ "select_click_folder",
+ "select_click_row",
+ "select_column_click_row",
+ "select_control_click_row",
+ "select_none",
+ "select_shift_click_folder",
+ "select_shift_click_row",
+ "set_close_message_on_delete",
+ "set_context_menu_background_tabs",
+ "set_mail_view",
+ "set_mc",
+ "set_open_message_behavior",
+ "set_pane_layout",
+ "set_show_unread_only",
+ "show_folder_pane",
+ "smimeUtils_ensureNSS",
+ "smimeUtils_loadCertificateAndKey",
+ "smimeUtils_loadPEMCertificate",
+ "switch_tab",
+ "throw_and_dump_view_state",
+ "toggle_main_menu",
+ "toggle_message_pane",
+ "toggle_thread_row",
+ "wait_for_all_messages_to_load",
+ "wait_for_blank_content_pane",
+ "wait_for_folder_events",
+ "wait_for_message_display_completion",
+ "wait_for_popup_to_open",
+];
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+// the windowHelper module
+var windowHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+var nsMsgViewIndex_None = 0xffffffff;
+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, MessageScenarioFactory, SyntheticMessageSet } =
+ ChromeUtils.import("resource://testing-common/mailnews/MessageGenerator.jsm");
+var { MessageInjection } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageInjection.jsm"
+);
+var { SmimeUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/smimeUtils.jsm"
+);
+var { dump_view_state } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ViewHelpers.jsm"
+);
+
+/**
+ * Server hostname as set in runtest.py
+ */
+var FAKE_SERVER_HOSTNAME = "tinderbox123";
+
+/** The controller for the main 3-pane window. */
+var mc;
+function set_mc(value) {
+ mc = value;
+}
+
+/** the index of the current 'other' tab */
+var otherTab;
+
+var testHelperModule;
+
+var msgGen;
+
+var messageInjection;
+
+msgGen = new MessageGenerator();
+var msgGenFactory = new MessageScenarioFactory(msgGen);
+
+var inboxFolder = null;
+
+// logHelper exports
+var normalize_for_json;
+
+// Default size of the main Thunderbird window in which the tests will run.
+var gDefaultWindowWidth = 1024;
+var gDefaultWindowHeight = 768;
+
+var initialized = false;
+function setupModule() {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+
+ testHelperModule = {
+ Cc,
+ Ci,
+ Cu,
+ // fake some xpcshell stuff
+ _TEST_FILE: ["mozmill"],
+ _do_not_wrap_xpcshell: true,
+ do_throw(aMsg) {
+ throw new Error(aMsg);
+ },
+ do_check_eq() {},
+ do_check_neq() {},
+ gDEPTH: "../../",
+ };
+
+ // -- logging
+
+ // The xpcshell test resources assume they are loaded into a single global
+ // namespace, so we need to help them out to maintain their delusion.
+ load_via_src_path(
+ "../../../testing/mochitest/resources/logHelper.js",
+ testHelperModule
+ );
+ // - Hook-up logHelper to the mozmill event system...
+ normalize_for_json = testHelperModule._normalize_for_json;
+
+ mc = windowHelper.wait_for_existing_window("mail:3pane");
+
+ setupAccountStuff();
+}
+setupModule();
+
+function get_about_3pane(win = mc.window) {
+ let tabmail = win.document.getElementById("tabmail");
+ if (tabmail?.currentTabInfo.mode.name == "mail3PaneTab") {
+ return tabmail.currentAbout3Pane;
+ }
+ throw new Error("The current tab is not a mail3PaneTab.");
+}
+
+function get_about_message(win = mc.window) {
+ let doc = win.document;
+ let tabmail = doc.getElementById("tabmail");
+ if (tabmail?.currentTabInfo.mode.name == "mailMessageTab") {
+ return tabmail.currentAboutMessage;
+ } else if (tabmail?.currentTabInfo.mode.name == "mail3PaneTab") {
+ // Not `currentAboutMessage`, we'll return a value even if it's hidden.
+ return get_about_3pane(win).messageBrowser.contentWindow;
+ } else if (
+ doc.documentElement.getAttribute("windowtype") == "mail:messageWindow"
+ ) {
+ return doc.getElementById("messageBrowser").contentWindow;
+ }
+ throw new Error("The current tab is not a mail3PaneTab or mailMessageTab.");
+}
+
+function ready_about_win(win) {
+ if (win.document.readyState == "complete") {
+ return;
+ }
+ utils.waitFor(
+ () => win.document.readyState == "complete",
+ `About win should complete loading`
+ );
+}
+
+function get_about_3pane_or_about_message(win = mc.window) {
+ let doc = win.document;
+ let tabmail = doc.getElementById("tabmail");
+ if (
+ tabmail &&
+ ["mail3PaneTab", "mailMessageTab"].includes(
+ tabmail.currentTabInfo.mode.name
+ )
+ ) {
+ return tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ } else if (
+ doc.documentElement.getAttribute("windowtype") == "mail:messageWindow"
+ ) {
+ return doc.getElementById("messageBrowser").contentWindow;
+ }
+ throw new Error("The current tab is not a mail3PaneTab or mailMessageTab.");
+}
+
+function get_db_view(win = mc.window) {
+ let aboutMessageWin = get_about_3pane_or_about_message(win);
+ ready_about_win(aboutMessageWin);
+ return aboutMessageWin.gDBView;
+}
+
+function smimeUtils_ensureNSS() {
+ SmimeUtils.ensureNSS();
+}
+
+function smimeUtils_loadPEMCertificate(file, certType, loadKey = false) {
+ SmimeUtils.loadPEMCertificate(file, certType, loadKey);
+}
+
+function smimeUtils_loadCertificateAndKey(file, pw) {
+ SmimeUtils.loadCertificateAndKey(file, pw);
+}
+
+function setupAccountStuff() {
+ messageInjection = new MessageInjection(
+ {
+ mode: "local",
+ },
+ msgGen
+ );
+ inboxFolder = messageInjection.getInboxFolder();
+}
+
+/*
+ * Although we all agree that the use of generators when dealing with async
+ * operations is awesome, the mozmill idiom is for calls to be synchronous and
+ * just spin event loops when they need to wait for things to happen. This
+ * does make the test code significantly less confusing, so we do it too.
+ * All of our operations are synchronous and just spin until they are happy.
+ */
+
+/**
+ * Create a folder and rebuild the folder tree view.
+ *
+ * @param {string} aFolderName - A folder name with no support for hierarchy at this time.
+ * @param {nsMsgFolderFlags} [aSpecialFlags] An optional list of nsMsgFolderFlags bits to set.
+ * @returns {nsIMsgFolder}
+ */
+async function create_folder(aFolderName, aSpecialFlags) {
+ wait_for_message_display_completion();
+
+ let folder = await messageInjection.makeEmptyFolder(
+ aFolderName,
+ aSpecialFlags
+ );
+ return folder;
+}
+
+/**
+ * Create a virtual folder by deferring to |MessageInjection.makeVirtualFolder| and making
+ * sure to rebuild the folder tree afterwards.
+ *
+ * @see MessageInjection.makeVirtualFolder
+ * @returns {nsIMsgFolder}
+ */
+function create_virtual_folder(...aArgs) {
+ let folder = messageInjection.makeVirtualFolder(...aArgs);
+ return folder;
+}
+
+/**
+ * Get special folder having a folder flag under Local Folders.
+ * This function clears the contents of the folder by default.
+ *
+ * @param aFolderFlag Folder flag of the required folder.
+ * @param aCreate Create the folder if it does not exist yet.
+ * @param aEmpty Set to false if messages from the folder must not be emptied.
+ */
+async function get_special_folder(
+ aFolderFlag,
+ aCreate = false,
+ aServer,
+ aEmpty = true
+) {
+ let folderNames = new Map([
+ [Ci.nsMsgFolderFlags.Drafts, "Drafts"],
+ [Ci.nsMsgFolderFlags.Templates, "Templates"],
+ [Ci.nsMsgFolderFlags.Queue, "Outbox"],
+ [Ci.nsMsgFolderFlags.Inbox, "Inbox"],
+ ]);
+
+ let folder = (
+ aServer ? aServer : MailServices.accounts.localFoldersServer
+ ).rootFolder.getFolderWithFlags(aFolderFlag);
+
+ if (!folder && aCreate) {
+ folder = await create_folder(folderNames.get(aFolderFlag), [aFolderFlag]);
+ }
+ if (!folder) {
+ throw new Error("Special folder not found");
+ }
+
+ // Ensure the folder is empty so that each test file can puts its new messages in it
+ // and they are always at reliable positions (starting from 0).
+ if (aEmpty) {
+ await empty_folder(folder);
+ }
+
+ return folder;
+}
+
+/**
+ * Create a thread with the specified number of messages in it.
+ *
+ * @param {number} aCount
+ * @returns {SyntheticMessageSet}
+ */
+function create_thread(aCount) {
+ return new SyntheticMessageSet(msgGenFactory.directReply(aCount));
+}
+
+/**
+ * Create and return a SyntheticMessage object.
+ *
+ * @param {MakeMessageOptions} aArgs An arguments object to be passed to
+ * MessageGenerator.makeMessage()
+ * @returns {SyntheticMessage}
+ */
+function create_message(aArgs) {
+ return msgGen.makeMessage(aArgs);
+}
+
+/**
+ * Create and return an SMIME SyntheticMessage object.
+ *
+ * @param {MakeMessageOptions} aArgs An arguments object to be passed to
+ * MessageGenerator.makeEncryptedSMimeMessage()
+ */
+function create_encrypted_smime_message(aArgs) {
+ return msgGen.makeEncryptedSMimeMessage(aArgs);
+}
+
+/**
+ * Create and return an OpenPGP SyntheticMessage object.
+ *
+ * @param {MakeMessageOptions} aArgs An arguments object to be passed to
+ * MessageGenerator.makeEncryptedOpenPGPMessage()
+ */
+function create_encrypted_openpgp_message(aArgs) {
+ return msgGen.makeEncryptedOpenPGPMessage(aArgs);
+}
+
+/**
+ * Adds a SyntheticMessage as a SyntheticMessageSet to a folder or folders.
+ *
+ * @see MessageInjection.addSetsToFolders
+ * @param {SyntheticMessage} aMsg
+ * @param {nsIMsgFolder[]} aFolder
+ */
+async function add_message_to_folder(aFolder, aMsg) {
+ await messageInjection.addSetsToFolders(aFolder, [
+ new SyntheticMessageSet([aMsg]),
+ ]);
+}
+
+/**
+ * Adds SyntheticMessageSets to a folder or folders.
+ *
+ * @see MessageInjection.addSetsToFolders
+ * @param {nsIMsgLocalMailFolder[]} aFolders
+ * @param {SyntheticMessageSet[]} aMsg
+ */
+async function add_message_sets_to_folders(aFolders, aMsg) {
+ await messageInjection.addSetsToFolders(aFolders, aMsg);
+}
+/**
+ * Makes SyntheticMessageSets in aFolders
+ *
+ * @param {nsIMsgFolder[]} aFolders
+ * @param {MakeMessageOptions[]} aOptions
+ * @returns {SyntheticMessageSet[]}
+ */
+async function make_message_sets_in_folders(aFolders, aOptions) {
+ return messageInjection.makeNewSetsInFolders(aFolders, aOptions);
+}
+
+/**
+ * @param {SyntheticMessageSet} aSynMessageSet The set of messages
+ * to delete. The messages do not all
+ * have to be in the same folder, but we have to delete them folder by
+ * folder if they are not.
+ */
+async function delete_messages(aSynMessageSet) {
+ await MessageInjection.deleteMessages(aSynMessageSet);
+}
+
+/**
+ * Make sure we are entering the folder from not having been in the folder. We
+ * will leave the folder and come back if we have to.
+ */
+async function enter_folder(aFolder) {
+ let win = get_about_3pane();
+
+ // If we're already selected, go back to the root...
+ if (win.gFolder == aFolder) {
+ await enter_folder(aFolder.rootFolder);
+ }
+
+ let displayPromise = BrowserTestUtils.waitForEvent(win, "folderURIChanged");
+ win.displayFolder(aFolder.URI);
+ await displayPromise;
+
+ // Drain the event queue.
+ utils.sleep(0);
+}
+
+/**
+ * Make sure we are in the given folder, entering it if we were not.
+ *
+ * @returns The tab info of the current tab (a more persistent identifier for
+ * tabs than the index, which will change as tabs open/close).
+ */
+async function be_in_folder(aFolder) {
+ let win = get_about_3pane();
+ if (win.gFolder != aFolder) {
+ await enter_folder(aFolder);
+ }
+ return mc.window.document.getElementById("tabmail").currentTabInfo;
+}
+
+/**
+ * Create a new tab displaying a folder, making that tab the current tab. This
+ * does not wait for message completion, because it doesn't know whether a
+ * message display will be triggered. If you know that a message display will be
+ * triggered, you should follow this up with
+ * |wait_for_message_display_completion(mc, true)|. If you know that a blank
+ * pane should be displayed, you should follow this up with
+ * |wait_for_blank_content_pane()| instead.
+ *
+ * @returns The tab info of the current tab (a more persistent identifier for
+ * tabs than the index, which will change as tabs open/close).
+ */
+async function open_folder_in_new_tab(aFolder) {
+ otherTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ let tab = mc.window.openTab(
+ "mail3PaneTab",
+ { folderURI: aFolder.URI },
+ "tab"
+ );
+ if (
+ tab.chromeBrowser.docShell.isLoadingDocument ||
+ tab.chromeBrowser.currentURI.spec != "about:3pane"
+ ) {
+ await BrowserTestUtils.browserLoaded(tab.chromeBrowser);
+ }
+ await TestUtils.waitForCondition(() => tab.folder == aFolder);
+
+ return tab;
+}
+
+/**
+ * Open a new mail:3pane window displaying a folder.
+ *
+ * @param aFolder the folder to be displayed in the new window
+ * @returns the augmented controller for the new window
+ */
+function open_folder_in_new_window(aFolder) {
+ windowHelper.plan_for_new_window("mail:3pane");
+ mc.window.MsgOpenNewWindowForFolder(aFolder.URI);
+ let mail3pane = windowHelper.wait_for_new_window("mail:3pane");
+ return mail3pane;
+}
+
+/**
+ * Open the selected message(s) by pressing Enter. The mail.openMessageBehavior
+ * pref is supposed to determine how the messages are opened.
+ *
+ * Since we don't know where this is going to trigger a message load, you're
+ * going to have to wait for message display completion yourself.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function open_selected_messages(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ // Focus the thread tree
+ focus_thread_tree();
+ // Open whatever's selected
+ press_enter(aController);
+}
+
+var open_selected_message = open_selected_messages;
+
+/**
+ * Create a new tab displaying the currently selected message, making that tab
+ * the current tab. We block until the message finishes loading.
+ *
+ * @param aBackground [optional] If true, then the tab is opened in the
+ * background. If false or not given, then the tab is opened
+ * in the foreground.
+ *
+ * @returns The tab info of the new tab (a more persistent identifier for tabs
+ * than the index, which will change as tabs open/close).
+ */
+async function open_selected_message_in_new_tab(aBackground) {
+ // get the current tab count so we can make sure the tab actually opened.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ // save the current tab as the 'other' tab
+ otherTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+
+ let win = get_about_3pane();
+ let message = win.gDBView.hdrForFirstSelectedMessage;
+ let tab = mc.window.document
+ .getElementById("tabmail")
+ .openTab("mailMessageTab", {
+ messageURI: message.folder.getUriForMsg(message),
+ viewWrapper: win.gViewWrapper,
+ background: aBackground,
+ });
+
+ if (
+ tab.chromeBrowser.docShell.isLoadingDocument ||
+ tab.chromeBrowser.currentURI.spec != "about:message"
+ ) {
+ await BrowserTestUtils.browserLoaded(tab.chromeBrowser);
+ }
+
+ if (!aBackground) {
+ wait_for_message_display_completion(undefined, true);
+ }
+
+ // check that the tab count increased
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount + 1
+ ) {
+ throw new Error("The tab never actually got opened!");
+ }
+
+ return tab;
+}
+
+/**
+ * Create a new window displaying the currently selected message. We do not
+ * return until the message has finished loading.
+ *
+ * @returns The MozmillController-wrapped new window.
+ */
+async function open_selected_message_in_new_window() {
+ let win = get_about_3pane();
+ let newWindowPromise =
+ windowHelper.async_plan_for_new_window("mail:messageWindow");
+ mc.window.MsgOpenNewWindowForMessage(
+ win.gDBView.hdrForFirstSelectedMessage,
+ win.gViewWrapper
+ );
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+ return msgc;
+}
+
+/**
+ * Display the given message in a folder tab. This doesn't make any assumptions
+ * about whether a new tab is opened, since that is dependent on a user
+ * preference. However, we do check that the tab we're returning is a folder
+ * tab.
+ *
+ * @param aMsgHdr The message header to display.
+ * @param [aExpectNew3Pane] This should be set to true if it is expected that a
+ * new 3-pane window will be opened as a result of
+ * the API call.
+ *
+ * @returns The currently selected tab, guaranteed to be a folder tab.
+ */
+function display_message_in_folder_tab(aMsgHdr, aExpectNew3Pane) {
+ if (aExpectNew3Pane) {
+ windowHelper.plan_for_new_window("mail:3pane");
+ }
+ MailUtils.displayMessageInFolderTab(aMsgHdr);
+ if (aExpectNew3Pane) {
+ mc = windowHelper.wait_for_new_window("mail:3pane");
+ }
+
+ // Make sure that the tab we're returning is a folder tab
+ let currentTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ assert_tab_mode_name(currentTab, "mail3PaneTab");
+
+ return currentTab;
+}
+
+/**
+ * Create a new window displaying a message loaded from a file. We do not
+ * return until the message has finished loading.
+ *
+ * @param file An nsIFile to load the message from.
+ * @returns The MozmillController-wrapped new window.
+ */
+async function open_message_from_file(file) {
+ if (!file.isFile() || !file.isReadable()) {
+ throw new Error(
+ "The requested message file " +
+ file.leafName +
+ " was not found or is not accessible."
+ );
+ }
+
+ let fileURL = Services.io.newFileURI(file).QueryInterface(Ci.nsIFileURL);
+ fileURL = fileURL
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let newWindowPromise =
+ windowHelper.async_plan_for_new_window("mail:messageWindow");
+ let win = mc.window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ );
+ await BrowserTestUtils.waitForEvent(win, "load");
+ let aboutMessage = get_about_message(win);
+ await BrowserTestUtils.waitForEvent(aboutMessage, "MsgLoaded");
+
+ let msgc = await newWindowPromise;
+ wait_for_message_display_completion(msgc, true);
+ windowHelper.wait_for_window_focused(msgc.window);
+ utils.sleep(0);
+
+ return msgc;
+}
+
+/**
+ * Switch to another folder or message tab. If no tab is specified, we switch
+ * to the 'other' tab. That is the last tab we used, most likely the tab that
+ * was current when we created this tab.
+ *
+ * @param aNewTab Optional, index of the other tab to switch to.
+ */
+async function switch_tab(aNewTab) {
+ if (typeof aNewTab == "number") {
+ aNewTab = mc.window.document.getElementById("tabmail").tabInfo[aNewTab];
+ }
+
+ // If the new tab is the same as the current tab, none of the below applies.
+ // Get out now.
+ if (aNewTab == mc.window.document.getElementById("tabmail").currentTabInfo) {
+ return;
+ }
+
+ let targetTab = aNewTab != null ? aNewTab : otherTab;
+ // now the current tab will be the 'other' tab after we switch
+ otherTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ let selectPromise = BrowserTestUtils.waitForEvent(
+ mc.window.document.getElementById("tabmail").tabContainer,
+ "select"
+ );
+ mc.window.document.getElementById("tabmail").switchToTab(targetTab);
+ await selectPromise;
+}
+
+/**
+ * Assert that the currently selected tab is the given one.
+ *
+ * @param aTab The tab that should currently be selected.
+ */
+function assert_selected_tab(aTab) {
+ Assert.equal(
+ mc.window.document.getElementById("tabmail").currentTabInfo,
+ aTab
+ );
+}
+
+/**
+ * Assert that the currently selected tab is _not_ the given one.
+ *
+ * @param aTab The tab that should currently not be selected.
+ */
+function assert_not_selected_tab(aTab) {
+ Assert.notEqual(
+ mc.window.document.getElementById("tabmail").currentTabInfo,
+ aTab
+ );
+}
+
+/**
+ * Assert that the given tab has the given mode name. Valid mode names include
+ * "message" and "folder".
+ *
+ * @param aTab A Tab. The currently selected tab if null.
+ * @param aModeName A string that should match the mode name of the tab.
+ */
+function assert_tab_mode_name(aTab, aModeName) {
+ if (!aTab) {
+ aTab = mc.window.document.getElementById("tabmail").currentTabInfo;
+ }
+
+ Assert.equal(aTab.mode.name, aModeName, `Tab should be of type ${aModeName}`);
+}
+
+/**
+ * Assert that the number of tabs open matches the value given.
+ *
+ * @param aNumber The number of tabs that should be open.
+ */
+function assert_number_of_tabs_open(aNumber) {
+ let actualNumber =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+ Assert.equal(actualNumber, aNumber, `There should be ${aNumber} tabs open`);
+}
+
+/**
+ * Assert that the given tab's title is based on the provided folder or
+ * message.
+ *
+ * @param aTab A Tab.
+ * @param aWhat Either an nsIMsgFolder or an nsIMsgDBHdr
+ */
+function assert_tab_titled_from(aTab, aWhat) {
+ let text;
+ if (aWhat instanceof Ci.nsIMsgFolder) {
+ text = aWhat.prettyName;
+ } else if (aWhat instanceof Ci.nsIMsgDBHdr) {
+ text = aWhat.mime2DecodedSubject;
+ }
+
+ utils.waitFor(
+ () => aTab.title.includes(text),
+ `Tab title should include '${text}' but does not. (Current title: '${aTab.title}')`
+ );
+}
+
+/**
+ * Assert that the given tab's title is what is given.
+ *
+ * @param aTab The tab to check.
+ * @param aTitle The title to check.
+ */
+function assert_tab_has_title(aTab, aTitle) {
+ Assert.equal(aTab.title, aTitle);
+}
+
+/**
+ * Close a tab. If no tab is specified, it is assumed you want to close the
+ * current tab.
+ */
+function close_tab(aTabToClose) {
+ if (typeof aTabToClose == "number") {
+ aTabToClose =
+ mc.window.document.getElementById("tabmail").tabInfo[aTabToClose];
+ }
+
+ // Get the current tab count so we can make sure the tab actually closed.
+ let preCount =
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length;
+
+ mc.window.document.getElementById("tabmail").closeTab(aTabToClose);
+
+ // Check that the tab count decreased.
+ if (
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length !=
+ preCount - 1
+ ) {
+ throw new Error("The tab never actually got closed!");
+ }
+}
+
+/**
+ * Close a message window by calling window.close() on the controller.
+ */
+function close_message_window(aController) {
+ windowHelper.close_window(aController);
+}
+
+/**
+ * Clear the selection. I'm not sure how we're pretending we did that, but
+ * we explicitly focus the thread tree as a side-effect.
+ */
+function select_none(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ wait_for_message_display_completion();
+ focus_thread_tree();
+ get_db_view(aController.window).selection.clearSelection();
+ get_about_3pane().threadTree.dispatchEvent(new CustomEvent("select"));
+ // Because the selection event may not be generated immediately, we need to
+ // spin until the message display thinks it is not displaying a message,
+ // which is the sign that the event actually happened.
+ let win2 = get_about_message();
+ function noMessageChecker() {
+ return win2.gMessage == null;
+ }
+ try {
+ utils.waitFor(noMessageChecker);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Timeout waiting for displayedMessage to become null."
+ );
+ } else {
+ throw e;
+ }
+ }
+ wait_for_blank_content_pane(aController);
+}
+
+/**
+ * Normalize a view index to be an absolute index, handling slice-style negative
+ * references as well as piercing complex things like message headers and
+ * synthetic message sets.
+ *
+ * @param aViewIndex An absolute index (integer >= 0), slice-style index (< 0),
+ * or a SyntheticMessageSet (we only care about the first message in it).
+ */
+function _normalize_view_index(aViewIndex) {
+ let dbView = get_db_view();
+
+ // SyntheticMessageSet special-case
+ if (typeof aViewIndex != "number") {
+ let msgHdrIter = aViewIndex.msgHdrs();
+ let msgHdr = msgHdrIter.next().value;
+ msgHdrIter.return();
+ // do not expand
+ aViewIndex = dbView.findIndexOfMsgHdr(msgHdr, false);
+ }
+
+ if (aViewIndex < 0) {
+ return dbView.rowCount + aViewIndex;
+ }
+ return aViewIndex;
+}
+
+/**
+ * Generic method to simulate a left click on a row in a <tree> element.
+ *
+ * @param {XULTreeElement} aTree - The tree element.
+ * @param {number} aRowIndex - Index of a row in the tree to click on.
+ * @param {MozMillController} aController - Controller object.
+ * @see mailTestUtils.treeClick for another way.
+ */
+function click_tree_row(aTree, aRowIndex, aController) {
+ if (aRowIndex < 0 || aRowIndex >= aTree.view.rowCount) {
+ throw new Error(
+ "Row " + aRowIndex + " does not exist in the tree " + aTree.id + "!"
+ );
+ }
+
+ let selection = aTree.view.selection;
+ selection.select(aRowIndex);
+ aTree.ensureRowIsVisible(aRowIndex);
+
+ // get cell coordinates
+ let column = aTree.columns[0];
+ let coords = aTree.getCoordsForCellItem(aRowIndex, column, "text");
+
+ utils.sleep(0);
+ EventUtils.synthesizeMouse(
+ aTree.body,
+ coords.x + 4,
+ coords.y + 4,
+ {},
+ aTree.ownerGlobal
+ );
+ utils.sleep(0);
+}
+
+function _get_row_at_index(aViewIndex) {
+ let win = get_about_3pane();
+ let tree = win.document.getElementById("threadTree");
+ Assert.greater(
+ tree.view.rowCount,
+ aViewIndex,
+ `index ${aViewIndex} must exist to be clicked on`
+ );
+ tree.scrollToIndex(aViewIndex, true);
+ utils.waitFor(() => tree.getRowAtIndex(aViewIndex));
+ return tree.getRowAtIndex(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ *
+ * @returns The message header selected.
+ */
+function select_click_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let row = _get_row_at_index(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, {}, row.ownerGlobal);
+ utils.sleep(0);
+
+ wait_for_message_display_completion(undefined, true);
+
+ return get_about_3pane().gDBView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row in the select column with our mouse.
+ *
+ * @param aViewIndex - If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ * @param aController - The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ *
+ * @returns The message header selected.
+ */
+function select_column_click_row(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let dbView = get_db_view(aController.window);
+
+ let hasMessageDisplay = "messageDisplay" in aController;
+ if (hasMessageDisplay) {
+ wait_for_message_display_completion(aController);
+ }
+ aViewIndex = _normalize_view_index(aViewIndex, aController);
+
+ // A click in the select column will always change the message display. If
+ // clicking on a single selection (deselect), don't wait for a message load.
+ var willDisplayMessage =
+ hasMessageDisplay &&
+ aController.messageDisplay.visible &&
+ !(dbView.selection.count == 1 && dbView.selection.isSelected(aViewIndex)) &&
+ dbView.selection.currentIndex !== aViewIndex;
+
+ if (willDisplayMessage) {
+ plan_for_message_display(aController);
+ }
+ _row_click_helper(
+ aController,
+ aController.window.document.getElementById("threadTree"),
+ aViewIndex,
+ 0,
+ null,
+ "selectCol"
+ );
+ if (hasMessageDisplay) {
+ wait_for_message_display_completion(aController, willDisplayMessage);
+ }
+ return dbView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are toggling the thread specified by a row.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ *
+ */
+function toggle_thread_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row.querySelector(".twisty"), {}, win);
+
+ wait_for_message_display_completion();
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse with the control key pressed,
+ * resulting in the addition/removal of just that row to/from the selection.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ *
+ * @returns The message header of the affected message.
+ */
+function select_control_click_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { accelKey: true }, win);
+
+ wait_for_message_display_completion();
+
+ return win.gDBView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse with the shift key pressed,
+ * adding all the messages between the shift pivot and the shift selected row.
+ *
+ * @param aViewIndex If >= 0, the view index provided, if < 0, a reference to
+ * a view index counting from the last row in the tree. -1 indicates the
+ * last message in the tree, -2 the second to last, etc.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ *
+ * @returns The message headers for all messages that are now selected.
+ */
+function select_shift_click_row(aViewIndex, aController, aDoNotRequireLoad) {
+ aViewIndex = _normalize_view_index(aViewIndex, aController);
+
+ let win = get_about_3pane();
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { shiftKey: true }, win);
+
+ wait_for_message_display_completion();
+
+ return win.gDBView.getSelectedMsgHdrs();
+}
+
+/**
+ * Helper function to click on a row with a given button.
+ */
+function _row_click_helper(
+ aController,
+ aTree,
+ aViewIndex,
+ aButton,
+ aExtra,
+ aColumnId
+) {
+ // Force-focus the tree
+ aTree.focus();
+ // coordinates of the upper left of the entire tree widget (headers included)
+ let treeRect = aTree.getBoundingClientRect();
+ let tx = treeRect.x,
+ ty = treeRect.y;
+ // coordinates of the row display region of the tree (below the headers)
+ let children = aController.window.document.getElementById(aTree.id, {
+ tagName: "treechildren",
+ });
+ let childrenRect = children.getBoundingClientRect();
+ let x = childrenRect.x,
+ y = childrenRect.y;
+ // Click in the middle of the row by default
+ let rowX = childrenRect.width / 2;
+ // For the thread tree, Position our click on the subject column (which cannot
+ // be hidden), and far enough in that we are in no danger of clicking the
+ // expand toggler unless that is explicitly requested.
+ if (aTree.id == "threadTree") {
+ let columnId = aColumnId || "subjectCol";
+ let col = aController.window.document.getElementById(columnId);
+ rowX = col.getBoundingClientRect().x - tx + 8;
+ // click on the toggle if so requested (for subjectCol)
+ if (columnId == "subjectCol" && aExtra !== "toggle") {
+ rowX += 32;
+ }
+ }
+ // Very important, gotta be able to see the row.
+ aTree.ensureRowIsVisible(aViewIndex);
+ let rowY =
+ aTree.rowHeight * (aViewIndex - aTree.getFirstVisibleRow()) +
+ aTree.rowHeight / 2;
+ if (aTree.getRowAt(x + rowX, y + rowY) != aViewIndex) {
+ throw new Error(
+ "Thought we would find row " +
+ aViewIndex +
+ " at " +
+ rowX +
+ "," +
+ rowY +
+ " but we found " +
+ aTree.getRowAt(rowX, rowY)
+ );
+ }
+ // Generate a mouse-down for all click types; the transient selection
+ // logic happens on mousedown which our tests assume is happening. (If you
+ // are using a keybinding to trigger the event, that will not happen, but
+ // we don't test that.)
+ EventUtils.synthesizeMouse(
+ aTree,
+ x + rowX - tx,
+ y + rowY - ty,
+ {
+ type: "mousedown",
+ button: aButton,
+ shiftKey: aExtra === "shift",
+ accelKey: aExtra === "accel",
+ },
+ aController.window
+ );
+
+ // For right-clicks, the platform code generates a "contextmenu" event
+ // when it sees the mouse press/down event. We are not synthesizing a platform
+ // level event (though it is in our power; we just historically have not),
+ // so we need to be the people to create the context menu.
+ if (aButton == 2) {
+ EventUtils.synthesizeMouse(
+ aTree,
+ x + rowX - tx,
+ y + rowY - ty,
+ { type: "contextmenu", button: aButton },
+ aController.window
+ );
+ }
+
+ EventUtils.synthesizeMouse(
+ aTree,
+ x + rowX - tx,
+ y + rowY - ty,
+ {
+ type: "mouseup",
+ button: aButton,
+ shiftKey: aExtra == "shift",
+ accelKey: aExtra === "accel",
+ },
+ aController.window
+ );
+}
+
+/**
+ * Right-click on the tree-view in question. With any luck, this will have
+ * the side-effect of opening up a pop-up which it is then on _your_ head
+ * to do something with or close. However, we have helpful popup function
+ * helpers because I'm so nice.
+ *
+ * @returns The message header that you clicked on.
+ */
+async function right_click_on_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ win.document.getElementById("mailContext"),
+ "popupshown"
+ );
+ let row = win.document.getElementById("threadTree").getRowAtIndex(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { type: "contextmenu" }, win);
+ await shownPromise;
+
+ return get_db_view().getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Middle-click on the tree-view in question, presumably opening a new message
+ * tab.
+ *
+ * @returns [The new tab, the message that you clicked on.]
+ */
+function middle_click_on_row(aViewIndex) {
+ aViewIndex = _normalize_view_index(aViewIndex);
+
+ let win = get_about_3pane();
+ let row = _get_row_at_index(aViewIndex);
+ EventUtils.synthesizeMouseAtCenter(row, { button: 1 }, win);
+
+ return [
+ mc.window.document.getElementById("tabmail").tabInfo[
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length -
+ 1
+ ],
+ win.gDBView.getMsgHdrAt(aViewIndex),
+ ];
+}
+
+/**
+ * Assert that the given folder mode is the current one.
+ *
+ * @param aMode The expected folder mode.
+ * @param [aController] The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function assert_folder_mode(aMode, aController) {
+ let about3Pane = get_about_3pane(aController?.window);
+ if (!about3Pane.folderPane.activeModes.includes(aMode)) {
+ throw new Error(`The folder mode "${aMode}" is not visible`);
+ }
+}
+
+/**
+ * Assert that the given folder is the child of the given parent in the folder
+ * tree view. aParent == null is equivalent to saying that the given folder
+ * should be a top-level folder.
+ */
+function assert_folder_child_in_view(aChild, aParent) {
+ let about3Pane = get_about_3pane();
+ let childRow = about3Pane.folderPane.getRowForFolder(aChild);
+ let parentRow = childRow.parentNode.closest("li");
+
+ if (parentRow?.uri != aParent.URI) {
+ throw new Error(
+ "Folder " +
+ aChild.URI +
+ " should be the child of " +
+ (aParent && aParent.URI) +
+ ", but is actually the child of " +
+ parentRow?.uri
+ );
+ }
+}
+
+/**
+ * Assert that the given folder is in the current folder mode and is visible.
+ *
+ * @param aFolder The folder to assert as visible
+ * @param [aController] The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ * @returns The index of the folder, if it is visible.
+ */
+function assert_folder_visible(aFolder, aController) {
+ let about3Pane = get_about_3pane(aController?.window);
+ let folderIndex = about3Pane.folderTree.rows.findIndex(
+ row => row.uri == aFolder.URI
+ );
+ if (folderIndex == -1) {
+ throw new Error("Folder: " + aFolder.URI + " should be visible, but isn't");
+ }
+
+ return folderIndex;
+}
+
+/**
+ * Assert that the given folder is either not in the current folder mode at all,
+ * or is not currently visible.
+ */
+function assert_folder_not_visible(aFolder) {
+ let about3Pane = get_about_3pane();
+ let folderIndex = about3Pane.folderTree.rows.findIndex(
+ row => row.uri == aFolder.URI
+ );
+ if (folderIndex != -1) {
+ throw new Error(
+ "Folder: " + aFolder.URI + " should not be visible, but is"
+ );
+ }
+}
+
+/**
+ * Collapse a folder if it has children. This will throw if the folder itself is
+ * not visible in the folder view.
+ */
+function collapse_folder(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let about3Pane = get_about_3pane();
+ let folderRow = about3Pane.folderTree.getRowAtIndex(folderIndex);
+ if (!folderRow.classList.contains("collapsed")) {
+ EventUtils.synthesizeMouseAtCenter(
+ folderRow.querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ }
+}
+
+/**
+ * Expand a folder if it has children. This will throw if the folder itself is
+ * not visible in the folder view.
+ */
+function expand_folder(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let about3Pane = get_about_3pane();
+ let folderRow = about3Pane.folderTree.getRowAtIndex(folderIndex);
+ if (folderRow.classList.contains("collapsed")) {
+ EventUtils.synthesizeMouseAtCenter(
+ folderRow.querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ }
+}
+
+/**
+ * Assert that a folder is currently visible and collapsed. This will throw if
+ * either of the two is untrue.
+ */
+function assert_folder_collapsed(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let row = get_about_3pane().folderTree.getRowAtIndex(folderIndex);
+ Assert.ok(row.classList.contains("collapsed"));
+}
+
+/**
+ * Assert that a folder is currently visible and expanded. This will throw if
+ * either of the two is untrue.
+ */
+function assert_folder_expanded(aFolder) {
+ let folderIndex = assert_folder_visible(aFolder);
+ let row = get_about_3pane().folderTree.getRowAtIndex(folderIndex);
+ Assert.ok(!row.classList.contains("collapsed"));
+}
+
+/**
+ * Pretend we are clicking on a folder with our mouse.
+ *
+ * @param aFolder The folder to click on. This needs to be present in the
+ * current folder tree view, of course.
+ *
+ * @returns the view index that you clicked on.
+ */
+function select_click_folder(aFolder) {
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ let row = folderTree.rows.find(row => row.uri == aFolder.URI);
+ row.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(row.querySelector(".container"), {}, win);
+}
+
+/**
+ * Pretend we are clicking on a folder with our mouse with the shift key pressed.
+ *
+ * @param aFolder The folder to shift-click on. This needs to be present in the
+ * current folder tree view, of course.
+ *
+ * @returns An array containing all the folders that are now selected.
+ */
+function select_shift_click_folder(aFolder) {
+ wait_for_all_messages_to_load();
+
+ let viewIndex = mc.folderTreeView.getIndexOfFolder(aFolder);
+ // Passing -1 as the start range checks the shift-pivot, which should be -1,
+ // so it should fall over to the current index, which is what we want. It
+ // will then set the shift-pivot to the previously-current-index and update
+ // the current index to be what we shift-clicked on. All matches user
+ // interaction.
+ mc.folderTreeView.selection.rangedSelect(-1, viewIndex, false);
+ wait_for_all_messages_to_load();
+ // give the event queue a chance to drain...
+ utils.sleep(0);
+
+ return mc.folderTreeView.getSelectedFolders();
+}
+
+/**
+ * Right click on the folder tree view. With any luck, this will have the
+ * side-effect of opening up a pop-up which it is then on _your_ head to do
+ * something with or close. However, we have helpful popup function helpers
+ * helpers because asuth's so nice.
+ *
+ * @note The argument is a folder here, unlike in the message case, so beware.
+ *
+ * @returns The view index that you clicked on.
+ */
+async function right_click_on_folder(aFolder) {
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ win.document.getElementById("folderPaneContext"),
+ "popupshown"
+ );
+ let row = folderTree.rows.find(row => row.uri == aFolder.URI);
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".container"),
+ { type: "contextmenu" },
+ win
+ );
+ await shownPromise;
+}
+
+/**
+ * Middle-click on the folder tree view, presumably opening a new folder tab.
+ *
+ * @note The argument is a folder here, unlike in the message case, so beware.
+ *
+ * @returns [The new tab, the view index that you clicked on.]
+ */
+function middle_click_on_folder(aFolder, shiftPressed) {
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ let row = folderTree.rows.find(row => row.uri == aFolder.URI);
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".container"),
+ { button: 1, shiftKey: shiftPressed },
+ win
+ );
+
+ return [
+ mc.window.document.getElementById("tabmail").tabInfo[
+ mc.window.document.getElementById("tabmail").tabContainer.allTabs.length -
+ 1
+ ],
+ ];
+}
+
+/**
+ * Get a reference to the smart folder with the given name.
+ *
+ * @param aFolderName The name of the smart folder (e.g. "Inbox").
+ * @returns An nsIMsgFolder representing the smart folder with the given name.
+ */
+function get_smart_folder_named(aFolderName) {
+ let smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ return smartServer.rootFolder.getChildNamed(aFolderName);
+}
+
+/**
+ * Assuming the context popup is popped-up (via right_click_on_row), select
+ * the deletion option. If the popup is not popped up, you are out of luck.
+ */
+async function delete_via_popup() {
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ let win = get_about_3pane();
+ let ctxDelete = win.document.getElementById("mailContext-delete");
+ if (AppConstants.platform == "macosx") {
+ // We need to use click() since the synthesizeMouseAtCenter doesn't work for
+ // context menu items on macos.
+ ctxDelete.click();
+ } else {
+ EventUtils.synthesizeMouseAtCenter(ctxDelete, {}, ctxDelete.ownerGlobal);
+ }
+
+ // for reasons unknown, the pop-up does not close itself?
+ await close_popup(mc, win.document.getElementById("mailContext"));
+ wait_for_folder_events();
+}
+
+async function wait_for_popup_to_open(popupElem) {
+ if (popupElem.state != "open") {
+ await BrowserTestUtils.waitForEvent(popupElem, "popupshown");
+ }
+}
+
+/**
+ * Close the open pop-up.
+ */
+async function close_popup(aController, elem) {
+ // if it was already closing, just leave
+ if (elem.state == "closed") {
+ return;
+ }
+
+ if (elem.state != "hiding") {
+ // Actually close the popup because it's not closing/closed.
+ let hiddenPromise = BrowserTestUtils.waitForEvent(elem, "popuphidden");
+ elem.hidePopup();
+ await hiddenPromise;
+ await new Promise(resolve =>
+ aController.window.requestAnimationFrame(resolve)
+ );
+ }
+}
+
+/**
+ * Pretend we are pressing the delete key, triggering message deletion of the
+ * selected messages.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ * @param aModifiers (optional) Modifiers to pass to the keypress method.
+ */
+function press_delete(aController, aModifiers) {
+ if (aController == null) {
+ aController = mc;
+ }
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+
+ EventUtils.synthesizeKey("VK_DELETE", aModifiers || {}, aController.window);
+ wait_for_folder_events();
+}
+
+/**
+ * Delete all messages in the given folder.
+ * (called empty_folder similarly to emptyTrash method on root folder)
+ *
+ * @param aFolder Folder to empty.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+async function empty_folder(aFolder, aController = mc) {
+ if (!aFolder) {
+ throw new Error("No folder for emptying given");
+ }
+
+ await be_in_folder(aFolder);
+ let msgCount;
+ while ((msgCount = aFolder.getTotalMessages(false)) > 0) {
+ select_click_row(0, aController);
+ press_delete(aController);
+ utils.waitFor(() => aFolder.getTotalMessages(false) < msgCount);
+ }
+}
+
+/**
+ * Archive the selected messages, and wait for it to complete. Archiving
+ * plans and waits for message display if the display is visible because
+ * successful archiving will by definition change the currently displayed
+ * set of messages (unless you are looking at a virtual folder that includes
+ * the archive folder.)
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function archive_selected_messages(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let dbView = get_db_view(aController.window);
+
+ // How many messages do we expect to remain after the archival?
+ let expectedCount = dbView.rowCount - dbView.numSelected;
+
+ // if (expectedCount && aController.messageDisplay.visible) {
+ // plan_for_message_display(aController);
+ // }
+ EventUtils.synthesizeKey("a", {}, aController.window);
+
+ // Wait for the view rowCount to decrease by the number of selected messages.
+ let messagesDeletedFromView = function () {
+ return dbView.rowCount == expectedCount;
+ };
+ utils.waitFor(
+ messagesDeletedFromView,
+ "Timeout waiting for messages to be archived"
+ );
+ // wait_for_message_display_completion(
+ // aController,
+ // expectedCount && aController.messageDisplay.visible
+ // );
+ // The above may return immediately, meaning the event queue might not get a
+ // chance. give it a chance now.
+ utils.sleep(0);
+}
+
+/**
+ * Pretend we are pressing the Enter key, triggering opening selected messages.
+ * Note that since we don't know where this is going to trigger a message load,
+ * you're going to have to wait for message display completion yourself.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function press_enter(aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ // if something is loading, make sure it finishes loading...
+ if ("messageDisplay" in aController) {
+ wait_for_message_display_completion(aController);
+ }
+ EventUtils.synthesizeKey("VK_RETURN", {}, aController.window);
+ // The caller's going to have to wait for message display completion
+}
+
+/**
+ * Wait for the |folderDisplay| on aController (defaults to mc if omitted) to
+ * finish loading. This generally only matters for folders that have an active
+ * search.
+ * This method is generally called automatically most of the time, and you
+ * should not need to call it yourself unless you are operating outside the
+ * helper methods in this file.
+ */
+function wait_for_all_messages_to_load(aController = mc) {
+ // utils.waitFor(
+ // () => aController.window.gFolderDisplay.allMessagesLoaded,
+ // "Messages never finished loading. Timed Out."
+ // );
+ // the above may return immediately, meaning the event queue might not get a
+ // chance. give it a chance now.
+ utils.sleep(0);
+}
+
+/**
+ * Call this before triggering a message display that you are going to wait for
+ * using |wait_for_message_display_completion| where you are passing true for
+ * the aLoadDemanded argument. This ensures that if a message is already
+ * displayed for the given controller that state is sufficiently cleaned up
+ * so it doesn't trick us into thinking that there is no need to wait.
+ *
+ * @param [aControllerOrTab] optional controller or tab, defaulting to |mc|. If
+ * the message display is going to be caused by a tab switch, a reference to
+ * the tab to switch to should be passed in.
+ */
+function plan_for_message_display(aControllerOrTab) {}
+
+/**
+ * If a message or summary is in the process of loading, let it finish;
+ * optionally, be sure to wait for a load to happen (assuming
+ * |plan_for_message_display| is used, modulo the conditions below.)
+ *
+ * This method is used defensively by a lot of other code in this file that is
+ * really not sure whether there might be a load in progress or not. So by
+ * default we only do something if there is obviously a message display in
+ * progress. Since some events may end up getting deferred due to script
+ * blockers or the like, it is possible the event that triggers the display
+ * may not have happened by the time you call this. In that case, you should
+ *
+ * 1) pass true for aLoadDemanded, and
+ * 2) invoke |plan_for_message_display|
+ *
+ * before triggering the event that will induce a message display. Note that:
+ * - You cannot do #2 if you are opening a new message window and can assume
+ * that this will be the first message ever displayed in the window. This is
+ * fine, because messageLoaded is initially false.
+ * - You should not do #2 if you are opening a new folder or message tab. That
+ * is because you'll affect the old tab's message display instead of the new
+ * tab's display. Again, this is fine, because a new message display will be
+ * created for the new tab, and messageLoaded will initially be false for it.
+ *
+ * If we didn't use this method defensively, we would get horrible assertions
+ * like so:
+ * ###!!! ASSERTION: Overwriting an existing document channel!
+ *
+ *
+ * @param [aController] optional controller, defaulting to |mc|.
+ * @param [aLoadDemanded=false] Should we require that we wait for a message to
+ * be loaded? You should use this in conjunction with
+ * |plan_for_message_display| as per the documentation above. If you do
+ * not pass true and there is no message load in process, this method will
+ * return immediately.
+ */
+function wait_for_message_display_completion(aController, aLoadDemanded) {
+ let win;
+ if (
+ aController == null ||
+ aController.window.document.getElementById("tabmail")
+ ) {
+ win = get_about_message(aController?.window);
+ } else {
+ win =
+ aController.window.document.getElementById(
+ "messageBrowser"
+ ).contentWindow;
+ }
+
+ let tabmail = mc.window.document.getElementById("tabmail");
+ if (tabmail.currentTabInfo.mode.name == "mail3PaneTab") {
+ let about3Pane = tabmail.currentAbout3Pane;
+ if (about3Pane?.gDBView?.getSelectedMsgHdrs().length > 1) {
+ // Displaying multiple messages.
+ return;
+ }
+ if (about3Pane?.messagePaneSplitter.isCollapsed) {
+ // Message pane hidden.
+ return;
+ }
+ }
+
+ utils.waitFor(() => win.document.readyState == "complete");
+
+ let browser = win.getMessagePaneBrowser();
+
+ try {
+ utils.waitFor(
+ () =>
+ !browser.docShell?.isLoadingDocument &&
+ (!aLoadDemanded || browser.currentURI?.spec != "about:blank")
+ );
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for a message. Current location: ${browser.currentURI?.spec}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ utils.sleep();
+}
+
+/**
+ * Wait for the content pane to be blank because no message is to be displayed.
+ *
+ * @param aController optional controller, defaulting to |mc|.
+ */
+function wait_for_blank_content_pane(aController) {
+ let win;
+ if (aController == null || aController == mc) {
+ win = get_about_message();
+ } else {
+ win = aController.window;
+ }
+
+ utils.waitFor(() => win.document.readyState == "complete");
+
+ let browser = win.getMessagePaneBrowser();
+ if (BrowserTestUtils.is_hidden(browser)) {
+ return;
+ }
+
+ try {
+ utils.waitFor(
+ () =>
+ !browser.docShell?.isLoadingDocument &&
+ browser.currentURI?.spec == "about:blank"
+ );
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for blank content pane. Current location: ${browser.currentURI?.spec}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ // the above may return immediately, meaning the event queue might not get a
+ // chance. give it a chance now.
+ utils.sleep();
+}
+
+var FolderListener = {
+ _inited: false,
+ ensureInited() {
+ if (this._inited) {
+ return;
+ }
+
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.event
+ );
+
+ this._inited = true;
+ },
+
+ sawEvents: false,
+ watchingFor: null,
+ planToWaitFor(...aArgs) {
+ this.sawEvents = false;
+ this.watchingFor = aArgs;
+ },
+
+ waitForEvents() {
+ if (this.sawEvents) {
+ return;
+ }
+ let self = this;
+ try {
+ utils.waitFor(() => self.sawEvents);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for events: ${this.watchingFor}`
+ );
+ } else {
+ throw e;
+ }
+ }
+ },
+
+ onFolderEvent(aFolder, aEvent) {
+ if (!this.watchingFor) {
+ return;
+ }
+ if (this.watchingFor.includes(aEvent)) {
+ this.watchingFor = null;
+ this.sawEvents = true;
+ }
+ },
+};
+
+/**
+ * Plan to wait for an nsIFolderListener.onFolderEvent matching one of the
+ * provided strings. Call this before you do the thing that triggers the
+ * event, then call |wait_for_folder_events| after the event. This ensures
+ * that we see the event, because it might be too late after you initiate
+ * the thing that would generate the event.
+ * For example, plan_to_wait_for_folder_events("DeleteOrMoveMsgCompleted",
+ * "DeleteOrMoveMsgFailed") waits for a deletion completion notification
+ * when you call |wait_for_folder_events|.
+ * The waiting is currently un-scoped, so the event happening on any folder
+ * triggers us. It is expected that you won't try and have multiple events
+ * in-flight or will augment us when the time comes to have to deal with that.
+ */
+function plan_to_wait_for_folder_events(...aArgs) {
+ FolderListener.ensureInited();
+ FolderListener.planToWaitFor(...aArgs);
+}
+function wait_for_folder_events() {
+ FolderListener.waitForEvents();
+}
+
+/**
+ * Assert that the given synthetic message sets are present in the folder
+ * display.
+ *
+ * Verify that the messages in the provided SyntheticMessageSets are the only
+ * visible messages in the provided DBViewWrapper. If dummy headers are present
+ * in the view for group-by-sort, the code will ensure that the dummy header's
+ * underlying header corresponds to a message in the synthetic sets. However,
+ * you should generally not rely on this code to test for anything involving
+ * dummy headers.
+ *
+ * In the event the view does not contain all of the messages from the provided
+ * sets or contains messages not in the provided sets, throw_and_dump_view_state
+ * will be invoked with a human readable explanation of the problem.
+ *
+ * @param aSynSets Either a single SyntheticMessageSet or a list of them.
+ * @param aController Optional controller, which we get the folderDisplay
+ * property from. If omitted, we use mc.
+ */
+function assert_messages_in_view(aSynSets, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ if (!("length" in aSynSets)) {
+ aSynSets = [aSynSets];
+ }
+
+ // - Iterate over all the message sets, retrieving the message header. Use
+ // this to construct a URI to populate a dictionary mapping.
+ let synMessageURIs = {}; // map URI to message header
+ for (let messageSet of aSynSets) {
+ for (let msgHdr of messageSet.msgHdrs()) {
+ synMessageURIs[msgHdr.folder.getUriForMsg(msgHdr)] = msgHdr;
+ }
+ }
+
+ // - Iterate over the contents of the view, nulling out values in
+ // synMessageURIs for found messages, and exploding for missing ones.
+ let dbView = get_db_view(aController.window);
+ let treeView = dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ // expected hit, null it out. (in the dummy case, we will just null out
+ // twice, which is also why we do an 'in' test and not a value test.
+ if (uri in synMessageURIs) {
+ synMessageURIs[uri] = null;
+ } else {
+ // the view is showing a message that should not be shown, explode.
+ throw_and_dump_view_state(
+ "The view should show the message header" + msgHdr.messageKey
+ );
+ }
+ }
+
+ // - Iterate over our URI set and make sure every message got nulled out.
+ for (let uri in synMessageURIs) {
+ let msgHdr = synMessageURIs[uri];
+ if (msgHdr != null) {
+ throw_and_dump_view_state(
+ "The view should include the message header" + msgHdr.messageKey
+ );
+ }
+ }
+}
+
+/**
+ * Assert the the given message/messages are not present in the view.
+ *
+ * @param aMessages Either a single nsIMsgDBHdr or a list of them.
+ */
+function assert_messages_not_in_view(aMessages) {
+ if (aMessages instanceof Ci.nsIMsgDBHdr) {
+ aMessages = [aMessages];
+ }
+
+ let dbView = get_db_view();
+ for (let msgHdr of aMessages) {
+ Assert.equal(
+ dbView.findIndexOfMsgHdr(msgHdr, true),
+ nsMsgViewIndex_None,
+ `Message header is present in view but should not be`
+ );
+ }
+}
+var assert_message_not_in_view = assert_messages_not_in_view;
+
+/**
+ * When displaying a folder, assert that the message pane is visible and all the
+ * menus, splitters, etc. are set up right.
+ */
+function assert_message_pane_visible() {
+ let win = get_about_3pane();
+ let messagePane = win.document.getElementById("messagePane");
+
+ Assert.equal(
+ win.paneLayout.messagePaneVisible,
+ true,
+ "The tab does not think that the message pane is visible, but it should!"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(messagePane),
+ "The message pane should not be collapsed!"
+ );
+ Assert.equal(
+ win.messagePaneSplitter.isCollapsed,
+ false,
+ "The message pane splitter should not be collapsed!"
+ );
+
+ mc.window.view_init(); // Force the view menu to update.
+ let paneMenuItem = mc.window.document.getElementById("menu_showMessage");
+ Assert.equal(
+ paneMenuItem.getAttribute("checked"),
+ "true",
+ "The Message Pane menu item should be checked."
+ );
+}
+
+/**
+ * When displaying a folder, assert that the message pane is hidden and all the
+ * menus, splitters, etc. are set up right.
+ */
+function assert_message_pane_hidden() {
+ let win = get_about_3pane();
+ let messagePane = win.document.getElementById("messagePane");
+
+ Assert.equal(
+ win.paneLayout.messagePaneVisible,
+ false,
+ "The tab thinks that the message pane is visible, but it shouldn't!"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(messagePane),
+ "The message pane should be collapsed!"
+ );
+ Assert.equal(
+ win.messagePaneSplitter.isCollapsed,
+ true,
+ "The message pane splitter should be collapsed!"
+ );
+
+ mc.window.view_init(); // Force the view menu to update.
+ let paneMenuItem = mc.window.document.getElementById("menu_showMessage");
+ Assert.notEqual(
+ paneMenuItem.getAttribute("checked"),
+ "true",
+ "The Message Pane menu item should not be checked."
+ );
+}
+
+/**
+ * Toggle the visibility of the message pane.
+ */
+function toggle_message_pane() {
+ EventUtils.synthesizeKey("VK_F8", {}, get_about_3pane());
+}
+
+/**
+ * Make the folder pane visible in order to run tests.
+ * This is necessary as the FolderPane is collapsed if no account is available.
+ */
+function show_folder_pane() {
+ mc.window.document.getElementById("folderPaneBox").collapsed = false;
+}
+
+/**
+ * Helper function for use by assert_selected / assert_selected_and_displayed /
+ * assert_displayed.
+ *
+ * @returns A list of two elements: [MozmillController, [list of view indices]].
+ */
+function _process_row_message_arguments(...aArgs) {
+ let troller = mc;
+ // - normalize into desired selected view indices
+ let desiredIndices = [];
+ for (let arg of aArgs) {
+ // An integer identifying a view index
+ if (typeof arg == "number") {
+ desiredIndices.push(_normalize_view_index(arg));
+ } else if (arg instanceof Ci.nsIMsgDBHdr) {
+ // A message header
+ // do not expand; the thing should already be selected, eg expanded!
+ let viewIndex = get_db_view(troller.window).findIndexOfMsgHdr(arg, false);
+ if (viewIndex == nsMsgViewIndex_None) {
+ throw_and_dump_view_state(
+ "Message not present in view that should be there. " +
+ "(" +
+ arg.messageKey +
+ ": " +
+ arg.mime2DecodedSubject +
+ ")"
+ );
+ }
+ desiredIndices.push(viewIndex);
+ } else if (arg.length == 2 && typeof arg[0] == "number") {
+ // A list containing two integers, indicating a range of view indices.
+ let lowIndex = _normalize_view_index(arg[0]);
+ let highIndex = _normalize_view_index(arg[1]);
+ for (let viewIndex = lowIndex; viewIndex <= highIndex; viewIndex++) {
+ desiredIndices.push(viewIndex);
+ }
+ } else if (arg.length !== undefined) {
+ // a List of message headers
+ for (let iMsg = 0; iMsg < arg.length; iMsg++) {
+ let msgHdr = arg[iMsg].QueryInterface(Ci.nsIMsgDBHdr);
+ if (!msgHdr) {
+ throw new Error(arg[iMsg] + " is not a message header!");
+ }
+ // false means do not expand, it should already be selected
+ let viewIndex = get_db_view(troller.window).findIndexOfMsgHdr(
+ msgHdr,
+ false
+ );
+ if (viewIndex == nsMsgViewIndex_None) {
+ throw_and_dump_view_state(
+ "Message not present in view that should be there. " +
+ "(" +
+ msgHdr.messageKey +
+ ": " +
+ msgHdr.mime2DecodedSubject +
+ ")"
+ );
+ }
+ desiredIndices.push(viewIndex);
+ }
+ } else if (arg.synMessages) {
+ // SyntheticMessageSet
+ for (let msgHdr of arg.msgHdrs()) {
+ let viewIndex = get_db_view(troller.window).findIndexOfMsgHdr(
+ msgHdr,
+ false
+ );
+ if (viewIndex == nsMsgViewIndex_None) {
+ throw_and_dump_view_state(
+ "Message not present in view that should be there. " +
+ "(" +
+ msgHdr.messageKey +
+ ": " +
+ msgHdr.mime2DecodedSubject +
+ ")"
+ );
+ }
+ desiredIndices.push(viewIndex);
+ }
+ } else if (arg.window) {
+ // it's a MozmillController
+ troller = arg;
+ } else {
+ throw new Error("Illegal argument: " + arg);
+ }
+ }
+ // sort by integer value
+ desiredIndices.sort(function (a, b) {
+ return a - b;
+ });
+
+ return [troller, desiredIndices];
+}
+
+/**
+ * Asserts that the given set of messages are selected. Unless you are dealing
+ * with transient selections resulting from right-clicks, you want to be using
+ * assert_selected_and_displayed because it makes sure that the display is
+ * correct too.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - A message header.
+ * - A list of message headers.
+ * - A synthetic message set.
+ */
+function assert_selected(...aArgs) {
+ let [troller, desiredIndices] = _process_row_message_arguments(...aArgs);
+
+ // - get the actual selection (already sorted by integer value)
+ let selectedIndices = get_db_view(troller.window).getIndicesForSelection();
+
+ // - test selection equivalence
+ // which is the same as string equivalence in this case. muah hah hah.
+ Assert.equal(
+ selectedIndices.toString(),
+ desiredIndices.toString(),
+ "should have the right selected indices"
+ );
+ return [troller, desiredIndices];
+}
+
+/**
+ * Assert that the given set of messages is displayed, but not necessarily
+ * selected. Unless you are dealing with transient selection issues or some
+ * other situation where the FolderDisplay should not be correlated with the
+ * MessageDisplay, you really should be using assert_selected_and_displayed.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - A message header.
+ * - A list of message headers.
+ */
+function assert_displayed(...aArgs) {
+ let [troller, desiredIndices] = _process_row_message_arguments(...aArgs);
+ _internal_assert_displayed(false, troller, desiredIndices);
+}
+
+/**
+ * Assert-that-the-display-is-right logic. We need an internal version so that
+ * we can know whether we can trust/assert that folderDisplay.selectedMessage
+ * agrees with messageDisplay, and also so that we don't have to re-compute
+ * troller and desiredIndices.
+ */
+function _internal_assert_displayed(trustSelection, troller, desiredIndices) {
+ // - verify that the right thing is being displayed.
+ // no selection means folder summary.
+ if (desiredIndices.length == 0) {
+ wait_for_blank_content_pane(troller);
+
+ let messageWindow = get_about_message();
+
+ // folder summary is not landed yet, just verify there is no message.
+ if (messageWindow.gMessage) {
+ throw new Error(
+ "Message display should not think it is displaying a message."
+ );
+ }
+ // make sure the content pane is pointed at about:blank
+ let location = messageWindow.getMessagePaneBrowser()?.location;
+ if (location && location.href != "about:blank") {
+ throw new Error(
+ `the content pane should be blank, but is showing: '${location.href}'`
+ );
+ }
+ } else if (desiredIndices.length == 1) {
+ /*
+ // 1 means the message should be displayed
+ // make sure message display thinks we are in single message display mode
+ if (!troller.messageDisplay.singleMessageDisplay) {
+ throw new Error("Message display is not in single message display mode.");
+ }
+ // now make sure that we actually are in single message display mode
+ let singleMessagePane = troller.window.document.getElementById("singleMessage");
+ let multiMessagePane = troller.window.document.getElementById("multimessage");
+ if (singleMessagePane && singleMessagePane.hidden) {
+ throw new Error("Single message pane is hidden but it should not be.");
+ }
+ if (multiMessagePane && !multiMessagePane.hidden) {
+ throw new Error("Multiple message pane is visible but it should not be.");
+ }
+
+ if (trustSelection) {
+ if (
+ troller.window.gFolderDisplay.selectedMessage !=
+ troller.messageDisplay.displayedMessage
+ ) {
+ throw new Error(
+ "folderDisplay.selectedMessage != " +
+ "messageDisplay.displayedMessage! (fd: " +
+ troller.window.gFolderDisplay.selectedMessage +
+ " vs md: " +
+ troller.messageDisplay.displayedMessage +
+ ")"
+ );
+ }
+ }
+
+ let msgHdr = troller.messageDisplay.displayedMessage;
+ let msgUri = msgHdr.folder.getUriForMsg(msgHdr);
+ // wait for the document to load so that we don't try and replace it later
+ // and get that stupid assertion
+ wait_for_message_display_completion();
+ utils.sleep(500)
+ // make sure the content pane is pointed at the right thing
+
+ let msgService = troller.window.gFolderDisplay.messenger.messageServiceFromURI(
+ msgUri
+ );
+ let msgUrl = msgService.getUrlForUri(
+ msgUri,
+ troller.window.gFolderDisplay.msgWindow
+ );
+ if (troller.window.content?.location.href != msgUrl.spec) {
+ throw new Error(
+ "The content pane is not displaying the right message! " +
+ "Should be: " +
+ msgUrl.spec +
+ " but it's: " +
+ troller.window.content.location.href
+ );
+ }
+ */
+ } else {
+ /*
+ // multiple means some form of multi-message summary
+ // XXX deal with the summarization threshold bail case.
+
+ // make sure the message display thinks we are in multi-message mode
+ if (troller.messageDisplay.singleMessageDisplay) {
+ throw new Error(
+ "Message display should not be in single message display" +
+ "mode! Desired indices: " +
+ desiredIndices
+ );
+ }
+
+ // verify that the message pane browser is displaying about:blank
+ if (mc.window.content && mc.window.content.location.href != "about:blank") {
+ throw new Error(
+ "the content pane should be blank, but is showing: '" +
+ mc.window.content.location.href +
+ "'"
+ );
+ }
+
+ // now make sure that we actually are in nultiple message display mode
+ let singleMessagePane = troller.window.document.getElementById("singleMessage");
+ let multiMessagePane = troller.window.document.getElementById("multimessage");
+ if (singleMessagePane && !singleMessagePane.hidden) {
+ throw new Error("Single message pane is visible but it should not be.");
+ }
+ if (multiMessagePane && multiMessagePane.hidden) {
+ throw new Error("Multiple message pane is hidden but it should not be.");
+ }
+
+ // and _now_ make sure that we actually summarized what we wanted to
+ // summarize.
+ let desiredMessages = desiredIndices.map(vi => mc.window.gFolderDisplay.view.dbView.getMsgHdrAt(vi));
+ assert_messages_summarized(troller, desiredMessages);
+ */
+ }
+}
+
+/**
+ * Assert that the messages corresponding to the one or more message spec
+ * arguments are selected and displayed. If you specify multiple messages,
+ * we verify that the multi-message selection mode is in effect and that they
+ * are doing the desired thing. (Verifying the summarization may seem
+ * overkill, but it helps make the tests simpler and allows you to be more
+ * confident if you're just running one test that everything in the test is
+ * performing in a sane fashion. Refactoring could be in order, of course.)
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - A message header.
+ * - A list of message headers.
+ */
+function assert_selected_and_displayed(...aArgs) {
+ // make sure the selection is right first.
+ let [troller, desiredIndices] = assert_selected(...aArgs);
+ // now make sure the display is right
+ _internal_assert_displayed(true, troller, desiredIndices);
+}
+
+/**
+ * Use the internal archiving code for archiving any given set of messages
+ *
+ * @param aMsgHdrs a list of message headers
+ */
+function archive_messages(aMsgHdrs) {
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+
+ let { MessageArchiver } = ChromeUtils.import(
+ "resource:///modules/MessageArchiver.jsm"
+ );
+ let batchMover = new MessageArchiver();
+ batchMover.archiveMessages(aMsgHdrs);
+ wait_for_folder_events();
+}
+
+/**
+ * Check if the selected messages match the summarized messages.
+ *
+ * @param aSummarizedKeys An array of keys (messageKey + folder.URI) for the
+ * summarized messages.
+ * @param aSelectedMessages An array of nsIMsgDBHdrs for the selected messages.
+ * @returns true is aSelectedMessages and aSummarizedKeys refer to the same set
+ * of messages.
+ */
+function _verify_summarized_message_set(aSummarizedKeys, aSelectedMessages) {
+ let summarizedKeys = aSummarizedKeys.slice();
+ summarizedKeys.sort();
+ // We use the same key-generation as in multimessageview.js.
+ let selectedKeys = aSelectedMessages.map(
+ msgHdr => msgHdr.messageKey + msgHdr.folder.URI
+ );
+ selectedKeys.sort();
+
+ // Stringified versions should now be equal...
+ return selectedKeys.toString() == summarizedKeys.toString();
+}
+
+/**
+ * Asserts that the messages the controller's folder display widget thinks are
+ * summarized are in fact summarized. This is automatically called by
+ * assert_selected_and_displayed, so you do not need to call this directly
+ * unless you are testing the summarization logic.
+ *
+ * @param aController The controller who has the summarized display going on.
+ * @param [aMessages] Optional set of messages to verify. If not provided, this
+ * is extracted via the folderDisplay. If a SyntheticMessageSet is provided
+ * we will automatically retrieve what we need from it.
+ */
+function assert_messages_summarized(aController, aSelectedMessages) {
+ // - Compensate for selection stabilization code.
+ // Although WindowHelpers sets the stabilization interval to 0, we
+ // still need to make sure we have drained the event queue so that it has
+ // actually gotten a chance to run.
+ utils.sleep(0);
+
+ // - Verify summary object knows about right messages
+ if (aSelectedMessages == null) {
+ aSelectedMessages = aController.window.gFolderDisplay.selectedMessages;
+ }
+ // if it's a synthetic message set, we want the headers...
+ if (aSelectedMessages.synMessages) {
+ aSelectedMessages = Array.from(aSelectedMessages.msgHdrs());
+ }
+
+ let summaryFrame = aController.window.gSummaryFrameManager.iframe;
+ let summary = summaryFrame.contentWindow.gMessageSummary;
+ let summarizedKeys = Object.keys(summary._msgNodes);
+ if (aSelectedMessages.length != summarizedKeys.length) {
+ let elaboration =
+ "Summary contains " +
+ summarizedKeys.length +
+ " messages, expected " +
+ aSelectedMessages.length +
+ ".";
+ throw new Error(
+ "Summary does not contain the right set of messages. " + elaboration
+ );
+ }
+ if (!_verify_summarized_message_set(summarizedKeys, aSelectedMessages)) {
+ let elaboration =
+ "Summary: " + summarizedKeys + " Selected: " + aSelectedMessages + ".";
+ throw new Error(
+ "Summary does not contain the right set of messages. " + elaboration
+ );
+ }
+}
+
+/**
+ * Assert that there is nothing selected and, assuming we are in a folder, that
+ * the folder summary is displayed.
+ */
+var assert_nothing_selected = assert_selected_and_displayed;
+
+/**
+ * Assert that the given view index or message is visible in the thread pane.
+ */
+function assert_visible(aViewIndexOrMessage) {
+ let win = get_about_3pane();
+ let viewIndex;
+ if (typeof aViewIndexOrMessage == "number") {
+ viewIndex = _normalize_view_index(aViewIndexOrMessage);
+ } else {
+ viewIndex = win.gDBView.findIndexOfMsgHdr(aViewIndexOrMessage, false);
+ }
+ let tree = win.threadTree;
+ let firstVisibleIndex = tree.getFirstVisibleIndex();
+ let lastVisibleIndex = tree.getLastVisibleIndex();
+
+ if (viewIndex < firstVisibleIndex || viewIndex > lastVisibleIndex) {
+ throw new Error(
+ "View index " +
+ viewIndex +
+ " is not visible! (" +
+ firstVisibleIndex +
+ "-" +
+ lastVisibleIndex +
+ " are visible)"
+ );
+ }
+}
+
+/**
+ * Assert that the given message is now shown in the current view.
+ */
+function assert_not_shown(aMessages) {
+ let win = get_about_3pane();
+ aMessages.forEach(function (msg) {
+ let viewIndex = win.gDBView.findIndexOfMsgHdr(msg, false);
+ if (viewIndex !== nsMsgViewIndex_None) {
+ throw new Error(
+ "Message shows; " + msg.messageKey + ": " + msg.mime2DecodedSubject
+ );
+ }
+ });
+}
+
+/**
+ * @param aShouldBeElided Should the messages at the view indices be elided?
+ * @param aArgs Arguments of the form processed by
+ * |_process_row_message_arguments|.
+ */
+function _assert_elided_helper(aShouldBeElided, ...aArgs) {
+ let [troller, viewIndices] = _process_row_message_arguments(...aArgs);
+
+ let dbView = get_db_view(troller.window);
+ for (let viewIndex of viewIndices) {
+ let flags = dbView.getFlagsAt(viewIndex);
+ if (Boolean(flags & Ci.nsMsgMessageFlags.Elided) != aShouldBeElided) {
+ throw new Error(
+ "Message at view index " +
+ viewIndex +
+ (aShouldBeElided
+ ? " should be elided but is not!"
+ : " should not be elided but is!")
+ );
+ }
+ }
+}
+
+/**
+ * Assert that all of the messages at the given view indices are collapsed.
+ * Arguments should be of the type accepted by |assert_selected_and_displayed|.
+ */
+function assert_collapsed(...aArgs) {
+ _assert_elided_helper(true, ...aArgs);
+}
+
+/**
+ * Assert that all of the messages at the given view indices are expanded.
+ * Arguments should be of the type accepted by |assert_selected_and_displayed|.
+ */
+function assert_expanded(...aArgs) {
+ _assert_elided_helper(false, ...aArgs);
+}
+
+/**
+ * Add the widget with the given id to the toolbar if it is not already present.
+ * It gets added to the front if we add it. Use |remove_from_toolbar| to
+ * remove the widget from the toolbar when you are done.
+ *
+ * @param aToolbarElement The DOM element that is the toolbar, like you would
+ * get from getElementById.
+ * @param aElementId The id attribute of the toolbaritem item you want added to
+ * the toolbar (not the id of the thing inside the toolbaritem tag!).
+ * We take the id name rather than element itself because if not already
+ * present the element is off floating in DOM limbo. (The toolbar widget
+ * calls removeChild on the palette.)
+ */
+function add_to_toolbar(aToolbarElement, aElementId) {
+ let currentSet = aToolbarElement.currentSet.split(",");
+ if (!currentSet.includes(aElementId)) {
+ currentSet.unshift(aElementId);
+ aToolbarElement.currentSet = currentSet.join(",");
+ }
+}
+
+/**
+ * Remove the widget with the given id from the toolbar if it is present. Use
+ * |add_to_toolbar| to add the item in the first place.
+ *
+ * @param aToolbarElement The DOM element that is the toolbar, like you would
+ * get from getElementById.
+ * @param aElementId The id attribute of the item you want removed to the
+ * toolbar.
+ */
+function remove_from_toolbar(aToolbarElement, aElementId) {
+ let currentSet = aToolbarElement.currentSet.split(",");
+ if (currentSet.includes(aElementId)) {
+ currentSet.splice(currentSet.indexOf(aElementId), 1);
+ aToolbarElement.currentSet = currentSet.join(",");
+ }
+}
+
+var RECOGNIZED_WINDOWS = ["messagepane", "multimessage"];
+var RECOGNIZED_ELEMENTS = ["folderTree", "threadTree", "attachmentList"];
+
+/**
+ * Focus the folder tree.
+ */
+function focus_folder_tree() {
+ let folderTree = get_about_3pane().document.getElementById("folderTree");
+ Assert.ok(BrowserTestUtils.is_visible(folderTree), "folder tree is visible");
+ folderTree.focus();
+}
+
+/**
+ * Focus the thread tree.
+ */
+function focus_thread_tree() {
+ let threadTree = get_about_3pane().document.getElementById("threadTree");
+ threadTree.table.body.focus();
+}
+
+/**
+ * Focus the (single) message pane.
+ */
+function focus_message_pane() {
+ let messageBrowser =
+ get_about_3pane().document.getElementById("messageBrowser");
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser is visible"
+ );
+ messageBrowser.focus();
+}
+
+/**
+ * Focus the multimessage pane.
+ */
+function focus_multimessage_pane() {
+ let multiMessageBrowser = get_about_3pane().document.getElementById(
+ "multiMessageBrowser"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(multiMessageBrowser),
+ "multi message browser is visible"
+ );
+ multiMessageBrowser.focus();
+}
+
+/**
+ * Returns a string indicating whatever's currently focused. This will return
+ * either one of the strings in RECOGNIZED_WINDOWS/RECOGNIZED_ELEMENTS or null.
+ */
+function _get_currently_focused_thing() {
+ // If the message pane or multimessage is focused, return that
+ let focusedWindow = mc.window.document.commandDispatcher.focusedWindow;
+ if (focusedWindow) {
+ for (let windowId of RECOGNIZED_WINDOWS) {
+ let elem = mc.window.document.getElementById(windowId);
+ if (elem && focusedWindow == elem.contentWindow) {
+ return windowId;
+ }
+ }
+ }
+
+ // Focused window not recognized, let's try the focused element.
+ // If an element is focused, it is necessary for the main window to be
+ // focused.
+ if (focusedWindow != mc.window) {
+ return null;
+ }
+
+ let focusedElement = mc.window.document.commandDispatcher.focusedElement;
+ let elementsToMatch = RECOGNIZED_ELEMENTS.map(elem =>
+ mc.window.document.getElementById(elem)
+ );
+ while (focusedElement && !elementsToMatch.includes(focusedElement)) {
+ focusedElement = focusedElement.parentNode;
+ }
+
+ return focusedElement ? focusedElement.id : null;
+}
+
+function _assert_thing_focused(aThing) {
+ let focusedThing = _get_currently_focused_thing();
+ if (focusedThing != aThing) {
+ throw new Error(
+ "The currently focused thing should be " +
+ aThing +
+ ", but is actually " +
+ focusedThing
+ );
+ }
+}
+
+/**
+ * Assert that the folder tree is focused.
+ */
+function assert_folder_tree_focused() {
+ Assert.equal(get_about_3pane().document.activeElement.id, "folderTree");
+}
+
+/**
+ * Assert that the thread tree is focused.
+ */
+function assert_thread_tree_focused() {
+ let about3Pane = get_about_3pane();
+ Assert.equal(
+ about3Pane.document.activeElement,
+ about3Pane.threadTree.table.body
+ );
+}
+
+/**
+ * Assert that the (single) message pane is focused.
+ */
+function assert_message_pane_focused() {
+ // TODO: this doesn't work.
+ // let aboutMessageWin = get_about_3pane_or_about_message();
+ // ready_about_win(aboutMessageWin);
+ // Assert.equal(
+ // aboutMessageWin.document.activeElement.id,
+ // "messageBrowser"
+ // );
+}
+
+/**
+ * Assert that the multimessage pane is focused.
+ */
+function assert_multimessage_pane_focused() {
+ _assert_thing_focused("multimessage");
+}
+
+/**
+ * Assert that the attachment list is focused.
+ */
+function assert_attachment_list_focused() {
+ _assert_thing_focused("attachmentList");
+}
+
+function _normalize_folder_view_index(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ if (aViewIndex < 0) {
+ return (
+ aController.folderTreeView.QueryInterface(Ci.nsITreeView).rowCount +
+ aViewIndex
+ );
+ }
+ return aViewIndex;
+}
+
+/**
+ * Helper function for use by assert_folders_selected /
+ * assert_folders_selected_and_displayed / assert_folder_displayed.
+ */
+function _process_row_folder_arguments(...aArgs) {
+ let troller = mc;
+ // - normalize into desired selected view indices
+ let desiredFolders = [];
+ for (let arg of aArgs) {
+ // An integer identifying a view index
+ if (typeof arg == "number") {
+ let folder = troller.folderTreeView.getFolderForIndex(
+ _normalize_folder_view_index(arg)
+ );
+ if (!folder) {
+ throw new Error("Folder index not present in folder view: " + arg);
+ }
+ desiredFolders.push(folder);
+ } else if (arg instanceof Ci.nsIMsgFolder) {
+ // A folder
+ desiredFolders.push(arg);
+ } else if (arg.length == 2 && typeof arg[0] == "number") {
+ // A list containing two integers, indicating a range of view indices.
+ let lowIndex = _normalize_folder_view_index(arg[0]);
+ let highIndex = _normalize_folder_view_index(arg[1]);
+ for (let viewIndex = lowIndex; viewIndex <= highIndex; viewIndex++) {
+ desiredFolders.push(
+ troller.folderTreeView.getFolderForIndex(viewIndex)
+ );
+ }
+ } else if (arg.length !== undefined) {
+ // a List of folders
+ for (let iFolder = 0; iFolder < arg.length; iFolder++) {
+ let folder = arg[iFolder].QueryInterface(Ci.nsIMsgFolder);
+ if (!folder) {
+ throw new Error(arg[iFolder] + " is not a folder!");
+ }
+ desiredFolders.push(folder);
+ }
+ } else if (arg.window) {
+ // it's a MozmillController
+ troller = arg;
+ } else {
+ throw new Error("Illegal argument: " + arg);
+ }
+ }
+ // we can't really sort, so you'll have to grin and bear it
+ return [troller, desiredFolders];
+}
+
+/**
+ * Asserts that the given set of folders is selected. Unless you are dealing
+ * with transient selections resulting from right-clicks, you want to be using
+ * assert_folders_selected_and_displayed because it makes sure that the
+ * display is correct too.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - An nsIMsgFolder.
+ * - A list of nsIMsgFolders.
+ */
+function assert_folders_selected(...aArgs) {
+ let [troller, desiredFolders] = _process_row_folder_arguments(...aArgs);
+
+ let win = get_about_3pane();
+ let folderTree = win.window.document.getElementById("folderTree");
+ // - get the actual selection (already sorted by integer value)
+ let uri = folderTree.rows[folderTree.selectedIndex]?.uri;
+ let selectedFolders = [MailServices.folderLookup.getFolderForURL(uri)];
+
+ // - test selection equivalence
+ // no shortcuts here. check if each folder in either array is present in the
+ // other array
+ if (
+ desiredFolders.some(
+ folder => _non_strict_index_of(selectedFolders, folder) == -1
+ ) ||
+ selectedFolders.some(
+ folder => _non_strict_index_of(desiredFolders, folder) == -1
+ )
+ ) {
+ throw new Error(
+ "Desired selection is: " +
+ _prettify_folder_array(desiredFolders) +
+ " but actual " +
+ "selection is: " +
+ _prettify_folder_array(selectedFolders)
+ );
+ }
+
+ return [troller, desiredFolders];
+}
+
+var assert_folder_selected = assert_folders_selected;
+
+/**
+ * Assert that the given folder is displayed, but not necessarily selected.
+ * Unless you are dealing with transient selection issues, you really should
+ * be using assert_folders_selected_and_displayed.
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - An nsIMsgFolder.
+ * - A list of nsIMsgFolders.
+ *
+ * In each case, since we can only have one folder displayed, we only look at
+ * the first folder you pass in.
+ */
+function assert_folder_displayed(...aArgs) {
+ let [troller, desiredFolders] = _process_row_folder_arguments(...aArgs);
+ Assert.equal(
+ troller.window.gFolderDisplay.displayedFolder,
+ desiredFolders[0]
+ );
+}
+
+/**
+ * Asserts that the folders corresponding to the one or more folder spec
+ * arguments are selected and displayed. If you specify multiple folders,
+ * we verify that all of them are selected and that the first folder you pass
+ * in is the one displayed. (If you don't pass in any folders, we can't assume
+ * anything, so we don't test that case.)
+ *
+ * The arguments consist of one or more of the following:
+ * - A MozmillController, indicating we should use that controller instead of
+ * the default, "mc" (corresponding to the 3pane.) Pass this first!
+ * - An integer identifying a view index.
+ * - A list containing two integers, indicating a range of view indices.
+ * - An nsIMsgFolder.
+ * - A list of nsIMsgFolders.
+ */
+function assert_folders_selected_and_displayed(...aArgs) {
+ let [, desiredFolders] = assert_folders_selected(...aArgs);
+ if (desiredFolders.length > 0) {
+ let win = get_about_3pane();
+ Assert.equal(win.gFolder, desiredFolders[0]);
+ }
+}
+
+var assert_folder_selected_and_displayed =
+ assert_folders_selected_and_displayed;
+
+/**
+ * Assert that there are the given number of rows (not including children of
+ * collapsed parents) in the folder tree view.
+ */
+function assert_folder_tree_view_row_count(aCount) {
+ let about3Pane = get_about_3pane();
+ if (about3Pane.folderTree.rowCount != aCount) {
+ throw new Error(
+ "The folder tree view's row count should be " +
+ aCount +
+ ", but is actually " +
+ about3Pane.folderTree.rowCount
+ );
+ }
+}
+
+/**
+ * Assert that the displayed text of the folder at index n equals to str.
+ */
+function assert_folder_at_index_as(n, str) {
+ let folderN = mc.window.gFolderTreeView.getFTVItemForIndex(n);
+ Assert.equal(folderN.text, str);
+}
+
+/**
+ * Since indexOf does strict equality checking, we need this.
+ */
+function _non_strict_index_of(aArray, aSearchElement) {
+ for (let [i, item] of aArray.entries()) {
+ if (item == aSearchElement) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+function _prettify_folder_array(aArray) {
+ return aArray.map(folder => folder.prettyName).join(", ");
+}
+
+/**
+ * Put the view in unthreaded mode.
+ */
+function make_display_unthreaded() {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.showUnthreaded = true;
+ // drain event queue
+ utils.sleep(0);
+ wait_for_message_display_completion();
+}
+
+/**
+ * Put the view in threaded mode.
+ */
+function make_display_threaded() {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.showThreaded = true;
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Put the view in group-by-sort mode.
+ */
+function make_display_grouped() {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.showGroupedBySort = true;
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Collapse all threads in the current view.
+ */
+function collapse_all_threads() {
+ wait_for_message_display_completion();
+ get_about_3pane().commandController.doCommand("cmd_collapseAllThreads");
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Set whether to show unread messages only in the current view.
+ */
+function set_show_unread_only(aShowUnreadOnly) {
+ wait_for_message_display_completion();
+ mc.window.gFolderDisplay.view.showUnreadOnly = aShowUnreadOnly;
+ wait_for_all_messages_to_load();
+ wait_for_message_display_completion();
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Assert that we are showing unread messages only in this view.
+ */
+function assert_showing_unread_only() {
+ wait_for_message_display_completion();
+ if (!mc.window.gFolderDisplay.view.showUnreadOnly) {
+ throw new Error(
+ "The view should be showing unread messages only, but it isn't."
+ );
+ }
+}
+
+/**
+ * Assert that we are _not_ showing unread messages only in this view.
+ */
+function assert_not_showing_unread_only() {
+ wait_for_message_display_completion();
+ if (mc.window.gFolderDisplay.view.showUnreadOnly) {
+ throw new Error(
+ "The view should not be showing unread messages only, but it is."
+ );
+ }
+}
+
+/**
+ * Set the mail view filter for the current view. The aData parameter is for
+ * tags (e.g. you can specify "$label1" for the first tag).
+ */
+function set_mail_view(aMailViewIndex, aData) {
+ wait_for_message_display_completion();
+ get_about_3pane().gViewWrapper.setMailView(aMailViewIndex, aData);
+ wait_for_all_messages_to_load();
+ wait_for_message_display_completion();
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Assert that the current mail view is as given. See the documentation for
+ * |set_mail_view| for information about aData.
+ */
+function assert_mail_view(aMailViewIndex, aData) {
+ let actualMailViewIndex = mc.window.gFolderDisplay.view.mailViewIndex;
+ if (actualMailViewIndex != aMailViewIndex) {
+ throw new Error(
+ "The mail view index should be " +
+ aMailViewIndex +
+ ", but is actually " +
+ actualMailViewIndex
+ );
+ }
+
+ let actualMailViewData = mc.window.gFolderDisplay.view.mailViewData;
+ if (actualMailViewData != aData) {
+ throw new Error(
+ "The mail view data should be " +
+ aData +
+ ", but is actually " +
+ actualMailViewData
+ );
+ }
+}
+
+/**
+ * Expand all threads in the current view.
+ */
+function expand_all_threads() {
+ wait_for_message_display_completion();
+ get_about_3pane().commandController.doCommand("cmd_expandAllThreads");
+ // drain event queue
+ utils.sleep(0);
+}
+
+/**
+ * Set the mail.openMessageBehavior pref.
+ *
+ * @param aPref One of "NEW_WINDOW", "EXISTING_WINDOW" or "NEW_TAB"
+ */
+function set_open_message_behavior(aPref) {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior[aPref]
+ );
+}
+
+/**
+ * Reset the mail.openMessageBehavior pref.
+ */
+function reset_open_message_behavior() {
+ if (Services.prefs.prefHasUserValue("mail.openMessageBehavior")) {
+ Services.prefs.clearUserPref("mail.openMessageBehavior");
+ }
+}
+
+/**
+ * Set the mail.tabs.loadInBackground pref.
+ *
+ * @param aPref true/false.
+ */
+function set_context_menu_background_tabs(aPref) {
+ Services.prefs.setBoolPref("mail.tabs.loadInBackground", aPref);
+}
+
+/**
+ * Reset the mail.tabs.loadInBackground pref.
+ */
+function reset_context_menu_background_tabs() {
+ if (Services.prefs.prefHasUserValue("mail.tabs.loadInBackground")) {
+ Services.prefs.clearUserPref("mail.tabs.loadInBackground");
+ }
+}
+
+/**
+ * Set the mail.close_message_window.on_delete pref.
+ *
+ * @param aPref true/false.
+ */
+function set_close_message_on_delete(aPref) {
+ Services.prefs.setBoolPref("mail.close_message_window.on_delete", aPref);
+}
+
+/**
+ * Reset the mail.close_message_window.on_delete pref.
+ */
+function reset_close_message_on_delete() {
+ if (Services.prefs.prefHasUserValue("mail.close_message_window.on_delete")) {
+ Services.prefs.clearUserPref("mail.close_message_window.on_delete");
+ }
+}
+
+/**
+ * assert that the multimessage/thread summary view contains
+ * the specified number of elements of the specified selector.
+ *
+ * @param aSelector: the CSS selector to use to select
+ * @param aNumElts: the number of expected elements that have that class
+ */
+
+function assert_summary_contains_N_elts(aSelector, aNumElts) {
+ let htmlframe = mc.window.document.getElementById("multimessage");
+ let matches = htmlframe.contentDocument.querySelectorAll(aSelector);
+ if (matches.length != aNumElts) {
+ throw new Error(
+ "Expected to find " +
+ aNumElts +
+ " elements with selector '" +
+ aSelector +
+ "', found: " +
+ matches.length
+ );
+ }
+}
+
+function throw_and_dump_view_state(aMessage, aController) {
+ dump("******** " + aMessage + "\n");
+ dump_view_state(get_db_view(aController?.window));
+ throw new Error(aMessage);
+}
+
+/**
+ * Copy constants from mailWindowOverlay.js
+ */
+
+var kClassicMailLayout = 0;
+var kWideMailLayout = 1;
+var kVerticalMailLayout = 2;
+
+/**
+ * Assert that the expected mail pane layout is shown.
+ *
+ * @param aLayout layout code
+ */
+function assert_pane_layout(aLayout) {
+ let actualPaneLayout = Services.prefs.getIntPref("mail.pane_config.dynamic");
+ if (actualPaneLayout != aLayout) {
+ throw new Error(
+ "The mail pane layout should be " +
+ aLayout +
+ ", but is actually " +
+ actualPaneLayout
+ );
+ }
+}
+
+/**
+ * Change the current mail pane layout.
+ *
+ * @param aLayout layout code
+ */
+function set_pane_layout(aLayout) {
+ Services.prefs.setIntPref("mail.pane_config.dynamic", aLayout);
+}
+
+/*
+ * Check window sizes of the main Tb window whether they are at the default values.
+ * Some tests change the window size so need to be sure what size they start with.
+ */
+function assert_default_window_size() {
+ Assert.equal(
+ mc.window.outerWidth,
+ gDefaultWindowWidth,
+ "Main window didn't meet the expected width"
+ );
+ Assert.equal(
+ mc.window.outerHeight,
+ gDefaultWindowHeight,
+ "Main window didn't meet the expected height"
+ );
+}
+
+/**
+ * Restore window to nominal dimensions; saving the size was not working out.
+ */
+function restore_default_window_size() {
+ windowHelper.resize_to(mc, gDefaultWindowWidth, gDefaultWindowHeight);
+}
+
+/**
+ * Toggle visibility of the Main menu bar.
+ *
+ * @param {boolean} aEnabled - Whether the menu should be shown or not.
+ */
+function toggle_main_menu(aEnabled = true) {
+ let menubar = mc.window.document.getElementById("toolbar-menubar");
+ let state = menubar.getAttribute("autohide") != "true";
+ menubar.setAttribute("autohide", !aEnabled);
+ utils.sleep(0);
+ return state;
+}
+
+/**
+ * Load a file in its own 'module' (scope really), based on the effective
+ * location of the staged FolderDisplayHelpers.jsm module.
+ *
+ * @param {string} aPath - A path relative to the module (can be just a file name)
+ * @param {object} aScope - Scope to load the file into.
+ *
+ * @returns An object that serves as the global scope for the loaded file.
+ */
+function load_via_src_path(aPath, aScope) {
+ let thisFileURL = Cc["@mozilla.org/network/protocol;1?name=resource"]
+ .getService(Ci.nsIResProtocolHandler)
+ .resolveURI(
+ Services.io.newURI(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+ )
+ );
+ let thisFile = Services.io
+ .newURI(thisFileURL)
+ .QueryInterface(Ci.nsIFileURL).file;
+
+ thisFile.setRelativePath;
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.setRelativePath(thisFile, aPath);
+ // The files are at different paths when tests are run locally vs. CI.
+ // Plain js files shouldn't really be loaded from a module, but while we
+ // work on resolving that, try both locations...
+ if (!file.exists()) {
+ file.setRelativePath(thisFile, aPath.replace("/testing", ""));
+ }
+ if (!file.exists()) {
+ throw new Error(
+ `Could not resolve file ${file.path} for path ${aPath} relative to ${thisFile.path}`
+ );
+ }
+ let uri = Services.io.newFileURI(file).spec;
+ Services.scriptloader.loadSubScript(uri, aScope);
+}
diff --git a/comm/mail/test/browser/shared-modules/JunkHelpers.jsm b/comm/mail/test/browser/shared-modules/JunkHelpers.jsm
new file mode 100644
index 0000000000..6d53271ed5
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/JunkHelpers.jsm
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "mark_selected_messages_as_junk",
+ "delete_mail_marked_as_junk",
+];
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+var {
+ mc,
+ get_about_3pane,
+ plan_to_wait_for_folder_events,
+ wait_for_message_display_completion,
+ wait_for_folder_events,
+} = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+
+/**
+ * Mark the selected messages as junk. This is done by pressing the J key.
+ *
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+function mark_selected_messages_as_junk(aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+ let win = get_about_3pane(aController.window);
+ if (aController == mc) {
+ win.document.getElementById("threadTree").focus();
+ }
+ EventUtils.synthesizeKey("j", {}, win);
+}
+
+/**
+ * Delete all mail marked as junk in the selected folder. This is done by
+ * activating the menu option from the Tools menu.
+ *
+ * @param aNumDeletesExpected The number of deletes expected.
+ * @param aController The controller in whose context to do this, defaults to
+ * |mc| if omitted.
+ */
+async function delete_mail_marked_as_junk(aNumDeletesExpected, aController) {
+ if (aController === undefined) {
+ aController = mc;
+ }
+ let win = get_about_3pane(aController.window);
+
+ // Monkey patch and wrap around the deleteJunkInFolder function, mainly for
+ // the case where deletes aren't expected.
+ let realDeleteJunkInFolder = win.deleteJunkInFolder;
+ let numMessagesDeleted = null;
+ let fakeDeleteJunkInFolder = function () {
+ numMessagesDeleted = realDeleteJunkInFolder();
+ return numMessagesDeleted;
+ };
+ try {
+ win.deleteJunkInFolder = fakeDeleteJunkInFolder;
+
+ // If something is loading, make sure it finishes loading...
+ wait_for_message_display_completion(aController);
+ if (aNumDeletesExpected != 0) {
+ plan_to_wait_for_folder_events(
+ "DeleteOrMoveMsgCompleted",
+ "DeleteOrMoveMsgFailed"
+ );
+ }
+
+ win.goDoCommand("cmd_deleteJunk");
+
+ if (aNumDeletesExpected != 0) {
+ wait_for_folder_events();
+ }
+
+ // If timeout waiting for numMessagesDeleted to turn non-null,
+ // this either means that deleteJunkInFolder didn't get called or that it
+ // didn't return a value."
+
+ await TestUtils.waitForCondition(
+ () => numMessagesDeleted === aNumDeletesExpected,
+ `Should have got ${aNumDeletesExpected} deletes, not ${numMessagesDeleted}`
+ );
+ } finally {
+ win.deleteJunkInFolder = realDeleteJunkInFolder;
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm b/comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm
new file mode 100644
index 0000000000..e99c5bd484
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/KeyboardHelpers.jsm
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "input_value",
+ "delete_existing",
+ "delete_all_existing",
+];
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+/**
+ * Emulates manual input
+ *
+ * @param aController The window controller to input keypresses into
+ * @param aStr The string to input into the control element
+ * @param aElement (optional) Element on which to perform the input
+ */
+function input_value(aController, aStr, aElement) {
+ if (aElement) {
+ aElement.focus();
+ }
+ for (let i = 0; i < aStr.length; i++) {
+ EventUtils.synthesizeKey(aStr.charAt(i), {}, aController.window);
+ }
+}
+
+/**
+ * Emulates deleting strings via the keyboard
+ *
+ * @param aController The window controller to input keypresses into
+ * @param aElement The element in which to delete characters
+ * @param aNumber The number of times to press the delete key.
+ */
+function delete_existing(aController, aElement, aNumber) {
+ for (let i = 0; i < aNumber; ++i) {
+ aElement.focus();
+ EventUtils.synthesizeKey("VK_BACK_SPACE", {}, aController.window);
+ }
+}
+
+/**
+ * Emulates deleting the entire string by pressing Ctrl-A and DEL
+ *
+ * @param aController The window controller to input keypresses into
+ * @param aElement The element in which to delete characters
+ */
+function delete_all_existing(aController, aElement) {
+ aElement.focus();
+ EventUtils.synthesizeKey("a", { accelKey: true }, aController.window);
+ aElement.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, aController.window);
+}
diff --git a/comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm b/comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm
new file mode 100644
index 0000000000..3b4ed91e53
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/MockObjectHelpers.jsm
@@ -0,0 +1,161 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["MockObjectReplacer", "MockObjectRegisterer"];
+
+var Cm = Components.manager;
+
+function MockObjectRegisterer(aContractID, aCID, aComponent) {
+ this._contractID = aContractID;
+ this._cid = Components.ID("{" + aCID + "}");
+ this._component = aComponent;
+}
+
+MockObjectRegisterer.prototype = {
+ register() {
+ let providedConstructor = this._component;
+ this._mockFactory = {
+ createInstance(aIid) {
+ return new providedConstructor().QueryInterface(aIid);
+ },
+ };
+
+ let componentRegistrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+
+ componentRegistrar.registerFactory(
+ this._cid,
+ "",
+ this._contractID,
+ this._mockFactory
+ );
+ },
+
+ unregister() {
+ let componentRegistrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+
+ componentRegistrar.unregisterFactory(this._cid, this._mockFactory);
+ },
+};
+
+/**
+ * Allows registering a mock XPCOM component, that temporarily replaces the
+ * original one when an object implementing a given ContractID is requested
+ * using createInstance.
+ *
+ * @param aContractID
+ * The ContractID of the component to replace, for example
+ * "@mozilla.org/filepicker;1".
+ *
+ * @param aReplacementCtor
+ * The constructor function for the JavaScript object that will be
+ * created every time createInstance is called. This object must
+ * implement QueryInterface and provide the XPCOM interfaces required by
+ * the specified ContractID (for example
+ * Ci.nsIFilePicker).
+ */
+
+function MockObjectReplacer(aContractID, aReplacementCtor) {
+ this._contractID = aContractID;
+ this._replacementCtor = aReplacementCtor;
+ this._cid = null;
+}
+
+MockObjectReplacer.prototype = {
+ /**
+ * Replaces the current factory with one that returns a new mock object.
+ *
+ * After register() has been called, it is mandatory to call unregister() to
+ * restore the original component. Usually, you should use a try-catch block
+ * to ensure that unregister() is called.
+ */
+ register() {
+ if (this._cid) {
+ throw Error("Invalid object state when calling register()");
+ }
+
+ // Define a factory that creates a new object using the given constructor.
+ var providedConstructor = this._replacementCtor;
+ this._mockFactory = {
+ createInstance(aIid) {
+ return new providedConstructor().QueryInterface(aIid);
+ },
+ };
+
+ var retVal = swapFactoryRegistration(
+ this._cid,
+ this._originalCID,
+ this._contractID,
+ this._mockFactory
+ );
+ if ("error" in retVal) {
+ throw new Error("ERROR: " + retVal.error);
+ } else {
+ this._cid = retVal.cid;
+ this._originalCID = retVal.originalCID;
+ }
+ },
+
+ /**
+ * Restores the original factory.
+ */
+ unregister() {
+ if (!this._cid) {
+ throw Error("Invalid object state when calling unregister()");
+ }
+
+ // Free references to the mock factory.
+ swapFactoryRegistration(
+ this._cid,
+ this._originalCID,
+ this._contractID,
+ this._mockFactory
+ );
+
+ // Allow registering a mock factory again later.
+ this._cid = null;
+ this._originalCID = null;
+ this._mockFactory = null;
+ },
+
+ // --- Private methods and properties ---
+
+ /**
+ * The CID under which the mock contractID was registered.
+ */
+ _cid: null,
+
+ /**
+ * The nsIFactory that was automatically generated by this object.
+ */
+ _mockFactory: null,
+};
+
+/**
+ * Swiped from mozilla/testing/mochitest/tests/SimpleTest/specialpowersAPI.js
+ */
+function swapFactoryRegistration(CID, originalCID, contractID, newFactory) {
+ let componentRegistrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+
+ if (originalCID == null) {
+ if (contractID != null) {
+ originalCID = componentRegistrar.contractIDToCID(contractID);
+ void Cm.getClassObject(Cc[contractID], Ci.nsIFactory);
+ } else {
+ return {
+ error: "trying to register a new contract ID: Missing contractID",
+ };
+ }
+ CID = Services.uuid.generateUUID();
+
+ componentRegistrar.registerFactory(CID, "", contractID, newFactory);
+ } else {
+ componentRegistrar.unregisterFactory(CID, newFactory);
+ // Restore the original factory.
+ componentRegistrar.registerFactory(originalCID, "", contractID, null);
+ }
+
+ return { cid: CID, originalCID };
+}
diff --git a/comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm b/comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm
new file mode 100644
index 0000000000..cd0f9a09d4
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/MouseEventHelpers.jsm
@@ -0,0 +1,226 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "drag_n_drop_element",
+ "synthesize_drag_start",
+ "synthesize_drag_over",
+ "synthesize_drag_end",
+ "synthesize_drop",
+];
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+
+/**
+ * Execute a drag and drop session.
+ *
+ * @param {XULElement} aDragObject
+ * the element from which the drag session should be started.
+ * @param {} aDragWindow
+ * the window the aDragObject is in
+ * @param {XULElement} aDropObject
+ * the element at which the drag session should be ended.
+ * @param {} aDropWindow
+ * the window the aDropObject is in
+ * @param {} aRelDropX
+ * the relative x-position the element is dropped over the aDropObject
+ * in percent of the aDropObject width
+ * @param {} aRelDropY
+ * the relative y-position the element is dropped over the aDropObject
+ * in percent of the aDropObject height
+ * @param {XULElement} aListener
+ * the element who's drop target should be captured and returned.
+ */
+function drag_n_drop_element(
+ aDragObject,
+ aDragWindow,
+ aDropObject,
+ aDropWindow,
+ aRelDropX,
+ aRelDropY,
+ aListener
+) {
+ let dt = synthesize_drag_start(aDragWindow, aDragObject, aListener);
+ Assert.ok(dt, "Drag data transfer was undefined");
+
+ synthesize_drag_over(aDropWindow, aDropObject, dt);
+
+ let dropRect = aDropObject.getBoundingClientRect();
+ synthesize_drop(aDropWindow, aDropObject, dt, {
+ screenX: aDropObject.screenX + dropRect.width * aRelDropX,
+ screenY: aDropObject.screenY + dropRect.height * aRelDropY,
+ });
+}
+
+/**
+ * Starts a drag new session.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {XULElement} aListener
+ * the element who's drop target should be captured and returned.
+ * @returns {nsIDataTransfer}
+ * returns the DataTransfer Object of captured by aListener.
+ */
+function synthesize_drag_start(aWindow, aDispatcher, aListener) {
+ let dt;
+
+ let trapDrag = function (event) {
+ if (!event.dataTransfer) {
+ throw new Error("no DataTransfer");
+ }
+
+ dt = event.dataTransfer;
+
+ event.preventDefault();
+ };
+
+ aListener.addEventListener("dragstart", trapDrag, true);
+
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mousedown" }, aWindow);
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 10,
+ { type: "mousemove" },
+ aWindow
+ );
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 15,
+ { type: "mousemove" },
+ aWindow
+ );
+
+ aListener.removeEventListener("dragstart", trapDrag, true);
+
+ return dt;
+}
+
+/**
+ * Synthesizes a drag over event.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function synthesize_drag_over(aWindow, aDispatcher, aDt, aArgs) {
+ _synthesizeDragEvent("dragover", aWindow, aDispatcher, aDt, aArgs);
+}
+
+/**
+ * Synthesizes a drag end event.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function synthesize_drag_end(aWindow, aDispatcher, aListener, aDt, aArgs) {
+ _synthesizeDragEvent("dragend", aWindow, aListener, aDt, aArgs);
+
+ // Ensure drag has ended.
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mousemove" }, aWindow);
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 10,
+ { type: "mousemove" },
+ aWindow
+ );
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mouseup" }, aWindow);
+}
+
+/**
+ * Synthesizes a drop event.
+ *
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function synthesize_drop(aWindow, aDispatcher, aDt, aArgs) {
+ _synthesizeDragEvent("drop", aWindow, aDispatcher, aDt, aArgs);
+
+ // Ensure drag has ended.
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mousemove" }, aWindow);
+ EventUtils.synthesizeMouse(
+ aDispatcher,
+ 5,
+ 10,
+ { type: "mousemove" },
+ aWindow
+ );
+ EventUtils.synthesizeMouse(aDispatcher, 5, 5, { type: "mouseup" }, aWindow);
+}
+
+/**
+ * Private function: Synthesizes a specified drag event.
+ *
+ * @param {} aType
+ * the type of the drag event to be synthesiyzed.
+ * @param {} aWindow
+ * @param {XULElement} aDispatcher
+ * the element from which the drag session should be started.
+ * @param {nsIDataTransfer} aDt
+ * the DataTransfer Object of captured by listener.
+ * @param {} aArgs
+ * arguments passed to the mouse event.
+ */
+function _synthesizeDragEvent(aType, aWindow, aDispatcher, aDt, aArgs) {
+ let screenX;
+ if (aArgs && "screenX" in aArgs) {
+ screenX = aArgs.screenX;
+ } else {
+ screenX = aDispatcher.screenX;
+ }
+
+ let screenY;
+ if (aArgs && "screenY" in aArgs) {
+ screenY = aArgs.screenY;
+ } else {
+ screenY = aDispatcher.screenY;
+ }
+
+ let event = aWindow.document.createEvent("DragEvent");
+ event.initDragEvent(
+ aType,
+ true,
+ true,
+ aWindow,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ null,
+ aDt
+ );
+ aDispatcher.dispatchEvent(event);
+}
diff --git a/comm/mail/test/browser/shared-modules/NNTPHelpers.jsm b/comm/mail/test/browser/shared-modules/NNTPHelpers.jsm
new file mode 100644
index 0000000000..71c785c02c
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/NNTPHelpers.jsm
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "setupNNTPDaemon",
+ "NNTP_PORT",
+ "setupLocalServer",
+ "startupNNTPServer",
+ "shutdownNNTPServer",
+];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { NewsArticle, NNTP_RFC977_handler, NntpDaemon } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Nntpd.jsm"
+);
+var { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+
+var kSimpleNewsArticle =
+ "From: John Doe <john.doe@example.com>\n" +
+ "Date: Sat, 24 Mar 1990 10:59:24 -0500\n" +
+ "Newsgroups: test.subscribe.simple\n" +
+ "Subject: H2G2 -- What does it mean?\n" +
+ "Message-ID: <TSS1@nntp.invalid>\n" +
+ "\n" +
+ "What does the acronym H2G2 stand for? I've seen it before...\n";
+
+// The groups to set up on the fake server.
+// It is an array of tuples, where the first element is the group name and the
+// second element is whether or not we should subscribe to it.
+var groups = [
+ ["test.empty", false],
+ ["test.subscribe.empty", true],
+ ["test.subscribe.simple", true],
+ ["test.filter", true],
+];
+
+// Sets up the NNTP daemon object for use in fake server
+function setupNNTPDaemon() {
+ var daemon = new NntpDaemon();
+
+ groups.forEach(function (element) {
+ daemon.addGroup(element[0]);
+ });
+
+ var article = new NewsArticle(kSimpleNewsArticle);
+ daemon.addArticleToGroup(article, "test.subscribe.simple", 1);
+
+ return daemon;
+}
+
+// Startup server
+function startupNNTPServer(daemon, port) {
+ var handler = NNTP_RFC977_handler;
+
+ function createHandler(daemon) {
+ return new handler(daemon);
+ }
+
+ var server = new nsMailServer(createHandler, daemon);
+ server.start(port);
+ return server;
+}
+
+// Shutdown server
+function shutdownNNTPServer(server) {
+ server.stop();
+}
+
+// Enable strict threading
+Services.prefs.setBoolPref("mail.strict_threading", true);
+
+// Make sure we don't try to use a protected port. I like adding 1024 to the
+// default port when doing so...
+var NNTP_PORT = 1024 + 119;
+
+var _server = null;
+
+function subscribeServer(incomingServer) {
+ // Subscribe to newsgroups
+ incomingServer.QueryInterface(Ci.nsINntpIncomingServer);
+ groups.forEach(function (element) {
+ if (element[1]) {
+ incomingServer.subscribeToNewsgroup(element[0]);
+ }
+ });
+ // Only allow one connection
+ incomingServer.maximumConnectionsNumber = 1;
+}
+
+// Sets up the client-side portion of fakeserver
+function setupLocalServer(port) {
+ if (_server != null) {
+ return _server;
+ }
+
+ var server = MailServices.accounts.createIncomingServer(
+ null,
+ "localhost",
+ "nntp"
+ );
+ server.port = port;
+ server.valid = false;
+
+ var account = MailServices.accounts.createAccount();
+ account.incomingServer = server;
+ server.valid = true;
+ // hack to cause an account loaded notification now the server is valid
+ // (see also Bug 903804)
+ account.incomingServer = account.incomingServer; // eslint-disable-line no-self-assign
+
+ subscribeServer(server);
+
+ _server = server;
+
+ return server;
+}
diff --git a/comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm b/comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm
new file mode 100644
index 0000000000..e9992fa344
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/NewMailAccountHelpers.jsm
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["remove_email_account"];
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * Remove an account with the address from the current profile.
+ *
+ * @param {string} address - The email address to try to remove.
+ */
+function remove_email_account(address) {
+ for (let account of MailServices.accounts.accounts) {
+ if (account.defaultIdentity && account.defaultIdentity.email == address) {
+ MailServices.accounts.removeAccount(account);
+ break;
+ }
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm b/comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm
new file mode 100644
index 0000000000..14a853e001
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/NotificationBoxHelpers.jsm
@@ -0,0 +1,219 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "check_notification_displayed",
+ "assert_notification_displayed",
+ "close_notification",
+ "wait_for_notification_to_stop",
+ "wait_for_notification_to_show",
+ "get_notification_button",
+ "get_notification",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+/**
+ * A helper function for determining whether or not a notification with
+ * a particular value is being displayed.
+ *
+ * @param aWindow the window to check
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to look for
+ * @param aNotification an optional out parameter: object that will pass the
+ * notification element out of this function in its
+ * 'notification' property
+ *
+ * @returns True/false depending on the state of the notification.
+ */
+function check_notification_displayed(aWindow, aBoxId, aValue, aNotification) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ if (nb.querySelector(".notificationbox-stack")) {
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ let notification = box.getNotificationWithValue(aValue);
+ if (aNotification) {
+ aNotification.notification = notification;
+ }
+ return notification != null;
+ }
+
+ return false;
+}
+
+/**
+ * A helper function ensuring whether or not a notification with
+ * a particular value is being displayed. Throws if the state is
+ * not the expected one.
+ *
+ * @param aWindow the window to check
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to look for
+ * @param aDisplayed true if the notification should be displayed, false
+ * otherwise
+ * @returns the notification if we're asserting that the notification is
+ * displayed, and it actually shows up. Throws otherwise.
+ */
+function assert_notification_displayed(aWindow, aBoxId, aValue, aDisplayed) {
+ let notification = {};
+ let hasNotification = check_notification_displayed(
+ aWindow,
+ aBoxId,
+ aValue,
+ notification
+ );
+ if (hasNotification != aDisplayed) {
+ throw new Error(
+ "Expected the notification with value " +
+ aValue +
+ " to " +
+ (aDisplayed ? "be shown" : "not be shown")
+ );
+ }
+
+ return notification.notification;
+}
+
+/**
+ * A helper function for closing a notification if one is currently displayed
+ * in the window.
+ *
+ * @param aWindow the window with the notification
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to close
+ */
+function close_notification(aWindow, aBoxId, aValue) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ let notification = box.getNotificationWithValue(aValue);
+ if (notification) {
+ notification.close();
+ }
+}
+
+/**
+ * A helper function that waits for a notification with value aValue
+ * to stop displaying in the window.
+ *
+ * @param aWindow the window with the notification
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to wait to stop
+ */
+function wait_for_notification_to_stop(aWindow, aBoxId, aValue) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ utils.waitFor(
+ () => !box.getNotificationWithValue(aValue),
+ "Timed out waiting for notification with value " + aValue + " to stop."
+ );
+}
+
+/**
+ * A helper function that waits for a notification with value aValue
+ * to show in the window.
+ *
+ * @param aWindow the window that we want the notification to appear in
+ * @param aBoxId the id of the notification box
+ * @param aValue the value of the notification to wait for
+ */
+function wait_for_notification_to_show(aWindow, aBoxId, aValue) {
+ let nb = aWindow.document.getElementById(aBoxId);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + aBoxId);
+ }
+
+ function nbReady() {
+ if (nb.querySelector(".notificationbox-stack")) {
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ return box.getNotificationWithValue(aValue) != null && !box._animating;
+ }
+ return false;
+ }
+ utils.waitFor(
+ nbReady,
+ "Timed out waiting for notification with value " + aValue + " to show."
+ );
+}
+
+/**
+ * Return the notification element based on the container ID and the Value type.
+ *
+ * @param {Window} win - The window that we want the notification to appear in.
+ * @param {string} id - The id of the notification box.
+ * @param {string} val - The value of the notification to fetch.
+ * @returns {?Element} - The notification element if found.
+ */
+function get_notification(win, id, val) {
+ let nb = win.document.getElementById(id);
+ if (!nb) {
+ throw new Error("Couldn't find a notification box for id=" + id);
+ }
+
+ if (nb.querySelector(".notificationbox-stack")) {
+ let box = nb.querySelector(".notificationbox-stack")._notificationBox;
+ return box.getNotificationWithValue(val);
+ }
+
+ return null;
+}
+
+/**
+ * Gets a button in a notification, as those do not have IDs.
+ *
+ * @param aWindow The window that has the notification.
+ * @param aBoxId The id of the notification box.
+ * @param aValue The value of the notification to find.
+ * @param aMatch Attributes of the button to find. An object with key:value
+ * pairs, similar to click_menus_in_sequence().
+ */
+function get_notification_button(aWindow, aBoxId, aValue, aMatch) {
+ let notification = get_notification(aWindow, aBoxId, aValue);
+ let buttons = notification.buttonContainer.querySelectorAll(
+ "button, toolbarbutton"
+ );
+ for (let button of buttons) {
+ let matchedAll = true;
+ for (let name in aMatch) {
+ let value = aMatch[name];
+ let matched = false;
+ if (name == "popup") {
+ if (button.getAttribute("type") == "menu") {
+ // The button contains a menupopup as the first child.
+ matched = button.querySelector("menupopup#" + value);
+ } else {
+ // The "popup" attribute is not on the button itself but in its
+ // buttonInfo member.
+ matched = "buttonInfo" in button && button.buttonInfo.popup == value;
+ }
+ } else if (
+ button.hasAttribute(name) &&
+ button.getAttribute(name) == value
+ ) {
+ matched = true;
+ }
+ if (!matched) {
+ matchedAll = false;
+ break;
+ }
+ }
+ if (matchedAll) {
+ return button;
+ }
+ }
+
+ throw new Error("Couldn't find the requested button on a notification");
+}
diff --git a/comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm b/comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm
new file mode 100644
index 0000000000..5913778590
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/OpenPGPTestUtils.jsm
@@ -0,0 +1,329 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["OpenPGPTestUtils"];
+
+const { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+const EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ OpenPGPAlias: "chrome://openpgp/content/modules/OpenPGPAlias.jsm",
+ EnigmailCore: "chrome://openpgp/content/modules/core.jsm",
+ EnigmailFuncs: "chrome://openpgp/content/modules/funcs.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+ RNP: "chrome://openpgp/content/modules/RNP.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+});
+
+const OpenPGPTestUtils = {
+ ACCEPTANCE_PERSONAL: "personal",
+ ACCEPTANCE_REJECTED: "rejected",
+ ACCEPTANCE_UNVERIFIED: "unverified",
+ ACCEPTANCE_VERIFIED: "verified",
+ ACCEPTANCE_UNDECIDED: "undecided",
+ ALICE_KEY_ID: "F231550C4F47E38E",
+ BOB_KEY_ID: "FBFCC82A015E7330",
+ CAROL_KEY_ID: "3099FF1238852B9F",
+
+ /**
+ * Given a compose message window, clicks on the "Digitally Sign This Message"
+ * menu item.
+ */
+ async toggleMessageSigning(win) {
+ return clickToolbarButtonMenuItem(win, "#button-encryption-options", [
+ "#menu_securitySign_Toolbar",
+ ]);
+ },
+
+ /**
+ * Given a compose message window, clicks on the "Encrypt Subject"
+ * menu item.
+ */
+ async toggleMessageEncryptSubject(win) {
+ return clickToolbarButtonMenuItem(win, "#button-encryption-options", [
+ "#menu_securityEncryptSubject_Toolbar",
+ ]);
+ },
+
+ /**
+ * Given a compose message window, clicks on the "Attach My Public Key"
+ * menu item.
+ */
+ async toggleMessageKeyAttachment(win) {
+ return clickToolbarButtonMenuItem(win, "#button-attach", [
+ "#button-attachPopup_attachPublicKey",
+ ]);
+ },
+
+ /**
+ * Given a compose message window, clicks on the "Require Encryption"
+ * menu item.
+ */
+ async toggleMessageEncryption(win) {
+ // Note: doing it through #menu_securityEncryptRequire_Menubar won't work on
+ // mac since the native menu bar can't be clicked.
+ // Use the toolbar button to click Require encryption.
+ await clickToolbarButtonMenuItem(win, "#button-encryption-options", [
+ "#menu_securityEncrypt_Toolbar",
+ ]);
+ },
+
+ /**
+ * For xpcshell-tests OpenPGP is not initialized automatically. This method
+ * should be called at the start of testing.
+ */
+ async initOpenPGP() {
+ Assert.ok(await lazy.RNP.init(), "librnp did load");
+ Assert.ok(await lazy.EnigmailCore.getService({}), "EnigmailCore did load");
+ lazy.EnigmailKeyRing.init();
+ await lazy.OpenPGPAlias.load();
+ },
+
+ /**
+ * Tests whether the signed icon's "src" attribute matches the provided state.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @param {"ok"|"unknown"|"verified"|"unverified"|"mismatch"} state - The
+ * state to test for.
+ * @returns {boolean}
+ */
+ hasSignedIconState(doc, state) {
+ return !!doc.querySelector(`#signedHdrIcon[src*=message-signed-${state}]`);
+ },
+
+ /**
+ * Checks that the signed icon is hidden.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @returns {boolean}
+ */
+ hasNoSignedIconState(doc) {
+ return !!doc.querySelector(`#signedHdrIcon[hidden]`);
+ },
+
+ /**
+ * Checks that the encrypted icon is hidden.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @returns {boolean}
+ */
+ hasNoEncryptedIconState(doc) {
+ return !!doc.querySelector(`#encryptedHdrIcon[hidden]`);
+ },
+
+ /**
+ * Tests whether the encrypted icon's "src" attribute matches the provided
+ * state value.
+ *
+ * @param {HTMLDocument} doc - The document of the message window.
+ * @param {"ok"|"notok"} state - The state to test for.
+ * @returns {boolean}
+ */
+ hasEncryptedIconState(doc, state) {
+ return !!doc.querySelector(
+ `#encryptedHdrIcon[src*=message-encrypted-${state}]`
+ );
+ },
+
+ /**
+ * Imports a public key into the keyring while also updating its acceptance.
+ *
+ * @param {nsIWindow} parent - The parent window.
+ * @param {nsIFile} file - A valid file containing a public OpenPGP key.
+ * @param {string} [acceptance] - The acceptance setting for the key.
+ * @returns {string[]} - List of imported key ids.
+ */
+ async importPublicKey(
+ parent,
+ file,
+ acceptance = OpenPGPTestUtils.ACCEPTANCE_VERIFIED
+ ) {
+ let ids = await OpenPGPTestUtils.importKey(parent, file, false);
+ if (!ids.length) {
+ throw new Error(`No public key imported from ${file.leafName}`);
+ }
+ return OpenPGPTestUtils.updateKeyIdAcceptance(ids, acceptance);
+ },
+
+ /**
+ * Imports a private key into the keyring while also updating its acceptance.
+ *
+ * @param {nsIWindow} parent - The parent window.
+ * @param {nsIFile} file - A valid file containing a private OpenPGP key.
+ * @param {string} [acceptance] - The acceptance setting for the key.
+ * @param {string} [passphrase] - The passphrase string that is required
+ * for unlocking the imported private key, or null, if no passphrase
+ * is necessary. The existing passphrase protection is kept.
+ * @param {boolean} [keepPassphrase] - true for keeping the existing
+ * passphrase. False for removing the existing passphrase and to
+ * set the automatic protection. If parameter passphrase is null
+ * then parameter keepPassphrase is ignored.
+ * @returns {string[]} - List of imported key ids.
+ */
+ async importPrivateKey(
+ parent,
+ file,
+ acceptance = OpenPGPTestUtils.ACCEPTANCE_PERSONAL,
+ passphrase = null,
+ keepPassphrase = false
+ ) {
+ let data = await IOUtils.read(file.path);
+ let pgpBlock = lazy.MailStringUtils.uint8ArrayToByteString(data);
+
+ function localPassphraseProvider(win, promptString, resultFlags) {
+ resultFlags.canceled = false;
+ return passphrase;
+ }
+
+ if (passphrase != null && keepPassphrase == undefined) {
+ throw new Error(
+ "must provide true of false for parameter keepPassphrase"
+ );
+ }
+
+ let result = await lazy.RNP.importSecKeyBlockImpl(
+ parent,
+ localPassphraseProvider,
+ passphrase != null && keepPassphrase,
+ pgpBlock,
+ false,
+ []
+ );
+
+ if (!result || result.exitCode !== 0) {
+ throw new Error(
+ `EnigmailKeyRing.importKey failed with result "${result.errorMsg}"!`
+ );
+ }
+ if (!result.importedKeys || !result.importedKeys.length) {
+ throw new Error(`No private key imported from ${file.leafName}`);
+ }
+
+ lazy.EnigmailKeyRing.updateKeys(result.importedKeys);
+ lazy.EnigmailKeyRing.clearCache();
+ return OpenPGPTestUtils.updateKeyIdAcceptance(
+ result.importedKeys.slice(),
+ acceptance
+ );
+ },
+
+ /**
+ * Imports a key into the keyring.
+ *
+ * @param {nsIWindow} parent - The parent window.
+ * @param {nsIFile} file - A valid file containing an OpenPGP key.
+ * @param {boolean} [isBinary] - false for ASCII armored files
+ * @returns {Promise<string[]>} - A list of ids for the key(s) imported.
+ */
+ async importKey(parent, file, isBinary) {
+ let data = await IOUtils.read(file.path);
+ let txt = lazy.MailStringUtils.uint8ArrayToByteString(data);
+ let errorObj = {};
+ let fingerPrintObj = {};
+
+ let result = lazy.EnigmailKeyRing.importKey(
+ parent,
+ false,
+ txt,
+ isBinary,
+ null,
+ errorObj,
+ fingerPrintObj,
+ false,
+ [],
+ false
+ );
+
+ if (result !== 0) {
+ console.debug(
+ `EnigmailKeyRing.importKey failed with result "${result}"!`
+ );
+ return [];
+ }
+ return fingerPrintObj.value.slice();
+ },
+
+ /**
+ * Updates the acceptance value of the provided key(s) in the database.
+ *
+ * @param {string|string[]} id - The id or list of ids to update.
+ * @param {string} acceptance - The new acceptance level for the key id.
+ * @returns {string[]} - A list of the key ids processed.
+ */
+ async updateKeyIdAcceptance(id, acceptance) {
+ let ids = Array.isArray(id) ? id : [id];
+ for (let id of ids) {
+ let key = lazy.EnigmailKeyRing.getKeyById(id);
+ let email = lazy.EnigmailFuncs.getEmailFromUserID(key.userId);
+ await lazy.PgpSqliteDb2.updateAcceptance(key.fpr, [email], acceptance);
+ }
+ lazy.EnigmailKeyRing.clearCache();
+ return ids.slice();
+ },
+
+ getProtectedKeysCount() {
+ return lazy.RNP.getProtectedKeysCount();
+ },
+
+ /**
+ * Removes a key by its id, clearing its acceptance and refreshing the
+ * cache.
+ *
+ * @param {string|string[]} id - The id or list of ids to remove.
+ * @param {boolean} [deleteSecret=false] - If true, secret keys will be removed too.
+ */
+ async removeKeyById(id, deleteSecret = false) {
+ let ids = Array.isArray(id) ? id : [id];
+ for (let id of ids) {
+ let key = lazy.EnigmailKeyRing.getKeyById(id);
+ await lazy.RNP.deleteKey(key.fpr, deleteSecret);
+ await lazy.PgpSqliteDb2.deleteAcceptance(key.fpr);
+ }
+ lazy.EnigmailKeyRing.clearCache();
+ },
+};
+
+/**
+ * Click a toolbar button to make it show the dropdown. Then click one of
+ * the menuitems in that popup.
+ */
+async function clickToolbarButtonMenuItem(
+ win,
+ buttonSelector,
+ menuitemSelectors
+) {
+ let menupopup = win.document.querySelector(`${buttonSelector} > menupopup`);
+ let popupshown = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ win.document.querySelector(`${buttonSelector} > dropmarker`),
+ {},
+ win
+ );
+ await popupshown;
+
+ if (menuitemSelectors.length > 1) {
+ let submenuSelector = menuitemSelectors.shift();
+ menupopup.querySelector(submenuSelector).openMenu(true);
+ }
+
+ let popuphidden = BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ menupopup.activateItem(win.document.querySelector(menuitemSelectors[0]));
+ await popuphidden;
+}
diff --git a/comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm b/comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm
new file mode 100644
index 0000000000..9f56fd2c25
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/PrefTabHelpers.jsm
@@ -0,0 +1,53 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/*
+ * Helpers to deal with the preferences tab.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["close_pref_tab", "open_pref_tab"];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var fdh = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var cth = ChromeUtils.import(
+ "resource://testing-common/mozmill/ContentTabHelpers.jsm"
+);
+
+/**
+ * Open the preferences tab with the given pane displayed. The pane needs to
+ * be one of the prefpane ids in mail/components/preferences/preferences.xhtml.
+ *
+ * @param aPaneID The ID of the pref pane to display (see
+ * mail/components/preferences/preferences.xhtml for valid IDs.)
+ */
+function open_pref_tab(aPaneID, aScrollTo) {
+ let tab = cth.open_content_tab_with_click(
+ function () {
+ fdh.mc.window.openOptionsDialog(aPaneID, aScrollTo);
+ },
+ "about:preferences",
+ fdh.mc,
+ "preferencesTab"
+ );
+ utils.waitFor(
+ () => tab.browser.contentWindow.gLastCategory.category == aPaneID,
+ "Timed out waiting for prefpane " + aPaneID + " to load."
+ );
+ return tab;
+}
+
+/**
+ * Close the preferences tab.
+ *
+ * @param aTab The content tab to close.
+ */
+function close_pref_tab(aTab) {
+ fdh.mc.window.document.getElementById("tabmail").closeTab(aTab);
+}
diff --git a/comm/mail/test/browser/shared-modules/PromptHelpers.jsm b/comm/mail/test/browser/shared-modules/PromptHelpers.jsm
new file mode 100644
index 0000000000..ca3eaac3b0
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/PromptHelpers.jsm
@@ -0,0 +1,271 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "gMockPromptService",
+ "gMockAuthPromptReg",
+ "gMockAuthPrompt",
+];
+
+var { MockObjectReplacer } = ChromeUtils.import(
+ "resource://testing-common/mozmill/MockObjectHelpers.jsm"
+);
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var kMockPromptServiceName = "Mock Prompt Service";
+var kPromptServiceContractID = "@mozilla.org/prompter;1";
+var kPromptServiceName = "Prompt Service";
+
+var gMockAuthPromptReg = new MockObjectReplacer(
+ "@mozilla.org/prompter;1",
+ MockAuthPromptFactoryConstructor
+);
+
+function MockAuthPromptFactoryConstructor() {
+ return gMockAuthPromptFactory;
+}
+
+var gMockAuthPromptFactory = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptFactory"]),
+ getPrompt(aParent, aIID, aResult) {
+ return gMockAuthPrompt.QueryInterface(aIID);
+ },
+};
+
+var gMockAuthPrompt = {
+ password: "",
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAuthPrompt"]),
+
+ prompt(aTitle, aText, aRealm, aSave, aDefaultText) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ promptUsernameAndPassword(aTitle, aText, aRealm, aSave, aUser, aPwd) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ promptPassword(aTitle, aText, aRealm, aSave, aPwd) {
+ aPwd.value = this.password;
+ return true;
+ },
+};
+
+var gMockPromptService = {
+ _registered: false,
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ _will_return: null,
+ _inout_value: null,
+ _promptState: null,
+ _origFactory: null,
+ _promptCb: null,
+
+ alert(aParent, aDialogTitle, aText) {
+ this._promptState = {
+ method: "alert",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ };
+ },
+
+ confirm(aParent, aDialogTitle, aText) {
+ this._promptState = {
+ method: "confirm",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ };
+
+ this.fireCb();
+
+ return this._will_return;
+ },
+
+ confirmCheck(aParent, aDialogTitle, aText) {
+ this._promptState = {
+ method: "confirmCheck",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ };
+
+ this.fireCb();
+
+ return this._will_return;
+ },
+
+ confirmEx(
+ aParent,
+ aDialogTitle,
+ aText,
+ aButtonFlags,
+ aButton0Title,
+ aButton1Title,
+ aButton2Title,
+ aCheckMsg,
+ aCheckState
+ ) {
+ this._promptState = {
+ method: "confirmEx",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ buttonFlags: aButtonFlags,
+ button0Title: aButton0Title,
+ button1Title: aButton1Title,
+ button2Title: aButton2Title,
+ checkMsg: aCheckMsg,
+ checkState: aCheckState,
+ };
+
+ this.fireCb();
+
+ return this._will_return;
+ },
+
+ prompt(aParent, aDialogTitle, aText, aValue, aCheckMsg, aCheckState) {
+ this._promptState = {
+ method: "prompt",
+ parent: aParent,
+ dialogTitle: aDialogTitle,
+ text: aText,
+ value: aValue,
+ checkMsg: aCheckMsg,
+ checkState: aCheckState,
+ };
+
+ this.fireCb();
+
+ if (this._inout_value != null) {
+ aValue.value = this._inout_value;
+ }
+
+ return this._will_return;
+ },
+
+ // Other dialogs should probably be mocked here, including alert,
+ // alertCheck, etc.
+ // See: http://mxr.mozilla.org/mozilla-central/source/embedding/components/
+ // windowwatcher/public/nsIPromptService.idl
+
+ /* Sets the value that the alert, confirm, etc dialog will return to
+ * the caller.
+ */
+ set returnValue(aReturn) {
+ this._will_return = aReturn;
+ },
+
+ set inoutValue(aValue) {
+ this._inout_value = aValue;
+ },
+
+ set onPromptCallback(aCb) {
+ this._promptCb = aCb;
+ },
+
+ promisePrompt() {
+ return new Promise(resolve => {
+ this.onPromptCallback = resolve;
+ });
+ },
+
+ fireCb() {
+ if (typeof this._promptCb == "function") {
+ this._promptCb.call();
+ }
+ },
+
+ /* Wipes out the prompt state and any return values.
+ */
+ reset() {
+ this._will_return = null;
+ this._promptState = null;
+ this._promptCb = null;
+ this._inout_value = null;
+ },
+
+ /* Returns the prompt state if one was observed since registering
+ * the Mock Prompt Service.
+ */
+ get promptState() {
+ return this._promptState;
+ },
+
+ CID: Components.ID("{404ebfa2-d8f4-4c94-8416-e65a55f9df5b}"),
+
+ get registrar() {
+ delete this.registrar;
+ return (this.registrar = Components.manager.QueryInterface(
+ Ci.nsIComponentRegistrar
+ ));
+ },
+
+ /* Registers the Mock Prompt Service, and stores the original Prompt Service.
+ */
+ register() {
+ if (!this.originalCID) {
+ void Components.manager.getClassObject(
+ Cc[kPromptServiceContractID],
+ Ci.nsIFactory
+ );
+
+ this.originalCID = this.registrar.contractIDToCID(
+ kPromptServiceContractID
+ );
+ this.registrar.registerFactory(
+ this.CID,
+ kMockPromptServiceName,
+ kPromptServiceContractID,
+ gMockPromptServiceFactory
+ );
+ this._resetServicesPrompt();
+ }
+ },
+
+ /* Unregisters the Mock Prompt Service, and re-registers the original
+ * Prompt Service.
+ */
+ unregister() {
+ if (this.originalCID) {
+ // Unregister the mock.
+ this.registrar.unregisterFactory(this.CID, gMockPromptServiceFactory);
+
+ this.registrar.registerFactory(
+ this.originalCID,
+ kPromptServiceName,
+ kPromptServiceContractID,
+ null
+ );
+
+ delete this.originalCID;
+ this._resetServicesPrompt();
+ }
+ },
+
+ _resetServicesPrompt() {
+ // eslint-disable-next-line mozilla/use-services
+ XPCOMUtils.defineLazyServiceGetter(
+ Services,
+ "prompt",
+ kPromptServiceContractID,
+ "nsIPromptService"
+ );
+ },
+};
+
+var gMockPromptServiceFactory = {
+ createInstance(aIID) {
+ if (!aIID.equals(Ci.nsIPromptService)) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ return gMockPromptService;
+ },
+};
diff --git a/comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm b/comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm
new file mode 100644
index 0000000000..04dee48e09
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/QuickFilterBarHelpers.jsm
@@ -0,0 +1,391 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "assert_quick_filter_button_enabled",
+ "assert_quick_filter_bar_visible",
+ "toggle_quick_filter_bar",
+ "assert_constraints_expressed",
+ "toggle_boolean_constraints",
+ "toggle_tag_constraints",
+ "toggle_tag_mode",
+ "assert_tag_constraints_visible",
+ "assert_tag_constraints_checked",
+ "toggle_text_constraints",
+ "assert_text_constraints_checked",
+ "set_filter_text",
+ "assert_filter_text",
+ "assert_results_label_count",
+ "clear_constraints",
+ "cleanup_qfb_button",
+];
+
+var { get_about_3pane, mc, wait_for_all_messages_to_load } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+
+const { getState, storeState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+
+const { getDefaultItemIdsForSpace } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableItems.sys.mjs"
+);
+
+let about3Pane = get_about_3pane();
+about3Pane.quickFilterBar.deferredUpdateSearch =
+ about3Pane.quickFilterBar.updateSearch;
+
+/**
+ * Maps names to bar DOM ids to simplify checking.
+ */
+var nameToBarDomId = {
+ sticky: "qfb-sticky",
+ unread: "qfb-unread",
+ starred: "qfb-starred",
+ addrbook: "qfb-inaddrbook",
+ tags: "qfb-tags",
+ attachments: "qfb-attachment",
+};
+
+async function ensure_qfb_unified_toolbar_button() {
+ const document = mc.window.document;
+
+ const state = getState();
+ if (state.mail?.includes("quick-filter-bar")) {
+ return;
+ }
+ if (!state.mail) {
+ state.mail = getDefaultItemIdsForSpace("mail");
+ if (state.mail.includes("quick-filter-bar")) {
+ return;
+ }
+ }
+ state.mail.push("quick-filter-bar");
+ storeState(state);
+ await BrowserTestUtils.waitForMutationCondition(
+ document.getElementById("unifiedToolbarContent"),
+ {
+ subtree: true,
+ childList: true,
+ },
+ () =>
+ document.querySelector("#unifiedToolbarContent .quick-filter-bar button")
+ );
+}
+
+async function cleanup_qfb_button() {
+ const document = mc.window.document;
+ const state = getState();
+ if (!state.mail?.includes("quick-filter-bar")) {
+ return;
+ }
+ state.mail = getDefaultItemIdsForSpace("mail");
+ storeState(state);
+ await BrowserTestUtils.waitForMutationCondition(
+ document.getElementById("unifiedToolbarContent"),
+ {
+ subtree: true,
+ childList: true,
+ },
+ () => !document.querySelector("#unifiedToolbarContent .quick-filter-bar")
+ );
+}
+
+async function assert_quick_filter_button_enabled(aEnabled) {
+ await ensure_qfb_unified_toolbar_button();
+ if (
+ mc.window.document.querySelector(
+ "#unifiedToolbarContent .quick-filter-bar button"
+ ).disabled == aEnabled
+ ) {
+ throw new Error(
+ "Quick filter bar button should be " + (aEnabled ? "enabled" : "disabled")
+ );
+ }
+}
+
+function assert_quick_filter_bar_visible(aVisible) {
+ let bar = about3Pane.document.getElementById("quick-filter-bar");
+ if (aVisible) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(bar),
+ "Quick filter bar should be visible"
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(bar),
+ "Quick filter bar should be hidden"
+ );
+ }
+}
+
+/**
+ * Toggle the state of the message filter bar as if by a mouse click.
+ */
+async function toggle_quick_filter_bar() {
+ await ensure_qfb_unified_toolbar_button();
+ EventUtils.synthesizeMouseAtCenter(
+ mc.window.document.querySelector(
+ "#unifiedToolbarContent .quick-filter-bar"
+ ),
+ { clickCount: 1 },
+ mc.window
+ );
+ wait_for_all_messages_to_load();
+}
+
+/**
+ * Assert that the state of the constraints visually expressed by the bar is
+ * consistent with the passed-in constraints. This method does not verify
+ * that the search constraints are in effect. Check that elsewhere.
+ */
+function assert_constraints_expressed(aConstraints) {
+ for (let name in nameToBarDomId) {
+ let domId = nameToBarDomId[name];
+ let expectedValue = name in aConstraints ? aConstraints[name] : false;
+ let domNode = about3Pane.document.getElementById(domId);
+ Assert.equal(
+ domNode.pressed,
+ expectedValue,
+ name + "'s pressed state should be " + expectedValue
+ );
+ }
+}
+
+/**
+ * Toggle the given filter buttons by name (from nameToBarDomId); variable
+ * argument magic enabled.
+ */
+function toggle_boolean_constraints(...aArgs) {
+ aArgs.forEach(arg =>
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.document.getElementById(nameToBarDomId[arg]),
+ { clickCount: 1 },
+ about3Pane
+ )
+ );
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Toggle the tag faceting buttons by tag key. Wait for messages after.
+ */
+function toggle_tag_constraints(...aArgs) {
+ aArgs.forEach(function (arg) {
+ let tagId = "qfb-tag-" + arg;
+ let button = about3Pane.document.getElementById(tagId);
+ button.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, about3Pane);
+ });
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Set the tag filtering mode. Wait for messages after.
+ */
+function toggle_tag_mode() {
+ let qbm = about3Pane.document.getElementById("qfb-boolean-mode");
+ if (qbm.value === "AND") {
+ qbm.selectedIndex--; // = move to "OR";
+ Assert.equal(qbm.value, "OR", "qfb-boolean-mode has wrong state");
+ } else if (qbm.value === "OR") {
+ qbm.selectedIndex++; // = move to "AND";
+ Assert.equal(qbm.value, "AND", "qfb-boolean-mode has wrong state");
+ } else {
+ throw new Error("qfb-boolean-mode value=" + qbm.value);
+ }
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Verify that tag buttons exist for exactly the given set of tag keys in the
+ * provided variable argument list. Ordering is significant.
+ */
+function assert_tag_constraints_visible(...aArgs) {
+ // the stupid bar should be visible if any arguments are specified
+ let tagBar = get_about_3pane().document.getElementById(
+ "quickFilterBarTagsContainer"
+ );
+ if (aArgs.length > 0) {
+ Assert.ok(
+ BrowserTestUtils.is_visible(tagBar),
+ "The tag bar should not be collapsed!"
+ );
+ }
+
+ let kids = tagBar.children;
+ let tagLength = kids.length - 1; // -1 for the qfb-boolean-mode widget
+ // this is bad error reporting in here for now.
+ if (tagLength != aArgs.length) {
+ throw new Error(
+ "Mismatch in expected tag count and actual. " +
+ "Expected " +
+ aArgs.length +
+ " actual " +
+ tagLength
+ );
+ }
+ for (let iArg = 0; iArg < aArgs.length; iArg++) {
+ let nodeId = "qfb-tag-" + aArgs[iArg];
+ if (nodeId != kids[iArg + 1].id) {
+ throw new Error(
+ "Mismatch at tag " +
+ iArg +
+ " expected " +
+ nodeId +
+ " but got " +
+ kids[iArg + 1].id
+ );
+ }
+ }
+}
+
+/**
+ * Verify that only the buttons corresponding to the provided tag keys are
+ * checked.
+ */
+function assert_tag_constraints_checked(...aArgs) {
+ let expected = {};
+ for (let arg of aArgs) {
+ let nodeId = "qfb-tag-" + arg;
+ expected[nodeId] = true;
+ }
+
+ let kids = mc.window.document.getElementById(
+ "quickFilterBarTagsContainer"
+ ).children;
+ for (let iNode = 0; iNode < kids.length; iNode++) {
+ let node = kids[iNode];
+ if (node.pressed != node.id in expected) {
+ throw new Error(
+ "node " +
+ node.id +
+ " should " +
+ (node.id in expected ? "be " : "not be ") +
+ "checked."
+ );
+ }
+ }
+}
+
+var nameToTextDomId = {
+ sender: "qfb-qs-sender",
+ recipients: "qfb-qs-recipients",
+ subject: "qfb-qs-subject",
+ body: "qfb-qs-body",
+};
+
+function toggle_text_constraints(...aArgs) {
+ aArgs.forEach(arg =>
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.document.getElementById(nameToTextDomId[arg]),
+ { clickCount: 1 },
+ about3Pane
+ )
+ );
+ wait_for_all_messages_to_load(mc);
+}
+
+/**
+ * Assert that the text constraint buttons are checked. Variable-argument
+ * support where the arguments are one of sender/recipients/subject/body.
+ */
+function assert_text_constraints_checked(...aArgs) {
+ let expected = {};
+ for (let arg of aArgs) {
+ let nodeId = nameToTextDomId[arg];
+ expected[nodeId] = true;
+ }
+
+ let kids = about3Pane.document.querySelectorAll(
+ "#quick-filter-bar-filter-text-bar button"
+ );
+ for (let iNode = 0; iNode < kids.length; iNode++) {
+ let node = kids[iNode];
+ if (node.tagName == "label") {
+ continue;
+ }
+ if (node.pressed != node.id in expected) {
+ throw new Error(
+ "node " +
+ node.id +
+ " should " +
+ (node.id in expected ? "be " : "not be ") +
+ "checked."
+ );
+ }
+ }
+}
+
+/**
+ * Set the text in the text filter box, trigger it like enter was pressed, then
+ * wait for all messages to load.
+ */
+function set_filter_text(aText) {
+ // We're not testing the reliability of the textbox widget; just poke our text
+ // in and trigger the command logic.
+ let textbox = about3Pane.document.getElementById("qfb-qs-textbox");
+ textbox.value = aText;
+ textbox.doCommand();
+ wait_for_all_messages_to_load(mc);
+}
+
+function assert_filter_text(aText) {
+ let textbox = get_about_3pane().document.getElementById("qfb-qs-textbox");
+ if (textbox.value != aText) {
+ throw new Error(
+ "Expected text filter value of '" +
+ aText +
+ "' but got '" +
+ textbox.value +
+ "'"
+ );
+ }
+}
+
+/**
+ * Assert that the results label is telling us there are aCount messages
+ * using the appropriate string.
+ */
+function assert_results_label_count(aCount) {
+ let resultsLabel = about3Pane.document.getElementById("qfb-results-label");
+ let attributes = about3Pane.document.l10n.getAttributes(resultsLabel);
+ if (aCount == 0) {
+ Assert.deepEqual(
+ attributes,
+ { id: "quick-filter-bar-no-results", args: null },
+ "results label should be displaying the no messages case"
+ );
+ } else {
+ Assert.deepEqual(
+ attributes,
+ { id: "quick-filter-bar-results", args: { count: aCount } },
+ `result count should show ${aCount}`
+ );
+ }
+}
+
+/**
+ * Clear active constraints via any means necessary; state cleanup for testing,
+ * not to be used as part of a test. Unlike normal clearing, this will kill
+ * the sticky bit.
+ *
+ * This is automatically called by the test teardown helper.
+ */
+function clear_constraints() {
+ about3Pane.quickFilterBar._testHelperResetFilterState();
+}
diff --git a/comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm b/comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm
new file mode 100644
index 0000000000..2eea0e17e7
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/SearchWindowHelpers.jsm
@@ -0,0 +1,206 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "assert_messages_in_search_view",
+ "assert_search_window_folder_displayed",
+ "close_search_window",
+ "open_search_window",
+ "open_search_window_from_context_menu",
+ "select_click_search_row",
+ "select_shift_click_search_row",
+];
+
+var { get_about_3pane, mc, right_click_on_folder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var windowHelper = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+/**
+ * Open a search window using the accel-shift-f shortcut.
+ *
+ * @returns the controller for the search window
+ */
+function open_search_window() {
+ windowHelper.plan_for_new_window("mailnews:search");
+ EventUtils.synthesizeKey("f", { shiftKey: true, accelKey: true }, mc.window);
+ return windowHelper.wait_for_new_window("mailnews:search");
+}
+
+/**
+ * Open a search window as if from the context menu. This needs the context menu
+ * to be already open.
+ *
+ * @param aFolder the folder to open the search window for
+ * @returns the controller for the search window
+ */
+async function open_search_window_from_context_menu(aFolder) {
+ let win = get_about_3pane();
+ let context = win.document.getElementById("folderPaneContext");
+ let item = win.document.getElementById("folderPaneContext-searchMessages");
+ await right_click_on_folder(aFolder);
+
+ windowHelper.plan_for_new_window("mailnews:search");
+ context.activateItem(item);
+ return windowHelper.wait_for_new_window("mailnews:search");
+}
+
+/**
+ * Close a search window by calling window.close() on the controller.
+ */
+function close_search_window(aController) {
+ windowHelper.close_window(aController);
+}
+
+/**
+ * Assert that the given folder is selected in the search window corresponding
+ * to the given controller.
+ */
+function assert_search_window_folder_displayed(aController, aFolder) {
+ let currentFolder = aController.window.gCurrentFolder;
+ Assert.equal(
+ currentFolder,
+ aFolder,
+ "The search window's selected folder should have been: " +
+ aFolder.prettyName +
+ ", but is actually: " +
+ currentFolder?.prettyName
+ );
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse.
+ *
+ * @param {number} aViewIndex - The view index to click.
+ * @param {MozMillController} aController - The controller in whose context to
+ * do this.
+ * @returns {nsIMsgDBHdr} The message header selected.
+ */
+function select_click_search_row(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let tree = aController.window.document.getElementById("threadTree");
+ tree.scrollToRow(aViewIndex);
+ let coords = tree.getCoordsForCellItem(
+ aViewIndex,
+ tree.columns.subjectCol,
+ "cell"
+ );
+ let treeChildren = tree.lastElementChild;
+ EventUtils.synthesizeMouse(
+ treeChildren,
+ coords.x + coords.width / 2,
+ coords.y + coords.height / 2,
+ {},
+ aController.window
+ );
+
+ return aController.window.gFolderDisplay.view.dbView.getMsgHdrAt(aViewIndex);
+}
+
+/**
+ * Pretend we are clicking on a row with our mouse with the shift key pressed,
+ * adding all the messages between the shift pivot and the shift selected row.
+ *
+ * @param {number} aViewIndex - The view index to click.
+ * @param {MozMillController} aController The controller in whose context to
+ * do this.
+ * @returns {nsIMsgDBHdr[]} The message headers for all messages that are now
+ * selected.
+ */
+function select_shift_click_search_row(aViewIndex, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+
+ let tree = aController.window.document.getElementById("threadTree");
+ tree.scrollToRow(aViewIndex);
+ let coords = tree.getCoordsForCellItem(
+ aViewIndex,
+ tree.columns.subjectCol,
+ "cell"
+ );
+ let treeChildren = tree.lastElementChild;
+ EventUtils.synthesizeMouse(
+ treeChildren,
+ coords.x + coords.width / 2,
+ coords.y + coords.height / 2,
+ { shiftKey: true },
+ aController.window
+ );
+
+ utils.sleep(0);
+ return aController.window.gFolderDisplay.selectedMessages;
+}
+
+/**
+ * Assert that the given synthetic message sets are present in the folder
+ * display.
+ *
+ * Verify that the messages in the provided SyntheticMessageSets are the only
+ * visible messages in the provided DBViewWrapper.
+ *
+ * @param {SyntheticMessageSet} aSynSets - Either a single SyntheticMessageSet
+ * or a list of them.
+ * @param {MozMillController} aController - The controller which we get the
+ * folderDisplay property from.
+ */
+function assert_messages_in_search_view(aSynSets, aController) {
+ if (aController == null) {
+ aController = mc;
+ }
+ if (!Array.isArray(aSynSets)) {
+ aSynSets = [aSynSets];
+ }
+
+ // Iterate over all the message sets, retrieving the message header. Use
+ // this to construct a URI to populate a dictionary mapping.
+ let synMessageURIs = {}; // map URI to message header
+ for (let messageSet of aSynSets) {
+ for (let msgHdr of messageSet.msgHdrs()) {
+ synMessageURIs[msgHdr.folder.getUriForMsg(msgHdr)] = msgHdr;
+ }
+ }
+
+ // Iterate over the contents of the view, nulling out values in
+ // synMessageURIs for found messages, and exploding for missing ones.
+ let dbView = aController.window.gFolderDisplay.view.dbView;
+ let treeView = aController.window.gFolderDisplay.view.dbView.QueryInterface(
+ Ci.nsITreeView
+ );
+ let rowCount = treeView.rowCount;
+
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ Assert.ok(
+ uri in synMessageURIs,
+ "The view should show the message header" + msgHdr.messageKey
+ );
+ delete synMessageURIs[uri];
+ }
+
+ // Iterate over our URI set and make sure every message was shown.
+ for (let uri in synMessageURIs) {
+ let msgHdr = synMessageURIs[uri];
+ Assert.ok(
+ false,
+ "The view is should include the message header" + msgHdr.messageKey
+ );
+ }
+}
diff --git a/comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm b/comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm
new file mode 100644
index 0000000000..2f096479f0
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/SubscribeWindowHelpers.jsm
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "open_subscribe_window_from_context_menu",
+ "enter_text_in_search_box",
+ "check_newsgroup_displayed",
+];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var { get_about_3pane, right_click_on_folder } = ChromeUtils.import(
+ "resource://testing-common/mozmill/FolderDisplayHelpers.jsm"
+);
+var { input_value, delete_all_existing } = ChromeUtils.import(
+ "resource://testing-common/mozmill/KeyboardHelpers.jsm"
+);
+var { click_menus_in_sequence, plan_for_modal_dialog, wait_for_modal_dialog } =
+ ChromeUtils.import("resource://testing-common/mozmill/WindowHelpers.jsm");
+
+/**
+ * Open a subscribe dialog from the context menu.
+ *
+ * @param aFolder the folder to open the subscribe dialog for
+ * @param aFunction Callback that will be invoked with a controller
+ * for the subscribe dialogue as parameter
+ */
+async function open_subscribe_window_from_context_menu(aFolder, aFunction) {
+ let win = get_about_3pane();
+
+ await right_click_on_folder(aFolder);
+ let callback = function (controller) {
+ // When the "stop button" is disabled, the panel is populated.
+ utils.waitFor(
+ () => controller.window.document.getElementById("stopButton").disabled
+ );
+ aFunction(controller);
+ };
+ plan_for_modal_dialog("mailnews:subscribe", callback);
+ await click_menus_in_sequence(
+ win.document.getElementById("folderPaneContext"),
+ [{ id: "folderPaneContext-subscribe" }]
+ );
+ wait_for_modal_dialog("mailnews:subscribe");
+}
+
+/**
+ * Enter a string in the text box for the search value.
+ *
+ * @param swc A controller for a subscribe dialog
+ * @param text The text to enter
+ */
+function enter_text_in_search_box(swc, text) {
+ let textbox = swc.window.document.getElementById("namefield");
+ delete_all_existing(swc, textbox);
+ input_value(swc, text, textbox);
+}
+
+/**
+ * Check whether the given newsgroup is in the searchview.
+ *
+ * @param swc A controller for the subscribe window
+ * @param name Name of the newsgroup
+ * @returns {boolean} Result of the check
+ */
+function check_newsgroup_displayed(swc, name) {
+ let tree = swc.window.document.getElementById("searchTree");
+ if (!tree.columns) {
+ // Maybe not yet available.
+ return false;
+ }
+ let treeview = tree.view;
+ let nameCol = tree.columns.getNamedColumn("nameColumn2");
+ for (let i = 0; i < treeview.rowCount; i++) {
+ if (treeview.getCellText(i, nameCol) == name) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/comm/mail/test/browser/shared-modules/ViewHelpers.jsm b/comm/mail/test/browser/shared-modules/ViewHelpers.jsm
new file mode 100644
index 0000000000..33d39a8e07
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/ViewHelpers.jsm
@@ -0,0 +1,85 @@
+/* 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/. */
+
+/** Module to help debugging view wrapper issues. */
+
+const EXPORTED_SYMBOLS = ["dump_view_contents", "dump_view_state"];
+
+function dump_view_state(aViewWrapper, aDoNotDumpContents) {
+ if (aViewWrapper.dbView == null) {
+ dump("no nsIMsgDBView instance!\n");
+ return;
+ }
+ if (!aDoNotDumpContents) {
+ dump_view_contents(aViewWrapper);
+ }
+ dump("View: " + aViewWrapper.dbView + "\n");
+ dump(
+ " View Type: " +
+ _lookupValueNameInInterface(
+ aViewWrapper.dbView.viewType,
+ Ci.nsMsgViewType
+ ) +
+ " " +
+ "View Flags: " +
+ aViewWrapper.dbView.viewFlags +
+ "\n"
+ );
+ dump(
+ " Sort Type: " +
+ _lookupValueNameInInterface(
+ aViewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType
+ ) +
+ " " +
+ "Sort Order: " +
+ _lookupValueNameInInterface(
+ aViewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder
+ ) +
+ "\n"
+ );
+ dump(aViewWrapper.search.prettyString());
+}
+
+var WHITESPACE = " ";
+var MSG_VIEW_FLAG_DUMMY = 0x20000000;
+function dump_view_contents(aViewWrapper) {
+ let dbView = aViewWrapper.dbView;
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ dump("********* Current View Contents\n");
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let level = treeView.getLevel(iViewIndex);
+ let flags = dbView.getFlagsAt(iViewIndex);
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+
+ let s = WHITESPACE.substr(0, level * 2);
+ if (treeView.isContainer(iViewIndex)) {
+ s += treeView.isContainerOpen(iViewIndex) ? "- " : "+ ";
+ } else {
+ s += ". ";
+ }
+ // s += treeView.getCellText(iViewIndex, )
+ if (flags & MSG_VIEW_FLAG_DUMMY) {
+ s += "dummy: ";
+ }
+ s += dbView.cellTextForColumn(iViewIndex, "subject");
+ s += " [" + msgHdr.folder.prettyName + "," + msgHdr.messageKey + "]";
+
+ dump(s + "\n");
+ }
+ dump("********* end view contents\n");
+}
+
+function _lookupValueNameInInterface(aValue, aInterface) {
+ for (let key in aInterface) {
+ let value = aInterface[key];
+ if (value == aValue) {
+ return key;
+ }
+ }
+ return "unknown: " + aValue;
+}
diff --git a/comm/mail/test/browser/shared-modules/WindowHelpers.jsm b/comm/mail/test/browser/shared-modules/WindowHelpers.jsm
new file mode 100644
index 0000000000..9a8d16fbae
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/WindowHelpers.jsm
@@ -0,0 +1,1018 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "click_menus_in_sequence",
+ "close_popup_sequence",
+ "click_through_appmenu",
+ "plan_for_new_window",
+ "wait_for_new_window",
+ "async_plan_for_new_window",
+ "plan_for_modal_dialog",
+ "wait_for_modal_dialog",
+ "plan_for_window_close",
+ "wait_for_window_close",
+ "close_window",
+ "wait_for_existing_window",
+ "wait_for_window_focused",
+ "wait_for_browser_load",
+ "wait_for_frame_load",
+ "resize_to",
+];
+
+var controller = ChromeUtils.import(
+ "resource://testing-common/mozmill/controller.jsm"
+);
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+var EventUtils = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+/**
+ * Timeout to use when waiting for the first window ever to load. This is
+ * long because we are basically waiting for the entire app startup process.
+ */
+var FIRST_WINDOW_EVER_TIMEOUT_MS = 30000;
+/**
+ * Interval to check if the window has shown up for the first window ever to
+ * load. The check interval is longer because it's less likely the window
+ * is going to show up quickly and there is a cost to the check.
+ */
+var FIRST_WINDOW_CHECK_INTERVAL_MS = 300;
+
+/**
+ * Timeout for opening a window.
+ */
+var WINDOW_OPEN_TIMEOUT_MS = 10000;
+/**
+ * Check interval for opening a window.
+ */
+var WINDOW_OPEN_CHECK_INTERVAL_MS = 100;
+
+/**
+ * Timeout for closing a window.
+ */
+var WINDOW_CLOSE_TIMEOUT_MS = 10000;
+/**
+ * Check interval for closing a window.
+ */
+var WINDOW_CLOSE_CHECK_INTERVAL_MS = 100;
+
+/**
+ * Timeout for focusing a window. Only really an issue on linux.
+ */
+var WINDOW_FOCUS_TIMEOUT_MS = 10000;
+
+function getWindowTypeOrId(aWindowElem) {
+ let windowType = aWindowElem.getAttribute("windowtype");
+ // Ignore types that start with "prompt:". This prefix gets added in
+ // toolkit/components/prompts/src/CommonDialog.jsm since bug 1388238.
+ if (windowType && !windowType.startsWith("prompt:")) {
+ return windowType;
+ }
+
+ return aWindowElem.getAttribute("id");
+}
+
+/**
+ * Return the "windowtype" or "id" for the given app window if it is available.
+ * If not, return null.
+ */
+function getWindowTypeForAppWindow(aAppWindow, aBusyOk) {
+ // Sometimes we are given HTML windows, for which the logic below will
+ // bail. So we use a fast-path here that should work for HTML and should
+ // maybe also work with XUL. I'm not going to go into it...
+ if (
+ aAppWindow.document &&
+ aAppWindow.document.documentElement &&
+ aAppWindow.document.documentElement.hasAttribute("windowtype")
+ ) {
+ return getWindowTypeOrId(aAppWindow.document.documentElement);
+ }
+
+ let docshell = aAppWindow.docShell;
+ // we need the docshell to exist...
+ if (!docshell) {
+ return null;
+ }
+
+ // we can't know if it's the right document until it's not busy
+ if (!aBusyOk && docshell.busyFlags) {
+ return null;
+ }
+
+ // it also needs to have content loaded (it starts out not busy with no
+ // content viewer.)
+ if (docshell.contentViewer == null) {
+ return null;
+ }
+
+ // now we're cooking! let's get the document...
+ let outerDoc = docshell.contentViewer.DOMDocument;
+ // and make sure it's not blank. that's also an intermediate state.
+ if (outerDoc.location.href == "about:blank") {
+ return null;
+ }
+
+ // finally, we can now have a windowtype!
+ let windowType = getWindowTypeOrId(outerDoc.documentElement);
+
+ if (windowType) {
+ return windowType;
+ }
+
+ // As a last resort, use the name given to the DOM window.
+ let domWindow = aAppWindow.docShell.domWindow;
+
+ return domWindow.name;
+}
+
+var WindowWatcher = {
+ _inited: false,
+ _firstWindowOpened: false,
+ ensureInited() {
+ if (this._inited) {
+ return;
+ }
+
+ // Add ourselves as an nsIWindowMediatorListener so we can here about when
+ // windows get registered with the window mediator. Because this
+ // generally happens
+ // Another possible means of getting this info would be to observe
+ // "xul-window-visible", but it provides no context and may still require
+ // polling anyways.
+ Services.wm.addListener(this);
+
+ // Clean up any references to windows at the end of each test, and clean
+ // up the listeners/observers as the end of the session.
+ let observer = {
+ observe(subject, topic) {
+ WindowWatcher.monitoringList.length = 0;
+ WindowWatcher.waitingList.clear();
+ if (topic == "quit-application-granted") {
+ Services.wm.removeListener(this);
+ Services.obs.removeObserver(this, "test-complete");
+ Services.obs.removeObserver(this, "quit-application-granted");
+ }
+ },
+ };
+ Services.obs.addObserver(observer, "test-complete");
+ Services.obs.addObserver(observer, "quit-application-granted");
+
+ this._inited = true;
+ },
+
+ /**
+ * Track the windowtypes we are waiting on. Keys are windowtypes. When
+ * watching for new windows, values are initially null, and are set to an
+ * nsIAppWindow when we actually find the window. When watching for closing
+ * windows, values are nsIAppWindows. This symmetry lets us have windows
+ * that appear and dis-appear do so without dangerously confusing us (as
+ * long as another one comes along...)
+ */
+ waitingList: new Map(),
+ /**
+ * Note that we will be looking for a window with the given window type
+ * (ex: "mailnews:search"). This allows us to be ready if an event shows
+ * up before waitForWindow is called.
+ */
+ planForWindowOpen(aWindowType) {
+ this.waitingList.set(aWindowType, null);
+ },
+
+ /**
+ * Like planForWindowOpen but we check for already-existing windows.
+ */
+ planForAlreadyOpenWindow(aWindowType) {
+ this.waitingList.set(aWindowType, null);
+ // We need to iterate over all the app windows and consider them all.
+ // We can't pass the window type because the window might not have a
+ // window type yet.
+ // because this iterates from old to new, this does the right thing in that
+ // side-effects of consider will pick the most recent window.
+ for (let appWindow of Services.wm.getAppWindowEnumerator(null)) {
+ if (!this.consider(appWindow)) {
+ this.monitoringList.push(appWindow);
+ }
+ }
+ },
+
+ /**
+ * The current windowType we are waiting to open. This is mainly a means of
+ * communicating the desired window type to monitorize without having to
+ * put the argument in the eval string.
+ */
+ waitingForOpen: null,
+ /**
+ * Wait for the given windowType to open and finish loading.
+ *
+ * @returns The window wrapped in a MozMillController.
+ */
+ waitForWindowOpen(aWindowType) {
+ this.waitingForOpen = aWindowType;
+ utils.waitFor(
+ () => this.monitorizeOpen(),
+ "Timed out waiting for window open!",
+ this._firstWindowOpened
+ ? WINDOW_OPEN_TIMEOUT_MS
+ : FIRST_WINDOW_EVER_TIMEOUT_MS,
+ this._firstWindowOpened
+ ? WINDOW_OPEN_CHECK_INTERVAL_MS
+ : FIRST_WINDOW_CHECK_INTERVAL_MS
+ );
+
+ this.waitingForOpen = null;
+ let appWindow = this.waitingList.get(aWindowType);
+ let domWindow = appWindow.docShell.domWindow;
+ this.waitingList.delete(aWindowType);
+ // spin the event loop to make sure any setTimeout 0 calls have gotten their
+ // time in the sun.
+ utils.sleep(0);
+ this._firstWindowOpened = true;
+ return new controller.MozMillController(domWindow);
+ },
+
+ /**
+ * Because the modal dialog spins its own event loop, the mozmill idiom of
+ * spinning your own event-loop as performed by waitFor is no good. We use
+ * this timer to generate our events so that we can have a waitFor
+ * equivalent.
+ *
+ * We only have one timer right now because modal dialogs that spawn modal
+ * dialogs are not tremendously likely.
+ */
+ _timer: null,
+ _timerRuntimeSoFar: 0,
+ /**
+ * The test function to run when the modal dialog opens.
+ */
+ subTestFunc: null,
+ planForModalDialog(aWindowType, aSubTestFunc) {
+ if (this._timer == null) {
+ this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+ this.waitingForOpen = aWindowType;
+ this.subTestFunc = aSubTestFunc;
+ this.waitingList.set(aWindowType, null);
+
+ this._timerRuntimeSoFar = 0;
+ this._timer.initWithCallback(
+ this,
+ WINDOW_OPEN_CHECK_INTERVAL_MS,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ },
+
+ /**
+ * This is the nsITimer notification we receive...
+ */
+ notify() {
+ if (this.monitorizeOpen()) {
+ // okay, the window is opened, and we should be in its event loop now.
+ let appWindow = this.waitingList.get(this.waitingForOpen);
+ let domWindow = appWindow.docShell.domWindow;
+ let troller = new controller.MozMillController(domWindow);
+
+ this._timer.cancel();
+
+ let self = this;
+ async function startTest() {
+ self.planForWindowClose(troller.window);
+ try {
+ await self.subTestFunc(troller);
+ } finally {
+ self.subTestFunc = null;
+ }
+
+ // if the test failed, make sure we force the window closed...
+ // except I'm not sure how to easily figure that out...
+ // so just close it no matter what.
+ troller.window.close();
+ self.waitForWindowClose();
+
+ self.waitingList.delete(self.waitingForOpen);
+ // now we are waiting for it to close...
+ self.waitingForClose = self.waitingForOpen;
+ self.waitingForOpen = null;
+ }
+
+ let targetFocusedWindow = {};
+ Services.focus.getFocusedElementForWindow(
+ domWindow,
+ true,
+ targetFocusedWindow
+ );
+ targetFocusedWindow = targetFocusedWindow.value;
+
+ let focusedWindow = {};
+ if (Services.focus.activeWindow) {
+ Services.focus.getFocusedElementForWindow(
+ Services.focus.activeWindow,
+ true,
+ focusedWindow
+ );
+
+ focusedWindow = focusedWindow.value;
+ }
+
+ if (focusedWindow == targetFocusedWindow) {
+ startTest();
+ } else {
+ function onFocus(event) {
+ targetFocusedWindow.setTimeout(startTest, 0);
+ }
+ targetFocusedWindow.addEventListener("focus", onFocus, {
+ capture: true,
+ once: true,
+ });
+ targetFocusedWindow.focus();
+ }
+ }
+ // notify is only used for modal dialogs, which are never the first window,
+ // so we can always just use this set of timeouts/intervals.
+ this._timerRuntimeSoFar += WINDOW_OPEN_CHECK_INTERVAL_MS;
+ if (this._timerRuntimeSoFar >= WINDOW_OPEN_TIMEOUT_MS) {
+ this._timer.cancel();
+ throw new Error("Timeout while waiting for modal dialog.\n");
+ }
+ },
+
+ /**
+ * Symmetry for planForModalDialog; conceptually provides the waiting. In
+ * reality, all we do is potentially soak up the event loop a little to
+ */
+ waitForModalDialog(aWindowType, aTimeout) {
+ // did the window already come and go?
+ if (this.subTestFunc == null) {
+ return;
+ }
+ // spin the event loop until we the window has come and gone.
+ utils.waitFor(
+ () => {
+ return this.waitingForOpen == null && this.monitorizeClose();
+ },
+ "Timeout waiting for modal dialog to open.",
+ aTimeout || WINDOW_OPEN_TIMEOUT_MS,
+ WINDOW_OPEN_CHECK_INTERVAL_MS
+ );
+ this.waitingForClose = null;
+ },
+
+ planForWindowClose(aAppWindow) {
+ let windowType = getWindowTypeOrId(aAppWindow.document.documentElement);
+ this.waitingList.set(windowType, aAppWindow);
+ this.waitingForClose = windowType;
+ },
+
+ /**
+ * The current windowType we are waiting to close. Same deal as
+ * waitingForOpen, this makes the eval less crazy.
+ */
+ waitingForClose: null,
+ waitForWindowClose() {
+ utils.waitFor(
+ () => this.monitorizeClose(),
+ "Timeout waiting for window to close!",
+ WINDOW_CLOSE_TIMEOUT_MS,
+ WINDOW_CLOSE_CHECK_INTERVAL_MS
+ );
+ let didDisappear = this.waitingList.get(this.waitingForClose) == null;
+ let windowType = this.waitingForClose;
+ this.waitingList.delete(windowType);
+ this.waitingForClose = null;
+ if (!didDisappear) {
+ throw new Error(windowType + " window did not disappear!");
+ }
+ },
+
+ /**
+ * Used by waitForWindowOpen to check all of the windows we are monitoring and
+ * then check if we have any results.
+ *
+ * @returns true if we found what we were |waitingForOpen|, false otherwise.
+ */
+ monitorizeOpen() {
+ for (let iWin = this.monitoringList.length - 1; iWin >= 0; iWin--) {
+ let appWindow = this.monitoringList[iWin];
+ if (this.consider(appWindow)) {
+ this.monitoringList.splice(iWin, 1);
+ }
+ }
+
+ return (
+ this.waitingList.has(this.waitingForOpen) &&
+ this.waitingList.get(this.waitingForOpen) != null
+ );
+ },
+
+ /**
+ * Used by waitForWindowClose to check if the window we are waiting to close
+ * actually closed yet.
+ *
+ * @returns true if it closed.
+ */
+ monitorizeClose() {
+ return this.waitingList.get(this.waitingForClose) == null;
+ },
+
+ /**
+ * A list of app windows to monitor because they are loading and it's not yet
+ * possible to tell whether they are something we are looking for.
+ */
+ monitoringList: [],
+ /**
+ * Monitor the given window's loading process until we can determine whether
+ * it is what we are looking for.
+ */
+ monitorWindowLoad(aAppWindow) {
+ this.monitoringList.push(aAppWindow);
+ },
+
+ /**
+ * nsIWindowMediatorListener notification that a app window was opened. We
+ * check out the window, and if we were not able to fully consider it, we
+ * add it to our monitoring list.
+ */
+ onOpenWindow(aAppWindow) {
+ // note: we would love to add our window activation/deactivation listeners
+ // and poke our unique id, but there is no contentViewer at this point
+ // and so there's no place to poke our unique id. (aAppWindow does not
+ // let us put expandos on; it's an XPCWrappedNative and explodes.)
+ // There may be nuances about outer window/inner window that make it
+ // feasible, but I have forgotten any such nuances I once knew.
+ if (!this.consider(aAppWindow)) {
+ this.monitorWindowLoad(aAppWindow);
+ }
+ },
+
+ /**
+ * Consider if the given window is something in our |waitingList|.
+ *
+ * @returns true if we were able to fully consider the object, false if we were
+ * not and need to be called again on the window later. This has no
+ * relation to whether the window was one in our waitingList or not.
+ * Check the waitingList structure for that.
+ */
+ consider(aAppWindow) {
+ let windowType = getWindowTypeForAppWindow(aAppWindow);
+ if (windowType == null) {
+ return false;
+ }
+
+ // stash the window if we were watching for it
+ if (this.waitingList.has(windowType)) {
+ this.waitingList.set(windowType, aAppWindow);
+ }
+
+ return true;
+ },
+
+ /**
+ * Closing windows have the advantage of having to already have been loaded,
+ * so things like their windowtype are immediately available.
+ */
+ onCloseWindow(aAppWindow) {
+ let domWindow = aAppWindow.docShell.domWindow;
+ let windowType = getWindowTypeOrId(domWindow.document.documentElement);
+ if (this.waitingList.has(windowType)) {
+ this.waitingList.set(windowType, null);
+ }
+ },
+};
+
+/**
+ * Call this if the window you want to get may already be open. What we
+ * provide above just directly grabbing the window yourself is:
+ * - We wait for it to finish loading.
+ *
+ * @param aWindowType the window type that will be created. This is literally
+ * the value of the "windowtype" attribute on the window. The values tend
+ * to look like "app:windowname", for example "mailnews:search".
+ *
+ * @returns {MozmillController}
+ */
+function wait_for_existing_window(aWindowType) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForAlreadyOpenWindow(aWindowType);
+ return WindowWatcher.waitForWindowOpen(aWindowType);
+}
+
+/**
+ * Call this just before you trigger the event that will cause a window to be
+ * displayed.
+ * In theory, we don't need this and could just do a sweep of existing windows
+ * when you call wait_for_new_window, or we could always just keep track of
+ * the most recently seen window of each type, but this is arguably more
+ * resilient in the face of multiple windows of the same type as long as you
+ * don't try and open them all at the same time.
+ *
+ * @param aWindowType the window type that will be created. This is literally
+ * the value of the "windowtype" attribute on the window. The values tend
+ * to look like "app:windowname", for example "mailnews:search".
+ */
+function plan_for_new_window(aWindowType) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForWindowOpen(aWindowType);
+}
+
+/**
+ * Wait for the loading of the given window type to complete (that you
+ * previously told us about via |plan_for_new_window|), returning it wrapped
+ * in a MozmillController.
+ *
+ * @returns {MozmillController}
+ */
+function wait_for_new_window(aWindowType) {
+ let c = WindowWatcher.waitForWindowOpen(aWindowType);
+ // A nested event loop can get spun inside the Controller's constructor
+ // (which is arguably not a great idea), so it's important that we denote
+ // when we're actually leaving this function in case something crazy
+ // happens.
+ return c;
+}
+
+async function async_plan_for_new_window(aWindowType) {
+ let domWindow = await BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ return (
+ win.document.documentElement.getAttribute("windowtype") == aWindowType
+ );
+ });
+
+ await new Promise(r => domWindow.setTimeout(r));
+ await new Promise(r => domWindow.setTimeout(r));
+
+ let domWindowController = new controller.MozMillController(domWindow);
+ return domWindowController;
+}
+
+/**
+ * Plan for the imminent display of a modal dialog. Modal dialogs spin their
+ * own event loop which means that either that control flow will not return
+ * to the caller until the modal dialog finishes running. This means that
+ * you need to provide a sub-test function to be run inside the modal dialog
+ * (and it should not start with "test" or mozmill will also try and run it.)
+ *
+ * @param aWindowType The window type that you expect the modal dialog to have
+ * or the id of the window if there is no window type
+ * available.
+ * @param aSubTestFunction The sub-test function that will be run once the modal
+ * dialog appears and is loaded. This function should take one argument,
+ * a MozmillController against the modal dialog.
+ */
+function plan_for_modal_dialog(aWindowType, aSubTestFunction) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForModalDialog(aWindowType, aSubTestFunction);
+}
+/**
+ * In case the dialog might be stuck for a long time, you can pass an optional
+ * timeout.
+ *
+ * @param aTimeout Your custom timeout (default is WINDOW_OPEN_TIMEOUT_MS)
+ */
+function wait_for_modal_dialog(aWindowType, aTimeout) {
+ WindowWatcher.waitForModalDialog(aWindowType, aTimeout);
+}
+
+/**
+ * Call this just before you trigger the event that will cause the provided
+ * controller's window to disappear. You then follow this with a call to
+ * |wait_for_window_close| when you want to block on verifying the close.
+ *
+ * @param aController The MozmillController, potentially returned from a call to
+ * wait_for_new_window, whose window should be disappearing.
+ */
+function plan_for_window_close(aController) {
+ WindowWatcher.ensureInited();
+ WindowWatcher.planForWindowClose(aController.window);
+}
+
+/**
+ * Wait for the closure of the window you noted you would listen for its close
+ * in plan_for_window_close.
+ */
+function wait_for_window_close() {
+ WindowWatcher.waitForWindowClose();
+}
+
+/**
+ * Close a window by calling window.close() on the controller.
+ *
+ * @param aController the controller whose window is to be closed.
+ */
+function close_window(aController) {
+ plan_for_window_close(aController);
+ aController.window.close();
+ wait_for_window_close();
+}
+
+/**
+ * Wait for the window to be focused.
+ *
+ * @param aWindow the window to be focused.
+ */
+function wait_for_window_focused(aWindow) {
+ let targetWindow = {};
+
+ Services.focus.getFocusedElementForWindow(aWindow, true, targetWindow);
+ targetWindow = targetWindow.value;
+
+ let focusedWindow = {};
+ if (Services.focus.activeWindow) {
+ Services.focus.getFocusedElementForWindow(
+ Services.focus.activeWindow,
+ true,
+ focusedWindow
+ );
+ focusedWindow = focusedWindow.value;
+ }
+
+ let focused = false;
+ if (focusedWindow == targetWindow) {
+ focused = true;
+ } else {
+ targetWindow.addEventListener("focus", () => (focused = true), {
+ capture: true,
+ once: true,
+ });
+ targetWindow.focus();
+ }
+
+ utils.waitFor(
+ () => focused,
+ "Timeout waiting for window to be focused.",
+ WINDOW_FOCUS_TIMEOUT_MS,
+ 100,
+ this
+ );
+}
+
+/**
+ * Given a <browser>, waits for it to completely load.
+ *
+ * @param aBrowser The <browser> element to wait for.
+ * @param aURLOrPredicate The URL that should be loaded (string) or a predicate
+ * for the URL (function).
+ * @returns The browser's content window wrapped in a MozMillController.
+ */
+function wait_for_browser_load(aBrowser, aURLOrPredicate) {
+ // aBrowser has all the fields we need already.
+ return _wait_for_generic_load(aBrowser, aURLOrPredicate);
+}
+
+/**
+ * Given an HTML <frame> or <iframe>, waits for it to completely load.
+ *
+ * @param aFrame The element to wait for.
+ * @param aURLOrPredicate The URL that should be loaded (string) or a predicate
+ * for the URL (function).
+ * @returns The frame wrapped in a MozMillController.
+ */
+function wait_for_frame_load(aFrame, aURLOrPredicate) {
+ return _wait_for_generic_load(aFrame, aURLOrPredicate);
+}
+
+/**
+ * Generic function to wait for some sort of document to load. We expect
+ * aDetails to have three fields:
+ * - webProgress: an nsIWebProgress associated with the contentWindow.
+ * - currentURI: the currently loaded page (nsIURI).
+ */
+function _wait_for_generic_load(aDetails, aURLOrPredicate) {
+ let predicate;
+ if (typeof aURLOrPredicate == "string") {
+ let expectedURL = NetUtil.newURI(aURLOrPredicate);
+ predicate = url => expectedURL.equals(url);
+ } else {
+ predicate = aURLOrPredicate;
+ }
+
+ function isLoadedChecker() {
+ if (aDetails.webProgress?.isLoadingDocument) {
+ return false;
+ }
+ if (
+ aDetails.contentDocument &&
+ aDetails.contentDocument.readyState != "complete"
+ ) {
+ return false;
+ }
+
+ return predicate(
+ aDetails.currentURI ||
+ NetUtil.newURI(aDetails.contentWindow.location.href)
+ );
+ }
+
+ try {
+ utils.waitFor(isLoadedChecker);
+ } catch (e) {
+ if (e instanceof utils.TimeoutError) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Timeout waiting for content page to load. Current URL is: ${aDetails.currentURI.spec}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ // Lie to mozmill to convince it to not explode because these frames never
+ // get a mozmillDocumentLoaded attribute (bug 666438).
+ let contentWindow = aDetails.contentWindow;
+ if (contentWindow) {
+ return new controller.MozMillController(contentWindow);
+ }
+ return null;
+}
+
+/**
+ * Resize given window to new dimensions.
+ *
+ * @param aController window controller
+ * @param aWidth the requested window width
+ * @param aHeight the requested window height
+ */
+function resize_to(aController, aWidth, aHeight) {
+ aController.window.resizeTo(aWidth, aHeight);
+ // Give the event loop a spin in order to let the reality of an asynchronously
+ // interacting window manager have its impact. This still may not be
+ // sufficient.
+ utils.sleep(0);
+ utils.waitFor(
+ () =>
+ aController.window.outerWidth == aWidth &&
+ aController.window.outerHeight == aHeight,
+ "Timeout waiting for resize (current screen size: " +
+ aController.window.screen.availWidth +
+ "X" +
+ aController.window.screen.availHeight +
+ "), Requested width " +
+ aWidth +
+ " but got " +
+ aController.window.outerWidth +
+ ", Request height " +
+ aHeight +
+ " but got " +
+ aController.window.outerHeight,
+ 10000,
+ 50
+ );
+}
+
+/**
+ * Dynamically-built/XBL-defined menus can be hard to work with, this makes it
+ * easier.
+ *
+ * @param aRootPopup The base popup. The caller is expected to activate it
+ * (by clicking/rightclicking the right widget). We will only wait for it
+ * to open if it is in the process.
+ * @param aActions An array of objects where each object has attributes
+ * with a value defined. We pick the menu item whose DOM node matches
+ * all the attributes with the specified names and value. We click whatever
+ * we find. We throw if the element being asked for is not found.
+ * @param aKeepOpen If set to true the popups are not closed after last click.
+ *
+ * @returns An array of popup elements that were left open. It will be
+ * an empty array if aKeepOpen was set to false.
+ */
+async function click_menus_in_sequence(aRootPopup, aActions, aKeepOpen) {
+ if (aRootPopup.state != "open") {
+ await BrowserTestUtils.waitForEvent(aRootPopup, "popupshown");
+ }
+
+ /**
+ * Check if a node's attributes match all those given in actionObj.
+ * Nodes that are obvious containers are skipped, and their children
+ * will be used to recursively find a match instead.
+ *
+ * @param {Element} node - The node to check.
+ * @param {object} actionObj - Contains attribute-value pairs to match.
+ * @returns {Element|null} The matched node or null if no match.
+ */
+ let findMatch = function (node, actionObj) {
+ // Ignore some elements and just use their children instead.
+ if (node.localName == "hbox" || node.localName == "vbox") {
+ for (let i = 0; i < node.children.length; i++) {
+ let childMatch = findMatch(node.children[i]);
+ if (childMatch) {
+ return childMatch;
+ }
+ }
+ return null;
+ }
+
+ let matchedAll = true;
+ for (let name in actionObj) {
+ let value = actionObj[name];
+ if (!node.hasAttribute(name) || node.getAttribute(name) != value) {
+ matchedAll = false;
+ break;
+ }
+ }
+ return matchedAll ? node : null;
+ };
+
+ // These popups sadly do not close themselves, so we need to keep track
+ // of them so we can make sure they end up closed.
+ let closeStack = [aRootPopup];
+
+ let curPopup = aRootPopup;
+ for (let [iAction, actionObj] of aActions.entries()) {
+ let matchingNode = null;
+ let kids = curPopup.children;
+ for (let iKid = 0; iKid < kids.length; iKid++) {
+ let node = kids[iKid];
+ matchingNode = findMatch(node, actionObj);
+ if (matchingNode) {
+ break;
+ }
+ }
+
+ if (!matchingNode) {
+ throw new Error(
+ "Did not find matching menu item for action index " +
+ iAction +
+ ": " +
+ JSON.stringify(actionObj)
+ );
+ }
+
+ if (matchingNode.localName == "menu") {
+ matchingNode.openMenu(true);
+ } else {
+ curPopup.activateItem(matchingNode);
+ }
+ await new Promise(r => matchingNode.ownerGlobal.setTimeout(r, 500));
+
+ let newPopup = null;
+ if ("menupopup" in matchingNode) {
+ newPopup = matchingNode.menupopup;
+ }
+ if (newPopup) {
+ curPopup = newPopup;
+ closeStack.push(curPopup);
+ if (curPopup.state != "open") {
+ await BrowserTestUtils.waitForEvent(curPopup, "popupshown");
+ }
+ }
+ }
+
+ if (!aKeepOpen) {
+ close_popup_sequence(closeStack);
+ return [];
+ }
+ return closeStack;
+}
+
+/**
+ * Close given menupopups.
+ *
+ * @param aCloseStack An array of menupopup elements that are to be closed.
+ * The elements are processed from the end of the array
+ * to the front (a stack).
+ */
+function close_popup_sequence(aCloseStack) {
+ while (aCloseStack.length) {
+ let curPopup = aCloseStack.pop();
+ if (curPopup.state == "open") {
+ curPopup.focus();
+ curPopup.hidePopup();
+ }
+ }
+}
+
+/**
+ * Click through the appmenu. Callers are expected to open the initial
+ * appmenu panelview (e.g. by clicking the appmenu button). We wait for it
+ * to open if it is not open yet. Then we use a recursive style approach
+ * with a sequence of event listeners handling "ViewShown" events. The
+ * `navTargets` parameter specifies items to click to navigate through the
+ * menu. The optional `nonNavTarget` parameter specifies a final item to
+ * click to perform a command after navigating through the menu. If this
+ * argument is omitted, callers can interact with the last view panel that
+ * is returned. Callers will then need to close the appmenu when they are
+ * done with it.
+ *
+ * @param {object[]} navTargets - Array of objects that contain
+ * attribute->value pairs. We pick the menu item whose DOM node matches
+ * all the attribute->value pairs. We click whatever we find. We throw
+ * if the element being asked for is not found.
+ * @param {object} [nonNavTarget] - Contains attribute->value pairs used
+ * to identify a final menu item to click.
+ * @param {Window} win - The window we're using.
+ * @returns {Element} The <vbox class="panel-subview-body"> element inside
+ * the last shown <panelview>.
+ */
+function _click_appmenu_in_sequence(navTargets, nonNavTarget, win) {
+ const rootPopup = win.document.getElementById("appMenu-popup");
+
+ function viewShownListener(navTargets, nonNavTarget, allDone, event) {
+ // Set up the next listener if there are more navigation targets.
+ if (navTargets.length > 0) {
+ rootPopup.addEventListener(
+ "ViewShown",
+ viewShownListener.bind(
+ null,
+ navTargets.slice(1),
+ nonNavTarget,
+ allDone
+ ),
+ { once: true }
+ );
+ }
+
+ const subview = event.target.querySelector(".panel-subview-body");
+
+ // Click a target if there is a target left to click.
+ const clickTarget = navTargets[0] || nonNavTarget;
+
+ if (clickTarget) {
+ const kids = Array.from(subview.children);
+ const findFunction = node => {
+ let selectors = [];
+ for (let name in clickTarget) {
+ let value = clickTarget[name];
+ selectors.push(`[${name}="${value}"]`);
+ }
+ let s = selectors.join(",");
+ return node.matches(s) || node.querySelector(s);
+ };
+
+ // Some views are dynamically populated after ViewShown, so we wait.
+ utils.waitFor(
+ () => kids.find(findFunction),
+ () =>
+ "Waited but did not find matching menu item for target: " +
+ JSON.stringify(clickTarget)
+ );
+
+ const foundNode = kids.find(findFunction);
+
+ EventUtils.synthesizeMouseAtCenter(foundNode, {}, foundNode.ownerGlobal);
+ }
+
+ // We are all done when there are no more navigation targets.
+ if (navTargets.length == 0) {
+ allDone(subview);
+ }
+ }
+
+ let done = false;
+ let subviewToReturn;
+ const allDone = subview => {
+ subviewToReturn = subview;
+ done = true;
+ };
+
+ utils.waitFor(
+ () => rootPopup.getAttribute("panelopen") == "true",
+ "Waited for the appmenu to open, but it never opened."
+ );
+
+ // Because the appmenu button has already been clicked in the calling
+ // code (to match click_menus_in_sequence), we have to call the first
+ // viewShownListener manually, using a fake event argument, to start the
+ // series of event listener calls.
+ const fakeEvent = {
+ target: win.document.getElementById("appMenu-mainView"),
+ };
+ viewShownListener(navTargets, nonNavTarget, allDone, fakeEvent);
+
+ utils.waitFor(() => done, "Timed out in _click_appmenu_in_sequence.");
+ return subviewToReturn;
+}
+
+/**
+ * Utility wrapper function that clicks the main appmenu button to open the
+ * appmenu before calling `click_appmenu_in_sequence`. Makes things simple
+ * and concise for the most common case while still allowing for tests that
+ * open the appmenu via keyboard before calling `_click_appmenu_in_sequence`.
+ *
+ * @param {object[]} navTargets - Array of objects that contain
+ * attribute->value pairs to be used to identify menu items to click.
+ * @param {?object} nonNavTarget - Contains attribute->value pairs used
+ * to identify a final menu item to click.
+ * @param {Window} win - The window we're using.
+ * @returns {Element} The <vbox class="panel-subview-body"> element inside
+ * the last shown <panelview>.
+ */
+function click_through_appmenu(navTargets, nonNavTarget, win) {
+ let appmenu = win.document.getElementById("button-appmenu");
+ EventUtils.synthesizeMouseAtCenter(appmenu, {}, appmenu.ownerGlobal);
+ return _click_appmenu_in_sequence(navTargets, nonNavTarget, win);
+}
diff --git a/comm/mail/test/browser/shared-modules/controller.jsm b/comm/mail/test/browser/shared-modules/controller.jsm
new file mode 100644
index 0000000000..9c0bd084d9
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/controller.jsm
@@ -0,0 +1,60 @@
+// ***** BEGIN LICENSE BLOCK *****
+// Version: MPL 1.1/GPL 2.0/LGPL 2.1
+//
+// The contents of this file are subject to the Mozilla Public License Version
+// 1.1 (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+// http://www.mozilla.org/MPL/
+//
+// Software distributed under the License is distributed on an "AS IS" basis,
+// WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+// for the specific language governing rights and limitations under the
+// License.
+//
+// The Original Code is Mozilla Corporation Code.
+//
+// The Initial Developer of the Original Code is
+// Adam Christian.
+// Portions created by the Initial Developer are Copyright (C) 2008
+// the Initial Developer. All Rights Reserved.
+//
+// Contributor(s):
+// Adam Christian <adam.christian@gmail.com>
+// Mikeal Rogers <mikeal.rogers@gmail.com>
+// Henrik Skupin <hskupin@mozilla.com>
+// Aaron Train <atrain@mozilla.com>
+//
+// Alternatively, the contents of this file may be used under the terms of
+// either the GNU General Public License Version 2 or later (the "GPL"), or
+// the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+// in which case the provisions of the GPL or the LGPL are applicable instead
+// of those above. If you wish to allow use of your version of this file only
+// under the terms of either the GPL or the LGPL, and not to allow others to
+// use your version of this file under the terms of the MPL, indicate your
+// decision by deleting the provisions above and replace them with the notice
+// and other provisions required by the GPL or the LGPL. If you do not delete
+// the provisions above, a recipient may use your version of this file under
+// the terms of any one of the MPL, the GPL or the LGPL.
+//
+// ***** END LICENSE BLOCK *****
+
+var EXPORTED_SYMBOLS = ["MozMillController"];
+
+var utils = ChromeUtils.import("resource://testing-common/mozmill/utils.jsm");
+var MozMillController = function (win) {
+ this.window = win;
+
+ utils.waitFor(
+ function () {
+ return (
+ win != null &&
+ win.document.readyState == "complete" &&
+ win.location.href != "about:blank"
+ );
+ },
+ "controller(): Window could not be initialized.",
+ undefined,
+ undefined,
+ this
+ );
+};
diff --git a/comm/mail/test/browser/shared-modules/moz.build b/comm/mail/test/browser/shared-modules/moz.build
new file mode 100644
index 0000000000..d61395fac5
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/moz.build
@@ -0,0 +1,34 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+TESTING_JS_MODULES.mozmill += [
+ "AccountManagerHelpers.jsm",
+ "AddressBookHelpers.jsm",
+ "AttachmentHelpers.jsm",
+ "CloudfileHelpers.jsm",
+ "ComposeHelpers.jsm",
+ "ContentTabHelpers.jsm",
+ "controller.jsm",
+ "CustomizationHelpers.jsm",
+ "DOMHelpers.jsm",
+ "EventUtils.jsm",
+ "FolderDisplayHelpers.jsm",
+ "JunkHelpers.jsm",
+ "KeyboardHelpers.jsm",
+ "MockObjectHelpers.jsm",
+ "MouseEventHelpers.jsm",
+ "NewMailAccountHelpers.jsm",
+ "NNTPHelpers.jsm",
+ "NotificationBoxHelpers.jsm",
+ "OpenPGPTestUtils.jsm",
+ "PrefTabHelpers.jsm",
+ "PromptHelpers.jsm",
+ "QuickFilterBarHelpers.jsm",
+ "SearchWindowHelpers.jsm",
+ "SubscribeWindowHelpers.jsm",
+ "utils.jsm",
+ "ViewHelpers.jsm",
+ "WindowHelpers.jsm",
+]
diff --git a/comm/mail/test/browser/shared-modules/utils.jsm b/comm/mail/test/browser/shared-modules/utils.jsm
new file mode 100644
index 0000000000..9605adfdca
--- /dev/null
+++ b/comm/mail/test/browser/shared-modules/utils.jsm
@@ -0,0 +1,130 @@
+// ***** BEGIN LICENSE BLOCK *****
+// Version: MPL 1.1/GPL 2.0/LGPL 2.1
+//
+// The contents of this file are subject to the Mozilla Public License Version
+// 1.1 (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+// http://www.mozilla.org/MPL/
+//
+// Software distributed under the License is distributed on an "AS IS" basis,
+// WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+// for the specific language governing rights and limitations under the
+// License.
+//
+// The Original Code is Mozilla Corporation Code.
+//
+// The Initial Developer of the Original Code is
+// Adam Christian.
+// Portions created by the Initial Developer are Copyright (C) 2008
+// the Initial Developer. All Rights Reserved.
+//
+// Contributor(s):
+// Adam Christian <adam.christian@gmail.com>
+// Mikeal Rogers <mikeal.rogers@gmail.com>
+// Henrik Skupin <hskupin@mozilla.com>
+//
+// Alternatively, the contents of this file may be used under the terms of
+// either the GNU General Public License Version 2 or later (the "GPL"), or
+// the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+// in which case the provisions of the GPL or the LGPL are applicable instead
+// of those above. If you wish to allow use of your version of this file only
+// under the terms of either the GPL or the LGPL, and not to allow others to
+// use your version of this file under the terms of the MPL, indicate your
+// decision by deleting the provisions above and replace them with the notice
+// and other provisions required by the GPL or the LGPL. If you do not delete
+// the provisions above, a recipient may use your version of this file under
+// the terms of any one of the MPL, the GPL or the LGPL.
+//
+// ***** END LICENSE BLOCK *****
+
+var EXPORTED_SYMBOLS = ["sleep", "TimeoutError", "waitFor"];
+
+var hwindow = Services.appShell.hiddenDOMWindow;
+
+/**
+ * Sleep for the given amount of milliseconds
+ *
+ * @param {number} milliseconds
+ * Sleeps the given number of milliseconds
+ */
+function sleep(milliseconds) {
+ // We basically just call this once after the specified number of milliseconds
+ var timeup = false;
+ function wait() {
+ timeup = true;
+ }
+ hwindow.setTimeout(wait, milliseconds);
+
+ var thread = Services.tm.currentThread;
+ while (!timeup) {
+ thread.processNextEvent(true);
+ }
+}
+
+/**
+ * TimeoutError
+ *
+ * Error object used for timeouts
+ */
+function TimeoutError(message, fileName, lineNumber) {
+ var err = new Error();
+ if (err.stack) {
+ this.stack = err.stack;
+ }
+ this.message = message === undefined ? err.message : message;
+ this.fileName = fileName === undefined ? err.fileName : fileName;
+ this.lineNumber = lineNumber === undefined ? err.lineNumber : lineNumber;
+}
+TimeoutError.prototype = new Error();
+TimeoutError.prototype.constructor = TimeoutError;
+TimeoutError.prototype.name = "TimeoutError";
+
+/**
+ * Waits for the callback evaluates to true
+ *
+ * @param callback Function that returns true when the waiting thould end.
+ * @param message {string or function} A message to throw if the callback didn't
+ * succeed until the timeout. Use a function
+ * if the message is to show some object state
+ * after the end of the wait (not before wait).
+ * @param timeout Milliseconds to wait until callback succeeds.
+ * @param interval Milliseconds to 'sleep' between checks of callback.
+ * @param thisObject (optional) 'this' to be passed into the callback.
+ */
+function waitFor(callback, message, timeout, interval, thisObject) {
+ timeout = timeout || 5000;
+ interval = interval || 100;
+
+ var self = { counter: 0, result: callback.call(thisObject) };
+
+ function wait() {
+ self.counter += interval;
+ self.result = callback.call(thisObject);
+ }
+
+ var timeoutInterval = hwindow.setInterval(wait, interval);
+ var thread = Services.tm.currentThread;
+
+ while (!self.result && self.counter < timeout) {
+ thread.processNextEvent(true);
+ }
+
+ hwindow.clearInterval(timeoutInterval);
+
+ if (self.counter >= timeout) {
+ let messageText;
+ if (message) {
+ if (typeof message === "function") {
+ messageText = message();
+ } else {
+ messageText = message;
+ }
+ } else {
+ messageText = "waitFor: Timeout exceeded for '" + callback + "'";
+ }
+
+ throw new TimeoutError(messageText);
+ }
+
+ return true;
+}