diff options
Diffstat (limited to 'comm/mail/test/browser/shared-modules/ComposeHelpers.jsm')
-rw-r--r-- | comm/mail/test/browser/shared-modules/ComposeHelpers.jsm | 2430 |
1 files changed, 2430 insertions, 0 deletions
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` + ); + } +} |