diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/test/browser/shared-modules | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
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; +} |