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/base/test/browser | |
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 'comm/mail/base/test/browser')
70 files changed, 25890 insertions, 0 deletions
diff --git a/comm/mail/base/test/browser/browser-detachedWindows.ini b/comm/mail/base/test/browser/browser-detachedWindows.ini new file mode 100644 index 0000000000..5932a9b682 --- /dev/null +++ b/comm/mail/base/test/browser/browser-detachedWindows.ini @@ -0,0 +1,15 @@ +[DEFAULT] +head = head.js +prefs = + mail.provider.suppress_dialog_on_startup=true + mail.spellcheck.inline=false + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.database.global.indexer.enabled=false + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +support-files = files/** + +[browser_detachedWindows.js] +skip-if = debug diff --git a/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini b/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini new file mode 100644 index 0000000000..63963b8145 --- /dev/null +++ b/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini @@ -0,0 +1,17 @@ +[DEFAULT] +head = head.js +prefs = + mail.provider.suppress_dialog_on_startup=true + mail.spellcheck.inline=false + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.database.global.indexer.enabled=false + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank + mail.tabs.drawInTitlebar=false +subsuite = thunderbird +support-files = + head_spacesToolbar.js +skip-if = os == 'mac' + +[browser_spacesToolbar_drawBelowTitlebar.js] diff --git a/comm/mail/base/test/browser/browser-drawInTitlebar.ini b/comm/mail/base/test/browser/browser-drawInTitlebar.ini new file mode 100644 index 0000000000..0b73400c5a --- /dev/null +++ b/comm/mail/base/test/browser/browser-drawInTitlebar.ini @@ -0,0 +1,17 @@ +[DEFAULT] +head = head.js +prefs = + mail.provider.suppress_dialog_on_startup=true + mail.spellcheck.inline=false + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.database.global.indexer.enabled=false + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank + mail.tabs.drawInTitlebar=true +subsuite = thunderbird +support-files = + head_spacesToolbar.js +skip-if = os == 'mac' + +[browser_spacesToolbar_drawInTitlebar.js] diff --git a/comm/mail/base/test/browser/browser.ini b/comm/mail/base/test/browser/browser.ini new file mode 100644 index 0000000000..4ff3a5d866 --- /dev/null +++ b/comm/mail/base/test/browser/browser.ini @@ -0,0 +1,66 @@ +[DEFAULT] +head = head.js +prefs = + mail.biff.show_alert=false + mail.provider.suppress_dialog_on_startup=true + mail.spellcheck.inline=false + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +support-files = files/** + +[browser_3paneTelemetry.js] +[browser_archive.js] +[browser_browserContext.js] +tags = contextmenu webextensions +[browser_browserRequestWindow.js] +[browser_cardsView.js] +[browser_editMenu.js] +skip-if = os == 'mac' +[browser_fileMenu.js] +skip-if = os == 'mac' +[browser_folderPaneContext.js] +tags = contextmenu +[browser_folderTreeProperties.js] +[browser_folderTreeQuirks.js] +[browser_formPickers.js] +tags = webextensions +[browser_goMenu.js] +skip-if = os == 'mac' +[browser_interactionTelemetry.js] +[browser_linkHandler.js] +[browser_mailContext.js] +tags = contextmenu +[browser_mailTabsAndWindows.js] +[browser_markAsRead.js] +[browser_menulist.js] +skip-if = os == 'mac' +[browser_messageMenu.js] +skip-if = os == 'mac' +[browser_navigation.js] +[browser_orderableTreeListbox.js] +[browser_paneFocus.js] +[browser_paneSplitter.js] +[browser_preferDisplayName.js] +[browser_searchMessages.js] +[browser_smartFolderDelete.js] +[browser_spacesToolbar.js] +[browser_spacesToolbarCustomize.js] +[browser_selectionWidgetController.js] +[browser_statusFeedback.js] +[browser_tabIcon.js] +[browser_tagsMode.js] +[browser_threads.js] +[browser_threadTreeDeleting.js] +[browser_threadTreeQuirks.js] +[browser_threadTreeSorting.js] +[browser_toolsMenu.js] +skip-if = os == 'mac' +[browser_treeListbox.js] +[browser_treeView.js] +[browser_viewMenu.js] +skip-if = os == 'mac' +[browser_webSearchTelemetry.js] +[browser_zoom.js] diff --git a/comm/mail/base/test/browser/browser_3paneTelemetry.js b/comm/mail/base/test/browser/browser_3paneTelemetry.js new file mode 100644 index 0000000000..84af064e84 --- /dev/null +++ b/comm/mail/base/test/browser/browser_3paneTelemetry.js @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +var tabmail = document.getElementById("tabmail"); +var folders = {}; + +add_setup(async function () { + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + let rootFolder = account.incomingServer.rootFolder; + + for (let type of ["Drafts", "SentMail", "Templates", "Junk", "Archive"]) { + rootFolder.createSubfolder(`telemetry${type}`, null); + folders[type] = rootFolder.getChildNamed(`telemetry${type}`); + folders[type].setFlag(Ci.nsMsgFolderFlags[type]); + } + rootFolder.createSubfolder("telemetryPlain", null); + folders.Other = rootFolder.getChildNamed("telemetryPlain"); + + let { paneLayout } = tabmail.currentAbout3Pane; + let folderPaneVisibleAtStart = paneLayout.folderPaneVisible; + let messagePaneVisibleAtStart = paneLayout.messagePaneVisible; + + registerCleanupFunction(function () { + MailServices.accounts.removeAccount(account, false); + tabmail.closeOtherTabs(0); + if (paneLayout.folderPaneVisible != folderPaneVisibleAtStart) { + goDoCommand("cmd_toggleFolderPane"); + } + if (paneLayout.messagePaneVisible != messagePaneVisibleAtStart) { + goDoCommand("cmd_toggleMessagePane"); + } + }); +}); + +add_task(async function testFolderOpen() { + Services.telemetry.clearScalars(); + + let about3Pane = tabmail.currentAbout3Pane; + about3Pane.displayFolder(folders.Other.URI); + + let scalarName = "tb.mails.folder_opened"; + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 1); + + about3Pane.displayFolder(folders.Templates.URI); + about3Pane.displayFolder(folders.Other.URI); + + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 2); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 1); + + about3Pane.displayFolder(folders.Junk.URI); + about3Pane.displayFolder(folders.Other.URI); + + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 3); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 1); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Junk", 1); + + about3Pane.displayFolder(folders.Junk.URI); + about3Pane.displayFolder(folders.Templates.URI); + about3Pane.displayFolder(folders.Archive.URI); + about3Pane.displayFolder(folders.Other.URI); + + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 4); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 2); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Archive", 1); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Junk", 2); +}); + +add_task(async function testPaneVisibility() { + let { paneLayout, displayFolder } = tabmail.currentAbout3Pane; + displayFolder(folders.Other.URI); + // Make the folder pane and message pane visible initially. + if (!paneLayout.folderPaneVisible) { + goDoCommand("cmd_toggleFolderPane"); + } + if (!paneLayout.messagePaneVisible) { + goDoCommand("cmd_toggleMessagePane"); + } + // The scalar is updated by switching to the folder tab, so open another tab. + window.openContentTab("about:mozilla"); + + Services.telemetry.clearScalars(); + + tabmail.switchToTab(0); + + let scalarName = "tb.ui.configuration.pane_visibility"; + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "folderPane", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + "messagePane", + true + ); + + // Hide the folder pane. + goDoCommand("cmd_toggleFolderPane"); + tabmail.switchToTab(1); + tabmail.switchToTab(0); + + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + "folderPane", + false + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + "messagePane", + true + ); + + // Hide the message pane. + goDoCommand("cmd_toggleMessagePane"); + tabmail.switchToTab(1); + tabmail.switchToTab(0); + + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + "folderPane", + false + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + "messagePane", + false + ); + + // Show both panes again. + goDoCommand("cmd_toggleFolderPane"); + goDoCommand("cmd_toggleMessagePane"); + tabmail.switchToTab(1); + tabmail.switchToTab(0); + + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "folderPane", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + "messagePane", + true + ); + + // Close the extra tab. + tabmail.closeOtherTabs(0); +}); diff --git a/comm/mail/base/test/browser/browser_archive.js b/comm/mail/base/test/browser/browser_archive.js new file mode 100644 index 0000000000..6a84aff45a --- /dev/null +++ b/comm/mail/base/test/browser/browser_archive.js @@ -0,0 +1,98 @@ +/* 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/. */ + +/* globals messenger */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const tabmail = document.getElementById("tabmail"); +const about3Pane = tabmail.currentAbout3Pane; +const { threadTree } = about3Pane; + +add_setup(async function () { + Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false); + // Create an account for the test. + MailServices.accounts.createLocalMailAccount(); + const account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + + // Remove test account on cleanup. + registerCleanupFunction(() => { + // This test should create mailbox://nobody@Local%20Folders/Archives/2000. + // Tests following this one may attempt to create a folder at the same URI + // and will fail because our folder lookup code is a mess. Renaming should + // prevent that. + let archiveFolder = rootFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Archive + ); + archiveFolder?.subFolders[0]?.rename("archive2000", null); + archiveFolder?.rename("archiveArchives", null); + + MailServices.accounts.removeAccount(account, false); + // Clear the undo and redo stacks to avoid side-effects on + // tests expecting them to start in a cleared state. + messenger.transactionManager.clear(); + Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true); + }); + + // Create a folder for the account to store test messages. + const rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("test-archive", null); + const testFolder = rootFolder + .getChildNamed("test-archive") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + // Generate test messages. + const generator = new MessageGenerator(); + testFolder.addMessageBatch( + generator + .makeMessages({ count: 2, msgsPerThread: 2 }) + .map(message => message.toMboxString()) + ); + + // Use the test folder. + about3Pane.displayFolder(testFolder.URI); +}); + +/** + * Tests undoing after archiving a thread. + */ +add_task(async function testArchiveUndo() { + let row = threadTree.getRowAtIndex(0); + + // Simulate a click on the row's subject line to select the row. + const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".thread-card-subject-container"), + { clickCount: 1 }, + about3Pane + ); + await selectPromise; + + // Make sure the thread is selected + Assert.ok( + row.classList.contains("selected"), + "The thread row should be selected" + ); + + // Archive the message. + EventUtils.synthesizeKey("a"); + + // Make sure the thread was removed from the thread tree. + await TestUtils.waitForCondition( + () => threadTree.getRowAtIndex(0) === null, + "The thread tree should not have any row" + ); + + // Undo the operation. + EventUtils.synthesizeKey("z", { accelKey: true }); + + // Make sure the thread makes it back to the thread tree. + await TestUtils.waitForCondition( + () => threadTree.getRowAtIndex(0) !== null, + "The thread should have returned back from the archive" + ); +}); diff --git a/comm/mail/base/test/browser/browser_browserContext.js b/comm/mail/base/test/browser/browser_browserContext.js new file mode 100644 index 0000000000..2691eba80e --- /dev/null +++ b/comm/mail/base/test/browser/browser_browserContext.js @@ -0,0 +1,398 @@ +/* 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/. */ + +/* eslint-env webextensions */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const TEST_DOCUMENT_URL = + "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html"; +const TEST_MESSAGE_URL = + "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.eml"; +const TEST_IMAGE_URL = + "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png"; + +let about3Pane, testFolder; + +async function getImageArrayBuffer() { + let response = await fetch(TEST_IMAGE_URL); + let blob = await response.blob(); + + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.addEventListener("loadend", event => { + resolve(event.target.result); + }); + reader.readAsArrayBuffer(blob); + }); +} + +function checkMenuitems(menu, ...expectedItems) { + if (expectedItems.length == 0) { + // Menu should not be shown. + Assert.equal(menu.state, "closed"); + return; + } + + Assert.notEqual(menu.state, "closed"); + + let actualItems = []; + for (let item of menu.children) { + if ( + ["menu", "menuitem", "menugroup"].includes(item.localName) && + !item.hidden + ) { + actualItems.push(item.id); + } + } + Assert.deepEqual(actualItems, expectedItems); +} + +async function checkABrowser(browser, doc = browser.ownerDocument) { + if ( + browser.webProgress?.isLoadingDocument || + !browser.currentURI || + browser.currentURI?.spec == "about:blank" + ) { + await BrowserTestUtils.browserLoaded( + browser, + undefined, + url => url != "about:blank" + ); + } + + let browserContext = doc.getElementById("browserContext"); + let isMac = AppConstants.platform == "macosx"; + let isWebPage = + browser.currentURI.schemeIs("http") || browser.currentURI.schemeIs("https"); + let isExtensionPage = browser.currentURI.schemeIs("moz-extension"); + + // Just some text. + + let shownPromise = BrowserTestUtils.waitForEvent( + browserContext, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "p", + { type: "contextmenu" }, + browser + ); + await shownPromise; + + let expectedContextItems = []; + if (isWebPage || isExtensionPage) { + if (isMac) { + // Mac has the nav items directly in the context menu and not in the horizontal + // context-navigation menugroup. + expectedContextItems.push( + "browserContext-back", + "browserContext-forward", + "browserContext-reload" + ); + } else { + expectedContextItems.push("context-navigation"); + checkMenuitems( + doc.getElementById("context-navigation"), + "browserContext-back", + "browserContext-forward", + "browserContext-reload" + ); + } + } + if (isWebPage) { + expectedContextItems.push("browserContext-openInBrowser"); + } + expectedContextItems.push("browserContext-selectall"); + checkMenuitems(browserContext, ...expectedContextItems); + browserContext.hidePopup(); + + // A link. + + shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a", + { type: "contextmenu" }, + browser + ); + await shownPromise; + checkMenuitems( + browserContext, + "browserContext-openLinkInBrowser", + "browserContext-selectall", + "browserContext-copylink", + "browserContext-savelink" + ); + browserContext.hidePopup(); + + // A text input widget. + + await BrowserTestUtils.synthesizeMouseAtCenter("input", {}, browser); + shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "input", + { type: "contextmenu" }, + browser + ); + await shownPromise; + checkMenuitems( + browserContext, + "browserContext-undo", + "browserContext-cut", + "browserContext-copy", + "browserContext-paste", + "browserContext-selectall", + "browserContext-spell-check-enabled" + ); + browserContext.hidePopup(); + + // An image. Also checks Save Image As works. + + shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "img", + { type: "contextmenu" }, + browser + ); + await shownPromise; + checkMenuitems( + browserContext, + "browserContext-selectall", + "browserContext-copyimage", + "browserContext-saveimage" + ); + + let pickerPromise = new Promise(resolve => { + SpecialPowers.MockFilePicker.init(window); + SpecialPowers.MockFilePicker.showCallback = picker => { + resolve(picker.defaultString); + return Ci.nsIFilePicker.returnCancel; + }; + }); + browserContext.activateItem(doc.getElementById("browserContext-saveimage")); + Assert.equal(await pickerPromise, "tb-logo.png"); + SpecialPowers.MockFilePicker.cleanup(); +} + +add_setup(async function () { + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + let rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("browserContextFolder", null); + testFolder = rootFolder + .getChildNamed("browserContextFolder") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + let message = await fetch(TEST_MESSAGE_URL).then(r => r.text()); + testFolder.addMessageBatch([message]); + let messages = new MessageGenerator().makeMessages({ count: 5 }); + let messageStrings = messages.map(message => message.toMboxString()); + testFolder.addMessageBatch(messageStrings); + + about3Pane = document.getElementById("tabmail").currentAbout3Pane; + about3Pane.restoreState({ + folderURI: testFolder.URI, + messagePaneVisible: true, + }); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function testMessagePane() { + about3Pane.messagePane.displayWebPage(TEST_DOCUMENT_URL); + await checkABrowser(about3Pane.webBrowser, document); + about3Pane.messagePane.clearWebPage(); +}); + +add_task(async function testContentTab() { + let tab = window.openContentTab(TEST_DOCUMENT_URL); + await checkABrowser(tab.browser); + + let tabmail = document.getElementById("tabmail"); + tabmail.closeTab(tab); +}); + +add_task(async function testExtensionTab() { + let extension = ExtensionTestUtils.loadExtension({ + background: async () => { + await browser.tabs.create({ url: "sampleContent.html" }); + browser.test.notifyPass("ready"); + }, + files: { + "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + "tb-logo.png": await getImageArrayBuffer(), + }, + }); + + await extension.startup(); + await extension.awaitFinish("ready"); + + let tabmail = document.getElementById("tabmail"); + await checkABrowser(tabmail.tabInfo[1].browser); + tabmail.closeOtherTabs(tabmail.tabInfo[0]); + + await extension.unload(); +}); + +add_task(async function testExtensionPopupWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background: async () => { + await browser.windows.create({ + url: "sampleContent.html", + type: "popup", + width: 800, + height: 500, + }); + browser.test.notifyPass("ready"); + }, + files: { + "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + "tb-logo.png": await getImageArrayBuffer(), + }, + }); + + await extension.startup(); + await extension.awaitFinish("ready"); + + let extensionPopup = Services.wm.getMostRecentWindow("mail:extensionPopup"); + // extensionPopup.xhtml needs time to initialise properly. + await new Promise(resolve => extensionPopup.setTimeout(resolve, 500)); + await checkABrowser(extensionPopup.document.getElementById("requestFrame")); + await BrowserTestUtils.closeWindow(extensionPopup); + + await extension.unload(); +}); + +add_task(async function testExtensionBrowserAction() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + "tb-logo.png": await getImageArrayBuffer(), + }, + manifest: { + applications: { + gecko: { + id: "browsercontext@mochi.test", + }, + }, + browser_action: { + default_popup: "sampleContent.html", + }, + }, + }); + + await extension.startup(); + + let { panel, browser } = await openExtensionPopup( + window, + "ext-browsercontext\\@mochi.test" + ); + await TestUtils.waitForCondition( + () => browser.clientWidth > 100, + "waiting for browser to resize" + ); + await checkABrowser(browser); + panel.hidePopup(); + + await extension.unload(); +}); + +add_task(async function testExtensionComposeAction() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + "tb-logo.png": await getImageArrayBuffer(), + }, + manifest: { + applications: { + gecko: { + id: "browsercontext@mochi.test", + }, + }, + compose_action: { + default_popup: "sampleContent.html", + }, + }, + }); + + await extension.startup(); + + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + params.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + let composeWindowPromise = BrowserTestUtils.domWindowOpened(); + MailServices.compose.OpenComposeWindowWithParams(null, params); + let composeWindow = await composeWindowPromise; + await BrowserTestUtils.waitForEvent(composeWindow, "load"); + + let { panel, browser } = await openExtensionPopup( + composeWindow, + "browsercontext_mochi_test-composeAction-toolbarbutton" + ); + await checkABrowser(browser); + panel.hidePopup(); + + await extension.unload(); + await BrowserTestUtils.closeWindow(composeWindow); +}); + +add_task(async function testExtensionMessageDisplayAction() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + "tb-logo.png": await getImageArrayBuffer(), + }, + manifest: { + applications: { + gecko: { + id: "browsercontext@mochi.test", + }, + }, + message_display_action: { + default_popup: "sampleContent.html", + }, + }, + }); + + await extension.startup(); + + let messageWindowPromise = BrowserTestUtils.domWindowOpened(); + window.MsgOpenNewWindowForMessage([...testFolder.messages][0]); + let messageWindow = await messageWindowPromise; + let { target: aboutMessage } = await BrowserTestUtils.waitForEvent( + messageWindow, + "aboutMessageLoaded" + ); + + let { panel, browser } = await openExtensionPopup( + aboutMessage, + "browsercontext_mochi_test-messageDisplayAction-toolbarbutton" + ); + await checkABrowser(browser); + panel.hidePopup(); + + await extension.unload(); + await BrowserTestUtils.closeWindow(messageWindow); +}); diff --git a/comm/mail/base/test/browser/browser_browserRequestWindow.js b/comm/mail/base/test/browser/browser_browserRequestWindow.js new file mode 100644 index 0000000000..47309e5fc6 --- /dev/null +++ b/comm/mail/base/test/browser/browser_browserRequestWindow.js @@ -0,0 +1,74 @@ +/* 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/. */ + +/* import-globals-from ../../content/browserRequest.js */ + +/** + * Open the browserRequest window. + * + * @returns {{cancelledPromise: Promise, requestWindow: DOMWindow}} + */ +async function openBrowserRequestWindow() { + let onCancelled; + let cancelledPromise = new Promise(resolve => { + onCancelled = resolve; + }); + let requestWindow = await new Promise(resolve => { + Services.ww.openWindow( + null, + "chrome://messenger/content/browserRequest.xhtml", + null, + "chrome,private,centerscreen,width=980,height=750", + { + url: "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html", + cancelled() { + onCancelled(); + }, + loaded(window, webProgress) { + resolve(window); + }, + } + ); + }); + return { cancelledPromise, requestWindow }; +} + +add_task(async function test_urlBar() { + let { requestWindow, cancelledPromise } = await openBrowserRequestWindow(); + + let browser = requestWindow.getBrowser(); + await BrowserTestUtils.browserLoaded(browser); + ok(browser, "Got a browser from global getBrowser function"); + + let urlBar = requestWindow.document.getElementById("headerMessage"); + is(urlBar.value, browser.currentURI.spec, "Initial page is shown in URL bar"); + + let redirect = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, "about:blank"); + await redirect; + is(urlBar.value, "about:blank", "URL bar value follows browser"); + + const closeEvent = new Event("close"); + requestWindow.dispatchEvent(closeEvent); + await BrowserTestUtils.closeWindow(requestWindow); + await cancelledPromise; +}); + +add_task(async function test_cancelWithEsc() { + let { requestWindow, cancelledPromise } = await openBrowserRequestWindow(); + + EventUtils.synthesizeKey("VK_ESCAPE", {}, requestWindow); + await cancelledPromise; +}); + +add_task(async function test_cancelWithAccelW() { + let { requestWindow, cancelledPromise } = await openBrowserRequestWindow(); + + EventUtils.synthesizeKey( + "w", + { [AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"]: true }, + requestWindow + ); + await cancelledPromise; +}); diff --git a/comm/mail/base/test/browser/browser_cardsView.js b/comm/mail/base/test/browser/browser_cardsView.js new file mode 100644 index 0000000000..462e21fba3 --- /dev/null +++ b/comm/mail/base/test/browser/browser_cardsView.js @@ -0,0 +1,248 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +const { click_through_appmenu } = ChromeUtils.import( + "resource://testing-common/mozmill/WindowHelpers.jsm" +); + +let tabmail = document.getElementById("tabmail"); +let about3Pane = tabmail.currentAbout3Pane; +let { threadPane, threadTree } = about3Pane; +let rootFolder, testFolder, testMessages, displayContext, displayButton; + +add_setup(async function () { + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("cardsView", null); + testFolder = rootFolder + .getChildNamed("cardsView") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + let generator = new MessageGenerator(); + testFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + testMessages = [...testFolder.messages]; + + about3Pane.displayFolder(testFolder.URI); + about3Pane.paneLayout.messagePaneVisible = false; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + about3Pane.paneLayout.messagePaneVisible = true; + about3Pane.folderTree.focus(); + }); +}); + +add_task(async function testSwitchToCardsView() { + Assert.ok( + threadTree.getAttribute("rows") == "thread-card", + "The tree view should have a card layout" + ); + + click_through_appmenu( + [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }], + { id: "appmenu_messagePaneClassic" }, + window + ); + + await BrowserTestUtils.waitForCondition( + () => threadTree.getAttribute("rows") == "thread-card", + "The tree view should not switch to a table layout" + ); + + displayContext = about3Pane.document.getElementById( + "threadPaneDisplayContext" + ); + displayButton = about3Pane.document.getElementById("threadPaneDisplayButton"); + let shownPromise = BrowserTestUtils.waitForEvent( + displayContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane); + await shownPromise; + + Assert.ok( + displayContext + .querySelector("#threadPaneCardsView") + .getAttribute("checked"), + "The cards view menuitem should be checked" + ); + + let hiddenPromise = BrowserTestUtils.waitForEvent( + displayContext, + "popuphidden" + ); + displayContext.activateItem( + displayContext.querySelector("#threadPaneTableView") + ); + await BrowserTestUtils.waitForCondition( + () => threadTree.getAttribute("rows") == "thread-row", + "The tree view switched to a table layout" + ); + EventUtils.synthesizeKey("KEY_Escape", {}); + await hiddenPromise; + + click_through_appmenu( + [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }], + { id: "appmenu_messagePaneVertical" }, + window + ); + + await BrowserTestUtils.waitForCondition( + () => threadTree.getAttribute("rows") == "thread-row", + "The tree view should not switch to a card layout" + ); + + Assert.equal( + threadTree.table.body.getAttribute("role"), + "tree", + "The message list table should be presented as Tree View" + ); + Assert.equal( + threadTree.getRowAtIndex(0).getAttribute("role"), + "treeitem", + "The message row should be presented as Tree Item" + ); + + displayContext = about3Pane.document.getElementById( + "threadPaneDisplayContext" + ); + displayButton = about3Pane.document.getElementById("threadPaneDisplayButton"); + shownPromise = BrowserTestUtils.waitForEvent(displayContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane); + await shownPromise; + + Assert.ok( + displayContext + .querySelector("#threadPaneTableView") + .getAttribute("checked"), + "The table view menuitem should be checked" + ); + + hiddenPromise = BrowserTestUtils.waitForEvent(displayContext, "popuphidden"); + displayContext.activateItem( + displayContext.querySelector("#threadPaneCardsView") + ); + await BrowserTestUtils.waitForCondition( + () => threadTree.getAttribute("rows") == "thread-card", + "The tree view switched to a card layout" + ); + EventUtils.synthesizeKey("KEY_Escape", {}); + await hiddenPromise; + + Assert.equal( + threadTree.getAttribute("rows"), + "thread-card", + "tree view in cards layout" + ); + Assert.equal( + threadTree.table.body.getAttribute("role"), + "tree", + "The message list table should remain as Tree View" + ); + Assert.equal( + threadTree.getRowAtIndex(0).getAttribute("role"), + "treeitem", + "The message row should remain as Tree Item" + ); + + let row = threadTree.getRowAtIndex(0); + let star = row.querySelector(".button-star"); + Assert.ok(BrowserTestUtils.is_visible(star), "star icon should be visible"); + let tag = row.querySelector(".tag-icon"); + Assert.ok(BrowserTestUtils.is_hidden(tag), "tag icon should be hidden"); + let attachment = row.querySelector(".attachment-icon"); + Assert.ok( + BrowserTestUtils.is_hidden(attachment), + "attachment icon should be hidden" + ); + + // Switching to horizontal view shouldn't affect the list layout. + click_through_appmenu( + [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }], + { id: "appmenu_messagePaneClassic" }, + window + ); + + Assert.equal( + threadTree.getAttribute("rows"), + "thread-card", + "tree view in cards layout" + ); + about3Pane.folderTree.focus(); +}); + +add_task(async function testTagsInVerticalView() { + let row = threadTree.getRowAtIndex(1); + EventUtils.synthesizeMouseAtCenter(row, {}, about3Pane); + Assert.ok(row.classList.contains("selected"), "the row should be selected"); + + let tag = row.querySelector(".tag-icon"); + Assert.ok(BrowserTestUtils.is_hidden(tag), "tag icon should be hidden"); + + // Set the important tag. + EventUtils.synthesizeKey("1", {}); + Assert.ok(BrowserTestUtils.is_visible(tag), "tag icon should be visible"); + Assert.deepEqual(tag.title, "Important", "The important tag should be set"); + + let row2 = threadTree.getRowAtIndex(2); + EventUtils.synthesizeMouseAtCenter(row2, {}, about3Pane); + Assert.ok( + row2.classList.contains("selected"), + "the third row should be selected" + ); + + let tag2 = row2.querySelector(".tag-icon"); + Assert.ok(BrowserTestUtils.is_hidden(tag2), "tag icon should be hidden"); + + // Set the work tag. + EventUtils.synthesizeKey("2", {}); + Assert.ok(BrowserTestUtils.is_visible(tag2), "tag icon should be visible"); + Assert.deepEqual(tag2.title, "Work", "The work tag should be set"); + + // Switch back to a table layout and horizontal view. + let shownPromise = BrowserTestUtils.waitForEvent( + displayContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane); + await shownPromise; + + Assert.ok( + displayContext + .querySelector("#threadPaneCardsView") + .getAttribute("checked"), + "The cards view menuitem should be checked" + ); + + let hiddenPromise = BrowserTestUtils.waitForEvent( + displayContext, + "popuphidden" + ); + displayContext.activateItem( + displayContext.querySelector("#threadPaneTableView") + ); + await BrowserTestUtils.waitForCondition( + () => threadTree.getAttribute("rows") == "thread-row", + "The tree view switched to a table layout" + ); + EventUtils.synthesizeKey("KEY_Escape", {}); + await hiddenPromise; + + Assert.equal( + threadTree.getAttribute("rows"), + "thread-row", + "tree view in table layout" + ); + + await ensure_cards_view(); + about3Pane.folderTree.focus(); +}); diff --git a/comm/mail/base/test/browser/browser_detachedWindows.js b/comm/mail/base/test/browser/browser_detachedWindows.js new file mode 100644 index 0000000000..a523f4a799 --- /dev/null +++ b/comm/mail/base/test/browser/browser_detachedWindows.js @@ -0,0 +1,223 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let manager = Cc["@mozilla.org/memory-reporter-manager;1"].getService( + Ci.nsIMemoryReporterManager +); + +let tabmail = document.getElementById("tabmail"); +let testFolder; +let testMessages; + +add_setup(async function () { + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + let rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("detachedWindows", null); + testFolder = rootFolder + .getChildNamed("detachedWindows") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + testFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + testMessages = [...testFolder.messages]; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + }); + + info("Initial state:"); + await getWindows(); +}); + +add_task(async function test3PaneTab() { + info("Opening a new 3-pane tab"); + window.MsgOpenNewTabForFolders([testFolder], { + background: false, + folderPaneVisible: true, + messagePaneVisible: true, + }); + let tab = tabmail.tabInfo[1]; + await BrowserTestUtils.waitForEvent( + tab.chromeBrowser, + "aboutMessageLoaded", + true + ); + await new Promise(resolve => + tab.chromeBrowser.contentWindow.setTimeout(resolve, 500) + ); + + tab.chromeBrowser.contentWindow.threadTree.selectedIndex = 0; + await BrowserTestUtils.waitForEvent(tab.chromeBrowser, "MsgLoaded"); + await new Promise(resolve => + tab.chromeBrowser.contentWindow.setTimeout(resolve, 500) + ); + + info("Closing the tab"); + tabmail.closeOtherTabs(0); + tab = null; + + await assertNoDetachedWindows(); +}); + +add_task(async function testMessageTab() { + info("Opening a new message tab"); + window.OpenMessageInNewTab(testMessages[0], { background: false }); + let tab = tabmail.tabInfo[1]; + await BrowserTestUtils.waitForEvent( + tab.chromeBrowser, + "aboutMessageLoaded", + true + ); + await new Promise(resolve => + tab.chromeBrowser.contentWindow.setTimeout(resolve, 500) + ); + + info("Closing the tab"); + tabmail.closeOtherTabs(0); + tab = null; + + await assertNoDetachedWindows(); +}); + +add_task(async function testMessageWindow() { + info("Opening a standalone message window"); + let win = await openMessageFromFile( + new FileUtils.File(getTestFilePath("files/sampleContent.eml")) + ); + await new Promise(resolve => win.setTimeout(resolve, 500)); + + info("Closing the window"); + await BrowserTestUtils.closeWindow(win); + win = null; + + await assertNoDetachedWindows(); +}); + +add_task(async function testSearchMessagesDialog() { + info("Opening the search messages dialog"); + let about3Pane = tabmail.currentAbout3Pane; + let context = about3Pane.document.getElementById("folderPaneContext"); + let searchMessagesItem = about3Pane.document.getElementById( + "folderPaneContext-searchMessages" + ); + + let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + about3Pane.folderPane.getRowForFolder(testFolder).querySelector(".name"), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + + let searchWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + null, + w => + w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml" + ); + context.activateItem(searchMessagesItem); + let searchWindow = await searchWindowPromise; + + await new Promise(resolve => searchWindow.setTimeout(resolve, 500)); + + info("Closing the dialog"); + await BrowserTestUtils.closeWindow(searchWindow); + searchWindowPromise = null; + searchWindow = null; + + await assertNoDetachedWindows(); +}); + +add_task(async function testAddressBookTab() { + info("Opening the Address Book"); + window.toAddressBook(); + let tab = tabmail.tabInfo[1]; + await BrowserTestUtils.waitForEvent( + tab.browser, + "about-addressbook-ready", + true + ); + await new Promise(resolve => + tab.browser.contentWindow.setTimeout(resolve, 500) + ); + + info("Closing the tab"); + tabmail.closeOtherTabs(0); + tab = null; + + await assertNoDetachedWindows(); +}); + +async function getWindows() { + await new Promise(resolve => manager.minimizeMemoryUsage(resolve)); + + let windows = new Set(); + await new Promise(resolve => + manager.getReports( + (process, path, kind, units, amount, description) => { + if (path.startsWith("explicit/window-objects/top")) { + path = path.replace("top(none)", "top"); + path = path.substring(0, path.indexOf(")") + 1); + path = path.replace(/\\/g, "/"); + windows.add(path); + } + }, + null, + resolve, + null, + false + ) + ); + + for (let win of windows) { + info(win); + } + + return [...windows]; +} + +async function assertNoDetachedWindows() { + info("Remaining windows:"); + let windows = await getWindows(); + + let noDetachedWindows = true; + for (let win of windows) { + if (win.includes("detached")) { + noDetachedWindows = false; + let url = win.substring(win.indexOf("(") + 1, win.indexOf(")")); + Assert.report(true, undefined, undefined, `detached window: ${url}`); + } + } + + if (noDetachedWindows) { + Assert.report(false, undefined, undefined, "no detached windows"); + } +} + +async function openMessageFromFile(file) { + let fileURL = Services.io + .newFileURI(file) + .mutate() + .setQuery("type=application/x-message-display") + .finalize(); + + let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + window.openDialog( + "chrome://messenger/content/messageWindow.xhtml", + "_blank", + "all,chrome,dialog=no,status,toolbar", + fileURL + ); + let win = await winPromise; + await BrowserTestUtils.waitForEvent(win, "MsgLoaded"); + await TestUtils.waitForCondition(() => Services.focus.activeWindow == win); + return win; +} diff --git a/comm/mail/base/test/browser/browser_editMenu.js b/comm/mail/base/test/browser/browser_editMenu.js new file mode 100644 index 0000000000..d320a12e36 --- /dev/null +++ b/comm/mail/base/test/browser/browser_editMenu.js @@ -0,0 +1,511 @@ +/* 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/. */ + +const { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +const { nsMailServer } = ChromeUtils.import( + "resource://testing-common/mailnews/Maild.jsm" +); +const { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/PromiseTestUtils.jsm" +); + +/** @type MenuData */ +const editMenuData = { + menu_undo: { disabled: true }, + menu_redo: { disabled: true }, + menu_cut: { disabled: true }, + menu_copy: { disabled: true }, + menu_paste: { disabled: true }, + menu_delete: { disabled: true, l10nID: "text-action-delete" }, + menu_select: {}, + menu_SelectAll: {}, + menu_selectThread: { disabled: true }, + menu_selectFlagged: { disabled: true }, + menu_find: {}, + menu_findCmd: { disabled: true }, + menu_findAgainCmd: { disabled: true }, + searchMailCmd: {}, + glodaSearchCmd: {}, + searchAddressesCmd: {}, + menu_favoriteFolder: { disabled: true }, + menu_properties: { disabled: true }, + "calendar-properties-menuitem": { disabled: true }, +}; +if (AppConstants.platform == "linux") { + editMenuData.menu_preferences = {}; + editMenuData.menu_accountmgr = {}; +} +let helper = new MenuTestHelper("menu_Edit", editMenuData); + +let tabmail = document.getElementById("tabmail"); +let rootFolder, testFolder, testMessages, virtualFolder; +let nntpRootFolder, nntpFolder; +let imapRootFolder, imapFolder; + +add_setup(async function () { + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + window.messenger.transactionManager.clear(); + + const generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("edit menu", null); + testFolder = rootFolder + .getChildNamed("edit menu") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + testFolder.addMessageBatch( + generator.makeMessages({}).map(message => message.toMboxString()) + ); + testMessages = [...testFolder.messages]; + + rootFolder.createSubfolder("edit menu virtual", null); + virtualFolder = rootFolder.getChildNamed("edit menu virtual"); + virtualFolder.setFlag(Ci.nsMsgFolderFlags.Virtual); + let msgDatabase = virtualFolder.msgDatabase; + let folderInfo = msgDatabase.dBFolderInfo; + folderInfo.setCharProperty("searchStr", "ALL"); + folderInfo.setCharProperty("searchFolderUri", testFolder.URI); + + NNTPServer.open(); + NNTPServer.addGroup("edit.menu.newsgroup"); + let nntpAccount = MailServices.accounts.createAccount(); + nntpAccount.incomingServer = MailServices.accounts.createIncomingServer( + `${nntpAccount.key}user`, + "localhost", + "nntp" + ); + nntpAccount.incomingServer.port = NNTPServer.port; + nntpRootFolder = nntpAccount.incomingServer.rootFolder; + nntpRootFolder.createSubfolder("edit.menu.newsgroup", null); + nntpFolder = nntpRootFolder.getChildNamed("edit.menu.newsgroup"); + + IMAPServer.open(); + let imapAccount = MailServices.accounts.createAccount(); + imapAccount.addIdentity(MailServices.accounts.createIdentity()); + imapAccount.incomingServer = MailServices.accounts.createIncomingServer( + `${imapAccount.key}user`, + "localhost", + "imap" + ); + imapAccount.incomingServer.port = IMAPServer.port; + imapAccount.incomingServer.username = "user"; + imapAccount.incomingServer.password = "password"; + imapAccount.incomingServer.deleteModel = Ci.nsMsgImapDeleteModels.IMAPDelete; + imapRootFolder = imapAccount.incomingServer.rootFolder; + imapFolder = imapRootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox); + IMAPServer.addMessages(imapFolder, generator.makeMessages({})); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + MailServices.accounts.removeAccount(nntpAccount, false); + MailServices.accounts.removeAccount(imapAccount, false); + NNTPServer.close(); + IMAPServer.close(); + }); +}); + +add_task(async function test3PaneTab() { + await helper.testAllItems("mail3PaneTab"); +}); + +/** + * Tests the "Delete" item in the menu. This item calls cmd_delete, which does + * various things depending on the current context. + */ +add_task(async function testDeleteItem() { + let about3Pane = tabmail.currentAbout3Pane; + let { displayFolder, folderTree, paneLayout, threadTree } = about3Pane; + paneLayout.messagePaneVisible = true; + + // Focus on the folder tree and check that an NNTP account shows + // "Unsubscribe Folder". The account can't be deleted this way so the menu + // item should be disabled. + + folderTree.focus(); + displayFolder(nntpRootFolder); + await helper.testItems({ + menu_delete: { + disabled: true, + l10nID: "text-action-delete", + }, + }); + + // Check that an NNTP folder shows "Unsubscribe Folder". Then check that + // calling cmd_delete actually attempts to unsubscribe the folder. + + displayFolder(nntpFolder); + await Promise.all([ + BrowserTestUtils.promiseAlertDialog("cancel"), + helper.activateItem("menu_delete", { + l10nID: "menu-edit-unsubscribe-newsgroup", + }), + ]); + + // Check that a mail account shows "Delete Folder". The account can't be + // deleted this way so the menu item should be disabled. + + displayFolder(rootFolder); + await helper.testItems({ + menu_delete: { + disabled: true, + l10nID: "text-action-delete", + }, + }); + + // Check that focus on the folder tree and a mail folder shows "Delete + // Folder". Then check that calling cmd_delete actually attempts to delete + // the folder. + + displayFolder(testFolder); + await Promise.all([ + BrowserTestUtils.promiseAlertDialog("cancel"), + helper.activateItem("menu_delete", { l10nID: "menu-edit-delete-folder" }), + ]); + await new Promise(resolve => setTimeout(resolve)); + + // Focus the Quick Filter bar text box and check the menu item shows "Delete". + + goDoCommand("cmd_showQuickFilterBar"); + about3Pane.document.getElementById("qfb-qs-textbox").focus(); + await helper.testItems({ + menu_delete: { + disabled: true, + l10nID: "text-action-delete", + }, + }); + + // Focus on the thread tree with no messages selected and check the menu + // item shows "Delete". + + threadTree.table.body.focus(); + threadTree.selectedIndex = -1; + await helper.testItems({ + menu_delete: { + disabled: true, + l10nID: "text-action-delete", + }, + }); + + // With one message selected check the menu item shows "Delete Message". + + threadTree.selectedIndex = 0; + await helper.testItems({ + menu_delete: { + l10nID: "menu-edit-delete-messages", + l10nArgs: { count: 1 }, + }, + }); + + // Focus the Quick Filter bar text box and check the menu item shows "Delete". + // It should *not* show "Delete Message" even though one is selected. + + about3Pane.document.getElementById("qfb-qs-textbox").focus(); + await helper.testItems({ + menu_delete: { + disabled: true, + l10nID: "text-action-delete", + }, + }); + + // Focus on about:message and check the menu item shows "Delete Message". + + about3Pane.messageBrowser.focus(); + await helper.testItems({ + menu_delete: { + l10nID: "menu-edit-delete-messages", + l10nArgs: { count: 1 }, + }, + }); + + // With multiple messages selected and check the menu item shows "Delete + // Messages". Then check that calling cmd_delete actually deletes the messages. + + threadTree.table.body.focus(); + threadTree.selectedIndices = [0, 1, 3]; + await Promise.all([ + new PromiseTestUtils.promiseFolderEvent( + testFolder, + "DeleteOrMoveMsgCompleted" + ), + helper.activateItem("menu_delete", { + l10nID: "menu-edit-delete-messages", + l10nArgs: { count: 3 }, + }), + ]); + + // Load an IMAP folder with the "just mark deleted" model. With no messages + // selected check the menu item shows "Delete". + + // Note that for each flag change, we wait for a second for the change to + // be sent to the IMAP server. + + displayFolder(imapFolder); + await TestUtils.waitForCondition(() => threadTree.view.rowCount == 10); + let dbView = about3Pane.gDBView; + threadTree.selectedIndex = -1; + await helper.testItems({ + menu_delete: { + disabled: true, + l10nID: "text-action-delete", + }, + }); + + // With one message selected check the menu item shows "Delete Message". + // Then check that calling cmd_delete sets the flag on the message. + + threadTree.selectedIndex = 0; + let message = dbView.getMsgHdrAt(0); + await helper.activateItem("menu_delete", { + l10nID: "menu-edit-delete-messages", + l10nArgs: { count: 1 }, + }); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + Assert.ok( + message.flags & Ci.nsMsgMessageFlags.IMAPDeleted, + "IMAPDeleted flag should be set" + ); + + // Check the menu item now shows "Undelete Message" and that calling + // cmd_delete clears the flag on the message. + + // The delete operation moved the selection, go back. + threadTree.selectedIndex = 0; + await helper.activateItem("menu_delete", { + l10nID: "menu-edit-undelete-messages", + l10nArgs: { count: 1 }, + }); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + Assert.ok( + !(message.flags & Ci.nsMsgMessageFlags.IMAPDeleted), + "IMAPDeleted flag should be cleared on message 0" + ); + + // Check the menu item again shows "Delete Message". + + await helper.testItems({ + menu_delete: { + l10nID: "menu-edit-delete-messages", + l10nArgs: { count: 1 }, + }, + }); + + // With multiple messages selected and check the menu item shows "Delete + // Messages". Check that calling cmd_delete sets the flag on the messages. + + threadTree.selectedIndices = [1, 3, 5]; + let messages = dbView.getSelectedMsgHdrs(); + await helper.testItems({ + menu_delete: { + l10nID: "menu-edit-delete-messages", + l10nArgs: { count: 3 }, + }, + }); + await helper.activateItem("menu_delete"); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + Assert.ok( + messages.every(m => m.flags & Ci.nsMsgMessageFlags.IMAPDeleted), + "IMAPDeleted flags should be set" + ); + + // Check the menu item now shows "Undelete Messages" and that calling + // cmd_delete clears the flag on the messages. + + threadTree.selectedIndices = [1, 3, 5]; + await helper.activateItem("menu_delete", { + l10nID: "menu-edit-undelete-messages", + l10nArgs: { count: 3 }, + }); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1000)); + Assert.ok( + messages.every(m => !(m.flags & Ci.nsMsgMessageFlags.IMAPDeleted)), + "IMAPDeleted flags should be cleared" + ); + + // Check the menu item again shows "Delete Messages". + + threadTree.selectedIndices = [1, 3, 5]; + await helper.testItems({ + menu_delete: { + l10nID: "menu-edit-delete-messages", + l10nArgs: { count: 3 }, + }, + }); + + Services.focus.focusedWindow = window; +}).__skipMe = AppConstants.DEBUG; // Too unreliable. + +/** + * Tests the "Favorite Folder" item in the menu is checked/unchecked as expected. + */ +add_task(async function testFavoriteFolderItem() { + let { displayFolder } = tabmail.currentAbout3Pane; + + testFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite); + displayFolder(testFolder); + await helper.testItems({ menu_favoriteFolder: {} }); + + testFolder.setFlag(Ci.nsMsgFolderFlags.Favorite); + await helper.activateItem("menu_favoriteFolder", { checked: true }); + Assert.ok( + !testFolder.getFlag(Ci.nsMsgFolderFlags.Favorite), + "favorite flag should be cleared" + ); + + await helper.activateItem("menu_favoriteFolder", {}); + Assert.ok( + testFolder.getFlag(Ci.nsMsgFolderFlags.Favorite), + "favorite flag should be set" + ); + + testFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite); +}); + +/** + * Tests the "Properties" item in the menu is enabled/disabled as expected, + * and has the correct label. + */ +add_task(async function testPropertiesItem() { + async function testDialog(folder, data, which = "folderProps.xhtml") { + await Promise.all([ + BrowserTestUtils.promiseAlertDialog( + undefined, + `chrome://messenger/content/${which}`, + { + callback(win) { + Assert.ok(true, "folder properties dialog opened"); + Assert.equal( + win.gMsgFolder.URI, + folder.URI, + "dialog has correct folder" + ); + win.document.querySelector("dialog").getButton("cancel").click(); + }, + } + ), + helper.activateItem("menu_properties", data), + ]); + await SimpleTest.promiseFocus(window); + } + + let { displayFolder } = tabmail.currentAbout3Pane; + + displayFolder(rootFolder); + await helper.testItems({ + menu_properties: { disabled: true, l10nID: "menu-edit-properties" }, + }); + + displayFolder(testFolder); + await testDialog(testFolder, { l10nID: "menu-edit-folder-properties" }); + + displayFolder(virtualFolder); + await testDialog( + virtualFolder, + { l10nID: "menu-edit-folder-properties" }, + "virtualFolderProperties.xhtml" + ); + + displayFolder(imapRootFolder); + await helper.testItems({ + menu_properties: { disabled: true, l10nID: "menu-edit-properties" }, + }); + + displayFolder(imapFolder); + await testDialog(imapFolder, { l10nID: "menu-edit-folder-properties" }); + + displayFolder(nntpRootFolder); + await helper.testItems({ + menu_properties: { disabled: true, l10nID: "menu-edit-properties" }, + }); + + displayFolder(nntpFolder); + await testDialog(nntpFolder, { l10nID: "menu-edit-newsgroup-properties" }); +}); + +var NNTPServer = { + open() { + let { NNTP_RFC977_handler, NntpDaemon } = ChromeUtils.import( + "resource://testing-common/mailnews/Nntpd.jsm" + ); + + this.daemon = new NntpDaemon(); + this.server = new nsMailServer( + daemon => new NNTP_RFC977_handler(daemon), + this.daemon + ); + this.server.start(); + + registerCleanupFunction(() => this.close()); + }, + + close() { + this.server.stop(); + }, + + get port() { + return this.server.port; + }, + + addGroup(group) { + return this.daemon.addGroup(group); + }, +}; + +var IMAPServer = { + open() { + let { ImapDaemon, ImapMessage, IMAP_RFC3501_handler } = ChromeUtils.import( + "resource://testing-common/mailnews/Imapd.jsm" + ); + IMAPServer.ImapMessage = ImapMessage; + + this.daemon = new ImapDaemon(); + this.server = new nsMailServer( + daemon => new IMAP_RFC3501_handler(daemon), + this.daemon + ); + this.server.start(); + + registerCleanupFunction(() => this.close()); + }, + close() { + this.server.stop(); + }, + get port() { + return this.server.port; + }, + + addMessages(folder, messages) { + let fakeFolder = IMAPServer.daemon.getMailbox(folder.name); + messages.forEach(message => { + if (typeof message != "string") { + message = message.toMessageString(); + } + let msgURI = Services.io.newURI( + "data:text/plain;base64," + btoa(message) + ); + let imapMsg = new IMAPServer.ImapMessage( + msgURI.spec, + fakeFolder.uidnext++, + [] + ); + fakeFolder.addMessage(imapMsg); + }); + + return new Promise(resolve => + mailTestUtils.updateFolderAndNotify(folder, resolve) + ); + }, +}; diff --git a/comm/mail/base/test/browser/browser_fileMenu.js b/comm/mail/base/test/browser/browser_fileMenu.js new file mode 100644 index 0000000000..927628eb93 --- /dev/null +++ b/comm/mail/base/test/browser/browser_fileMenu.js @@ -0,0 +1,137 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +/** @type MenuData */ +const fileMenuData = { + menu_New: {}, + menu_newNewMsgCmd: {}, + "calendar-new-event-menuitem": { hidden: true }, + "calendar-new-task-menuitem": { hidden: true }, + menu_newFolder: { hidden: ["mailMessageTab", "contentTab"] }, + menu_newVirtualFolder: { hidden: ["mailMessageTab", "contentTab"] }, + newCreateEmailAccountMenuItem: {}, + newMailAccountMenuItem: {}, + newIMAccountMenuItem: {}, + newFeedAccountMenuItem: {}, + newNewsgroupAccountMenuItem: {}, + "calendar-new-calendar-menuitem": {}, + menu_newCard: {}, + newIMContactMenuItem: { disabled: true }, + menu_Open: {}, + openMessageFileMenuitem: {}, + "calendar-open-calendar-file-menuitem": {}, + menu_close: {}, + "calendar-save-menuitem": { hidden: true }, + "calendar-save-and-close-menuitem": { hidden: true }, + menu_saveAs: {}, + menu_saveAsFile: { disabled: ["mail3PaneTab", "contentTab"] }, + menu_saveAsTemplate: { disabled: ["mail3PaneTab", "contentTab"] }, + menu_getAllNewMsg: {}, + menu_getnewmsgs_all_accounts: { disabled: true }, + menu_getnewmsgs_current_account: { disabled: true }, + menu_getnextnmsg: { hidden: true }, + menu_sendunsentmsgs: { disabled: true }, + menu_subscribe: { disabled: true }, + menu_deleteFolder: { disabled: true }, + menu_renameFolder: { disabled: true }, + menu_compactFolder: { disabled: ["mailMessageTab", "contentTab"] }, + menu_emptyTrash: { disabled: ["mailMessageTab", "contentTab"] }, + offlineMenuItem: {}, + goOfflineMenuItem: {}, + menu_synchronizeOffline: {}, + menu_settingsOffline: { disabled: true }, + menu_downloadFlagged: { disabled: true }, + menu_downloadSelected: { disabled: true }, + printMenuItem: { disabled: ["mail3PaneTab"] }, + menu_FileQuitItem: {}, +}; +let helper = new MenuTestHelper("menu_File", fileMenuData); + +let tabmail = document.getElementById("tabmail"); +let inboxFolder, plainFolder, rootFolder, testMessages, trashFolder; + +add_setup(async function () { + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("file menu inbox", null); + inboxFolder = rootFolder + .getChildNamed("file menu inbox") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox); + inboxFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + testMessages = [...inboxFolder.messages]; + + rootFolder.createSubfolder("file menu plain", null); + plainFolder = rootFolder.getChildNamed("file menu plain"); + + trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash); + + window.OpenMessageInNewTab(testMessages[0], { background: true }); + await BrowserTestUtils.waitForEvent( + tabmail.tabInfo[1].chromeBrowser, + "MsgLoaded" + ); + + window.openTab("contentTab", { + url: "https://example.com/", + background: true, + }); + + registerCleanupFunction(() => { + tabmail.closeOtherTabs(0); + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function test3PaneTab() { + tabmail.currentAbout3Pane.displayFolder(rootFolder); + await helper.testAllItems("mail3PaneTab"); + + tabmail.currentAbout3Pane.displayFolder(inboxFolder); + await helper.testItems({ + menu_deleteFolder: { disabled: true }, + menu_renameFolder: { disabled: true }, + menu_compactFolder: { disabled: false }, + menu_emptyTrash: {}, + }); + + tabmail.currentAbout3Pane.displayFolder(plainFolder); + await helper.testItems({ + menu_deleteFolder: { disabled: false }, + menu_renameFolder: { disabled: false }, + menu_compactFolder: { disabled: false }, + menu_emptyTrash: {}, + }); + + tabmail.currentAbout3Pane.displayFolder(trashFolder); + await helper.testItems({ + menu_deleteFolder: { disabled: true }, + menu_renameFolder: { disabled: true }, + menu_compactFolder: { disabled: false }, + menu_emptyTrash: {}, + }); +}); + +add_task(async function testMessageTab() { + tabmail.switchToTab(1); + await helper.testAllItems("mailMessageTab"); +}); + +add_task(async function testContentTab() { + tabmail.switchToTab(2); + await helper.testAllItems("contentTab"); +}); diff --git a/comm/mail/base/test/browser/browser_folderPaneContext.js b/comm/mail/base/test/browser/browser_folderPaneContext.js new file mode 100644 index 0000000000..843965c966 --- /dev/null +++ b/comm/mail/base/test/browser/browser_folderPaneContext.js @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm"); + +let servers = ["server", "rssRoot"]; +let realFolders = ["plain", "inbox", "junk", "trash", "rssFeed"]; + +const folderPaneContextData = { + "folderPaneContext-getMessages": [...servers, "rssFeed"], + "folderPaneContext-pauseAllUpdates": ["rssRoot"], + "folderPaneContext-pauseUpdates": ["rssFeed"], + "folderPaneContext-openNewTab": true, + "folderPaneContext-openNewWindow": true, + "folderPaneContext-searchMessages": [...servers, ...realFolders], + "folderPaneContext-subscribe": ["rssRoot", "rssFeed"], + "folderPaneContext-newsUnsubscribe": [], + "folderPaneContext-new": [...servers, ...realFolders], + "folderPaneContext-remove": ["plain", "junk", "virtual", "rssFeed"], + "folderPaneContext-rename": ["plain", "junk", "virtual", "rssFeed"], + "folderPaneContext-compact": [...servers, ...realFolders], + "folderPaneContext-markMailFolderAllRead": [...realFolders, "virtual"], + "folderPaneContext-markNewsgroupAllRead": [], + "folderPaneContext-emptyTrash": ["trash"], + "folderPaneContext-emptyJunk": ["junk"], + "folderPaneContext-sendUnsentMessages": [], + "folderPaneContext-favoriteFolder": [...realFolders, "virtual"], + "folderPaneContext-properties": [...realFolders, "virtual"], + "folderPaneContext-markAllFoldersRead": [...servers], + "folderPaneContext-settings": [...servers], + "folderPaneContext-manageTags": ["tags"], + "folderPaneContext-moveMenu": ["plain", "virtual", "rssFeed"], + "folderPaneContext-copyMenu": ["plain", "rssFeed"], +}; + +let about3Pane = document.getElementById("tabmail").currentAbout3Pane; +let context = about3Pane.document.getElementById("folderPaneContext"); +let rootFolder, + plainFolder, + inboxFolder, + junkFolder, + trashFolder, + virtualFolder; +let rssRootFolder, rssFeedFolder; +let tagsFolder; + +add_setup(async function () { + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + `${account.key}user`, + "localhost", + "pop3" + ); + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + + plainFolder = rootFolder.createLocalSubfolder("folderPaneContextFolder"); + inboxFolder = rootFolder.createLocalSubfolder("folderPaneContextInbox"); + inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox); + junkFolder = rootFolder.createLocalSubfolder("folderPaneContextJunk"); + junkFolder.setFlag(Ci.nsMsgFolderFlags.Junk); + trashFolder = rootFolder.createLocalSubfolder("folderPaneContextTrash"); + trashFolder.setFlag(Ci.nsMsgFolderFlags.Trash); + + virtualFolder = rootFolder.createLocalSubfolder("folderPaneContextVirtual"); + virtualFolder.setFlag(Ci.nsMsgFolderFlags.Virtual); + let msgDatabase = virtualFolder.msgDatabase; + let folderInfo = msgDatabase.dBFolderInfo; + folderInfo.setCharProperty("searchStr", "ALL"); + folderInfo.setCharProperty("searchFolderUri", plainFolder.URI); + + let rssAccount = FeedUtils.createRssAccount("rss"); + rssRootFolder = rssAccount.incomingServer.rootFolder; + FeedUtils.subscribeToFeed( + "https://example.org/browser/comm/mail/base/test/browser/files/rss.xml?folderPaneContext", + rssRootFolder, + null + ); + await TestUtils.waitForCondition(() => rssRootFolder.subFolders.length == 2); + rssFeedFolder = rssRootFolder.getChildNamed("Test Feed"); + + about3Pane.folderPane.activeModes = ["all", "tags"]; + tagsFolder = about3Pane.folderPane._modes.tags._tagsFolder.subFolders[0]; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + MailServices.accounts.removeAccount(rssAccount, false); + about3Pane.folderPane.activeModes = ["all"]; + }); +}); + +add_task(async function () { + // Check the menu has the right items for the selected folder. + leftClickOn(rootFolder); + await rightClickOn(rootFolder, "server"); + leftClickOn(plainFolder); + await rightClickOn(plainFolder, "plain"); + leftClickOn(inboxFolder); + await rightClickOn(inboxFolder, "inbox"); + leftClickOn(junkFolder); + await rightClickOn(junkFolder, "junk"); + leftClickOn(trashFolder); + await rightClickOn(trashFolder, "trash"); + leftClickOn(virtualFolder); + await rightClickOn(virtualFolder, "virtual"); + leftClickOn(rssRootFolder); + await rightClickOn(rssRootFolder, "rssRoot"); + leftClickOn(rssFeedFolder); + await rightClickOn(rssFeedFolder, "rssFeed"); + leftClickOn(tagsFolder); + await rightClickOn(tagsFolder, "tags"); + + // Check the menu has the right items when the selected folder is not the + // folder that was right-clicked on. + await rightClickOn(rootFolder, "server"); + leftClickOn(rootFolder); + await rightClickOn(plainFolder, "plain"); + await rightClickOn(inboxFolder, "inbox"); + await rightClickOn(junkFolder, "junk"); + await rightClickOn(trashFolder, "trash"); + await rightClickOn(virtualFolder, "virtual"); + await rightClickOn(rssRootFolder, "rssRoot"); + await rightClickOn(rssFeedFolder, "rssFeed"); + await rightClickOn(tagsFolder, "tags"); +}); + +function leftClickOn(folder) { + EventUtils.synthesizeMouseAtCenter( + about3Pane.folderPane.getRowForFolder(folder).querySelector(".name"), + {}, + about3Pane + ); +} + +async function rightClickOn(folder, mode) { + let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + about3Pane.folderPane.getRowForFolder(folder).querySelector(".name"), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(context, mode); + let hiddenPromise = BrowserTestUtils.waitForEvent(context, "popuphidden"); + context.hidePopup(); + await hiddenPromise; +} + +function checkMenuitems(menu, mode) { + if (!mode) { + // Menu should not be shown. + Assert.equal(menu.state, "closed"); + return; + } + + Assert.notEqual(menu.state, "closed"); + + let expectedItems = []; + for (let [id, modes] of Object.entries(folderPaneContextData)) { + if (modes === true || modes.includes(mode)) { + expectedItems.push(id); + } + } + + let actualItems = []; + for (let item of menu.children) { + if (["menu", "menuitem"].includes(item.localName) && !item.hidden) { + actualItems.push(item.id); + } + } + + let notFoundItems = expectedItems.filter(i => !actualItems.includes(i)); + if (notFoundItems.length) { + Assert.report( + true, + undefined, + undefined, + "items expected but not found: " + notFoundItems.join(", ") + ); + } + + let unexpectedItems = actualItems.filter(i => !expectedItems.includes(i)); + if (unexpectedItems.length) { + Assert.report( + true, + undefined, + undefined, + "items found but not expected: " + unexpectedItems.join(", ") + ); + } + + if (notFoundItems.length + unexpectedItems.length == 0) { + Assert.report(false, undefined, undefined, `all ${mode} items are correct`); + } +} diff --git a/comm/mail/base/test/browser/browser_folderTreeProperties.js b/comm/mail/base/test/browser/browser_folderTreeProperties.js new file mode 100644 index 0000000000..6fdfa4f2ad --- /dev/null +++ b/comm/mail/base/test/browser/browser_folderTreeProperties.js @@ -0,0 +1,236 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { FolderTreeProperties } = ChromeUtils.import( + "resource:///modules/FolderTreeProperties.jsm" +); + +const TRASH_COLOR_HEX = "#52507c"; +const TRASH_COLOR_RGB = "rgb(82, 80, 124)"; +const VIRTUAL_COLOR_HEX = "#cd26a5"; +const VIRTUAL_COLOR_RGB = "rgb(205, 38, 165)"; + +let about3Pane = document.getElementById("tabmail").currentAbout3Pane; +let { folderPane, folderTree, threadTree } = about3Pane; +let rootFolder, trashFolder, trashFolderRows, virtualFolder, virtualFolderRows; + +add_setup(async function () { + Services.prefs.setIntPref("ui.prefersReducedMotion", 1); + FolderTreeProperties.resetColors(); + + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + `${account.key}user`, + "localhost", + "none" + ); + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash); + trashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite); + + rootFolder.createSubfolder("folderTreePropsVirtual", null); + virtualFolder = rootFolder.getChildNamed("folderTreePropsVirtual"); + virtualFolder.flags |= + Ci.nsMsgFolderFlags.Virtual | Ci.nsMsgFolderFlags.Favorite; + let virtualFolderInfo = virtualFolder.msgDatabase.dBFolderInfo; + virtualFolderInfo.setCharProperty("searchStr", "ALL"); + virtualFolderInfo.setCharProperty("searchFolderUri", trashFolder.URI); + + // Test the colours change in all folder modes, not just the current one. + folderPane.activeModes = ["all", "favorite"]; + await new Promise(resolve => setTimeout(resolve)); + for (let row of folderTree.querySelectorAll(".collapsed")) { + folderTree.expandRow(row); + } + + trashFolderRows = { + all: folderPane.getRowForFolder(trashFolder, "all"), + favorite: folderPane.getRowForFolder(trashFolder, "favorite"), + }; + virtualFolderRows = { + all: folderPane.getRowForFolder(virtualFolder, "all"), + favorite: folderPane.getRowForFolder(virtualFolder, "favorite"), + }; + + registerCleanupFunction(async () => { + folderPane.activeModes = ["all"]; + MailServices.accounts.removeAccount(account, false); + FolderTreeProperties.resetColors(); + Services.prefs.clearUserPref("ui.prefersReducedMotion"); + }); +}); + +add_task(async function testNormalFolderColors() { + await subtestColors(trashFolderRows, TRASH_COLOR_HEX, TRASH_COLOR_RGB); +}); + +add_task(async function testVirtualFolderColors() { + await subtestColors(virtualFolderRows, VIRTUAL_COLOR_HEX, VIRTUAL_COLOR_RGB); +}); + +async function subtestColors(rows, defaultHex, defaultRGB) { + assertRowColors(rows, defaultRGB); + + // Accept the dialog without changing anything. + let dialog = await openFolderProperties(rows.all); + dialog.assertColor(defaultHex); + await dialog.accept(); + assertRowColors(rows, defaultRGB); + + // Cancel the dialog without changing anything. + dialog = await openFolderProperties(rows.favorite); + dialog.assertColor(defaultHex); + await dialog.cancel(); + assertRowColors(rows, defaultRGB); + + // Set a non-default color. + dialog = await openFolderProperties(rows.all); + dialog.assertColor(defaultHex); + await dialog.setColor("#ff6600"); + assertRowColors(rows, "rgb(255, 102, 0)"); + await dialog.accept(); + assertRowColors(rows, "rgb(255, 102, 0)"); + + // Reset to the default color. + dialog = await openFolderProperties(rows.favorite); + dialog.assertColor("#ff6600"); + dialog.resetColor(); + dialog.assertColor(defaultHex); + assertRowColors(rows, defaultRGB); + await dialog.accept(); + assertRowColors(rows, defaultRGB); + + // Set a color, but cancel the dialog. + dialog = await openFolderProperties(rows.all); + dialog.assertColor(defaultHex); + await dialog.setColor("#ffcc00"); + assertRowColors(rows, "rgb(255, 204, 0)"); + await dialog.cancel(); + assertRowColors(rows, defaultRGB); + + // Set a color, but reset it and accept the dialog. + dialog = await openFolderProperties(rows.favorite); + dialog.assertColor(defaultHex); + await dialog.setColor("#00cc00"); + assertRowColors(rows, "rgb(0, 204, 0)"); + dialog.resetColor(); + dialog.assertColor(defaultHex); + assertRowColors(rows, defaultRGB); + await dialog.accept(); + assertRowColors(rows, defaultRGB); + + // Set a non-default color. + dialog = await openFolderProperties(rows.all); + dialog.assertColor(defaultHex); + await dialog.setColor("#0000cc"); + assertRowColors(rows, "rgb(0, 0, 204)"); + await dialog.accept(); + assertRowColors(rows, "rgb(0, 0, 204)"); + + // Accept the dialog without changing anything. + dialog = await openFolderProperties(rows.favorite); + dialog.assertColor("#0000cc"); + await dialog.accept(); + assertRowColors(rows, "rgb(0, 0, 204)"); + + // Cancel the dialog without changing anything. + dialog = await openFolderProperties(rows.all); + dialog.assertColor("#0000cc"); + await dialog.cancel(); + assertRowColors(rows, "rgb(0, 0, 204)"); + + // Reset the color and cancel the dialog. + dialog = await openFolderProperties(rows.favorite); + dialog.assertColor("#0000cc"); + dialog.resetColor(); + dialog.assertColor(defaultHex); + assertRowColors(rows, defaultRGB); + await dialog.cancel(); + assertRowColors(rows, "rgb(0, 0, 204)"); + + // Reset the color, pick a new one, and accept the dialog. + dialog = await openFolderProperties(rows.all); + dialog.assertColor("#0000cc"); + dialog.resetColor(); + dialog.assertColor(defaultHex); + assertRowColors(rows, defaultRGB); + await dialog.setColor("#0066cc"); + assertRowColors(rows, "rgb(0, 102, 204)"); + await dialog.accept(); + assertRowColors(rows, "rgb(0, 102, 204)"); +} + +async function openFolderProperties(row) { + let folderPaneContext = + about3Pane.document.getElementById("folderPaneContext"); + let folderPaneContextProperties = about3Pane.document.getElementById( + "folderPaneContext-properties" + ); + + let shownPromise = BrowserTestUtils.waitForEvent( + folderPaneContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".name"), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + + let windowOpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + folderPaneContext.activateItem(folderPaneContextProperties); + let dialogWindow = await windowOpenedPromise; + let dialogDocument = dialogWindow.document; + + let colorButton = dialogDocument.getElementById("color"); + let resetColorButton = dialogDocument.getElementById("resetColor"); + let folderPropertiesDialog = dialogDocument.querySelector("dialog"); + + return { + assertColor(hex) { + Assert.equal(colorButton.value, hex); + }, + async setColor(hex) { + SpecialPowers.MockColorPicker.init(dialogWindow); + SpecialPowers.MockColorPicker.returnColor = hex; + let inputPromise = BrowserTestUtils.waitForEvent(colorButton, "input"); + EventUtils.synthesizeMouseAtCenter(colorButton, {}, dialogWindow); + await inputPromise; + SpecialPowers.MockColorPicker.cleanup(); + }, + resetColor() { + EventUtils.synthesizeMouseAtCenter(resetColorButton, {}, dialogWindow); + }, + async accept() { + let windowClosedPromise = BrowserTestUtils.domWindowClosed(dialogWindow); + EventUtils.synthesizeMouseAtCenter( + folderPropertiesDialog.getButton("accept"), + {}, + dialogWindow + ); + await windowClosedPromise; + }, + async cancel() { + let windowClosedPromise = BrowserTestUtils.domWindowClosed(dialogWindow); + EventUtils.synthesizeMouseAtCenter( + folderPropertiesDialog.getButton("cancel"), + {}, + dialogWindow + ); + await windowClosedPromise; + }, + }; +} + +function assertRowColors(rows, rgb) { + // Always move the focus away from the row otherwise we might get the selected + // state which turns the icon white. + threadTree.table.body.focus(); + for (let row of Object.values(rows)) { + Assert.equal(getComputedStyle(row.querySelector(".icon")).stroke, rgb); + } +} diff --git a/comm/mail/base/test/browser/browser_folderTreeQuirks.js b/comm/mail/base/test/browser/browser_folderTreeQuirks.js new file mode 100644 index 0000000000..885be9242c --- /dev/null +++ b/comm/mail/base/test/browser/browser_folderTreeQuirks.js @@ -0,0 +1,1450 @@ +/* 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/. */ + +const { MessageGenerator, SyntheticMessageSet } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +const { MessageInjection } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageInjection.jsm" +); +const { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/PromiseTestUtils.jsm" +); +const { VirtualFolderHelper } = ChromeUtils.import( + "resource:///modules/VirtualFolderWrapper.jsm" +); + +let about3Pane = document.getElementById("tabmail").currentAbout3Pane; +let { folderPane, folderTree, threadTree } = about3Pane; +let account, + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + moreButton, + moreContext; +let generator = new MessageGenerator(); +let messageInjection = new MessageInjection( + { + mode: "local", + }, + generator +); + +add_setup(async function () { + account = MailServices.accounts.accounts[0]; + rootFolder = account.incomingServer.rootFolder; + inboxFolder = rootFolder.getChildNamed("Inbox"); + trashFolder = rootFolder.getChildNamed("Trash"); + outboxFolder = rootFolder.getChildNamed("Outbox"); + moreButton = about3Pane.document.querySelector("#folderPaneMoreButton"); + moreContext = about3Pane.document.getElementById("folderPaneMoreContext"); + + rootFolder.createSubfolder("folderTreeQuirksA", null); + folderA = rootFolder + .getChildNamed("folderTreeQuirksA") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + folderA.createSubfolder("folderTreeQuirksB", null); + folderB = folderA + .getChildNamed("folderTreeQuirksB") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + folderB.createSubfolder("folderTreeQuirksC", null); + folderC = folderB + .getChildNamed("folderTreeQuirksC") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + messageInjection.addSetsToFolders( + [folderA, folderB, folderC], + [ + new SyntheticMessageSet(generator.makeMessages({ read: true })), + new SyntheticMessageSet(generator.makeMessages({ read: true })), + new SyntheticMessageSet(generator.makeMessages({ read: true })), + ] + ); + + Services.prefs.setIntPref("ui.prefersReducedMotion", 1); + about3Pane.paneLayout.messagePaneVisible = false; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + Services.prefs.clearUserPref("ui.prefersReducedMotion"); + folderPane.activeModes = ["all"]; + Services.xulStore.removeDocument( + "chrome://messenger/content/messenger.xhtml" + ); + about3Pane.paneLayout.messagePaneVisible = true; + }); +}); + +/** + * Tests the Favorite Folders mode. + */ +add_task(async function testFavoriteFolders() { + folderPane.activeModes = ["all", "favorite"]; + await checkModeListItems("favorite", []); + + folderA.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [rootFolder, folderA]); + + folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", []); + + folderB.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [rootFolder, folderA, folderB]); + + folderB.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", []); + + folderC.setFlag(Ci.nsMsgFolderFlags.Favorite); + folderA.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]); + + folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]); + + folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", []); +}); + +/** + * Tests the compact Favorite Folders mode. + */ +add_task(async function testCompactFavoriteFolders() { + folderPane.activeModes = ["all", "favorite"]; + folderPane.isCompact = true; + await checkModeListItems("favorite", []); + + folderA.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [folderA]); + + folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", []); + + folderB.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [folderB]); + + folderB.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", []); + + folderC.setFlag(Ci.nsMsgFolderFlags.Favorite); + folderA.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [folderA, folderC]); // c, a + + folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [folderC]); + + folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", []); + + // Test with multiple accounts. + + let foo = MailServices.accounts.createAccount(); + foo.incomingServer = MailServices.accounts.createIncomingServer( + `${foo.key}user`, + "localhost", + "none" + ); + let fooRootFolder = foo.incomingServer.rootFolder; + let fooTrashFolder = fooRootFolder.getChildNamed("Trash"); + + fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [fooTrashFolder]); + + folderC.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [fooTrashFolder, folderC]); + + MailServices.accounts.reorderAccounts([account.key, foo.key]); + await checkModeListItems("favorite", [folderC, fooTrashFolder]); + + fooTrashFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [folderC]); + + fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [folderC, fooTrashFolder]); + + folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite); + await checkModeListItems("favorite", [fooTrashFolder]); + + // Clean up. + + MailServices.accounts.removeAccount(foo, false); + await checkModeListItems("favorite", []); + folderPane.isCompact = false; +}); + +/** + * Tests the Unread Folders mode. + */ +add_task(async function testUnreadFolders() { + let folderAMessages = [...folderA.messages]; + let folderBMessages = [...folderB.messages]; + let folderCMessages = [...folderC.messages]; + + folderPane.activeModes = ["all", "unread"]; + await checkModeListItems("unread", []); + + folderAMessages[0].markRead(false); + await checkModeListItems("unread", [rootFolder, folderA]); + + folderAMessages[1].markRead(false); + folderAMessages[2].markRead(false); + await checkModeListItems("unread", [rootFolder, folderA]); + + window.MsgMarkAllRead([folderA]); + await checkModeListItems("unread", [rootFolder, folderA]); + + folderAMessages[0].markRead(false); + folderBMessages[0].markRead(false); + await checkModeListItems("unread", [rootFolder, folderA, folderB]); + + folderCMessages[0].markRead(false); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); + + folderBMessages[0].markRead(true); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); + + folderAMessages[0].markRead(true); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); + + folderCMessages[0].markRead(true); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); + + folderCMessages[0].markRead(false); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); + + folderCMessages[1].markRead(false); + folderCMessages[2].markRead(false); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); + + window.MsgMarkAllRead([folderC]); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); +}); + +/** + * Tests the compact Unread Folders mode. + */ +add_task(async function testCompactUnreadFolders() { + let folderAMessages = [...folderA.messages]; + let folderBMessages = [...folderB.messages]; + let folderCMessages = [...folderC.messages]; + + folderPane.activeModes = ["all", "unread"]; + folderPane.isCompact = true; + await checkModeListItems("unread", []); + + folderAMessages[0].markRead(false); + await checkModeListItems("unread", [folderA]); + + folderAMessages[1].markRead(false); + folderAMessages[2].markRead(false); + await checkModeListItems("unread", [folderA]); + + window.MsgMarkAllRead([folderA]); + await checkModeListItems("unread", [folderA]); + + folderAMessages[0].markRead(false); + folderBMessages[0].markRead(false); + await checkModeListItems("unread", [folderA, folderB]); + + folderCMessages[0].markRead(false); + await checkModeListItems("unread", [folderA, folderB, folderC]); + + folderBMessages[0].markRead(true); + await checkModeListItems("unread", [folderA, folderB, folderC]); + + folderAMessages[0].markRead(true); + await checkModeListItems("unread", [folderA, folderB, folderC]); + + folderCMessages[0].markRead(true); + await checkModeListItems("unread", [folderA, folderB, folderC]); + + folderCMessages[0].markRead(false); + await checkModeListItems("unread", [folderA, folderB, folderC]); + + folderCMessages[1].markRead(false); + folderCMessages[2].markRead(false); + await checkModeListItems("unread", [folderA, folderB, folderC]); + + window.MsgMarkAllRead([folderC]); + await checkModeListItems("unread", [folderA, folderB, folderC]); + + // Test with multiple accounts. + + let foo = MailServices.accounts.createAccount(); + foo.incomingServer = MailServices.accounts.createIncomingServer( + `${foo.key}user`, + "localhost", + "none" + ); + let fooRootFolder = foo.incomingServer.rootFolder; + let fooTrashFolder = fooRootFolder.getChildNamed("Trash"); + + let generator = new MessageGenerator(); + fooTrashFolder + .QueryInterface(Ci.nsIMsgLocalMailFolder) + .addMessage(generator.makeMessages({}).map(m => m.toMboxString())); + let fooMessages = [...fooTrashFolder.messages]; + + fooMessages[0].markRead(false); + await checkModeListItems("unread", [ + fooTrashFolder, + folderA, + folderB, + folderC, + ]); + + folderCMessages[0].markRead(false); + await checkModeListItems("unread", [ + fooTrashFolder, + folderA, + folderB, + folderC, + ]); + + MailServices.accounts.reorderAccounts([account.key, foo.key]); + await checkModeListItems("unread", [ + folderA, + folderB, + folderC, + fooTrashFolder, + ]); + + fooMessages[0].markRead(true); + await checkModeListItems("unread", [ + folderA, + folderB, + folderC, + fooTrashFolder, + ]); + + fooMessages[0].markRead(false); + await checkModeListItems("unread", [ + folderA, + folderB, + folderC, + fooTrashFolder, + ]); + + folderCMessages[0].markRead(true); + await checkModeListItems("unread", [ + folderA, + folderB, + folderC, + fooTrashFolder, + ]); + + // Clean up. + + MailServices.accounts.removeAccount(foo, false); + await checkModeListItems("unread", [folderA, folderB, folderC]); + folderPane.isCompact = false; +}); + +/** + * Tests the Smart Folders mode. + */ +add_task(async function testSmartFolders() { + folderPane.activeModes = ["smart"]; + + // Check the mode is set up correctly. + let localExtraFolders = [rootFolder, outboxFolder, folderA, folderB, folderC]; + let smartServer = MailServices.accounts.findServer( + "nobody", + "smart mailboxes", + "none" + ); + let smartInbox = smartServer.rootFolder.getChildNamed("Inbox"); + let smartInboxFolders = [smartInbox, inboxFolder]; + let otherSmartFolders = [ + smartServer.rootFolder.getChildNamed("Drafts"), + smartServer.rootFolder.getChildNamed("Templates"), + smartServer.rootFolder.getChildNamed("Sent"), + smartServer.rootFolder.getChildNamed("Archives"), + smartServer.rootFolder.getChildNamed("Junk"), + smartServer.rootFolder.getChildNamed("Trash"), + ]; + await checkModeListItems("smart", [ + ...smartInboxFolders, + ...otherSmartFolders, + trashFolder, + ...localExtraFolders, + ]); + + // Add some subfolders of existing folders. + rootFolder.createSubfolder("folderTreeQuirksX", null); + let folderX = rootFolder.getChildNamed("folderTreeQuirksX"); + inboxFolder.createSubfolder("folderTreeQuirksY", null); + let folderY = inboxFolder.getChildNamed("folderTreeQuirksY"); + folderY.createSubfolder("folderTreeQuirksYY", null); + let folderYY = folderY.getChildNamed("folderTreeQuirksYY"); + folderB.createSubfolder("folderTreeQuirksZ", null); + let folderZ = folderB.getChildNamed("folderTreeQuirksZ"); + + // Check the folders are listed in the right order. + await checkModeListItems("smart", [ + ...smartInboxFolders, + folderY, + folderYY, + ...otherSmartFolders, + trashFolder, + ...localExtraFolders, + folderZ, + folderX, + ]); + + // Check the hierarchy. + let rootRow = folderPane.getRowForFolder(rootFolder); + let inboxRow = folderPane.getRowForFolder(inboxFolder); + let trashRow = folderPane.getRowForFolder(trashFolder); + let rowB = folderPane.getRowForFolder(folderB); + let rowX = folderPane.getRowForFolder(folderX); + let rowY = folderPane.getRowForFolder(folderY); + let rowYY = folderPane.getRowForFolder(folderYY); + let rowZ = folderPane.getRowForFolder(folderZ); + Assert.equal( + rowX.parentNode.parentNode, + rootRow, + "folderX should be displayed as a child of rootFolder" + ); + Assert.equal( + rowY.parentNode.parentNode, + inboxRow, + "folderY should be displayed as a child of inboxFolder" + ); + Assert.equal( + rowYY.parentNode.parentNode, + rowY, + "folderYY should be displayed as a child of folderY" + ); + Assert.equal( + rowZ.parentNode.parentNode, + rowB, + "folderZ should be displayed as a child of folderB" + ); + + // Stop searching folderY and folderYY in the smart inbox. They should stop + // being listed under the inbox and instead appear under the root folder. + let wrappedInbox = VirtualFolderHelper.wrapVirtualFolder(smartInbox); + Assert.deepEqual(wrappedInbox.searchFolders, [ + inboxFolder, + folderY, + folderYY, + ]); + wrappedInbox.searchFolders = [inboxFolder]; + + // Check the folders are listed in the right order. + await checkModeListItems("smart", [ + ...smartInboxFolders, + ...otherSmartFolders, + trashFolder, + ...localExtraFolders, + folderZ, + folderX, + folderY, + folderYY, + ]); + + // Check the hierarchy. + rowY = folderPane.getRowForFolder(folderY); + rowYY = folderPane.getRowForFolder(folderYY); + Assert.equal( + rowY.parentNode.parentNode, + rootRow, + "folderY should be displayed as a child of the rootFolder" + ); + Assert.equal( + rowYY.parentNode.parentNode, + rowY, + "folderYY should be displayed as a child of folderY" + ); + + // Search them again. They should move back to the smart inbox section. + wrappedInbox.searchFolders = [inboxFolder, folderY, folderYY]; + + // Check the folders are listed in the right order. + await checkModeListItems("smart", [ + ...smartInboxFolders, + folderY, + folderYY, + ...otherSmartFolders, + trashFolder, + ...localExtraFolders, + folderZ, + folderX, + ]); + + // Check the hierarchy. + rowY = folderPane.getRowForFolder(folderY); + rowYY = folderPane.getRowForFolder(folderYY); + Assert.equal( + rowY.parentNode.parentNode, + inboxRow, + "folderY should be displayed as a child of inboxFolder" + ); + Assert.equal( + rowYY.parentNode.parentNode, + rowY, + "folderYY should be displayed as a child of folderY" + ); + + // Delete the added folders. + folderX.deleteSelf(null); + folderY.deleteSelf(null); + folderZ.deleteSelf(null); + folderX = trashFolder.getChildNamed("folderTreeQuirksX"); + folderY = trashFolder.getChildNamed("folderTreeQuirksY"); + folderYY = folderY.getChildNamed("folderTreeQuirksYY"); + folderZ = trashFolder.getChildNamed("folderTreeQuirksZ"); + + // Check they appear in the trash. + await checkModeListItems("smart", [ + ...smartInboxFolders, + ...otherSmartFolders, + trashFolder, + folderX, + folderY, + folderYY, + folderZ, + ...localExtraFolders, + ]); + + // Check the hierarchy. + rowX = folderPane.getRowForFolder(folderX); + rowY = folderPane.getRowForFolder(folderY); + rowYY = folderPane.getRowForFolder(folderYY); + rowZ = folderPane.getRowForFolder(folderZ); + Assert.equal( + rowX.parentNode.parentNode, + trashRow, + "folderX should be displayed as a child of trashFolder" + ); + Assert.equal( + rowY.parentNode.parentNode, + trashRow, + "folderY should be displayed as a child of trashFolder" + ); + Assert.equal( + rowYY.parentNode.parentNode, + rowY, + "folderYY should be displayed as a child of folderY" + ); + Assert.equal( + rowZ.parentNode.parentNode, + trashRow, + "folderZ should be displayed as a child of trashFolder" + ); + + // Empty the trash and check everything is back to normal. + rootFolder.emptyTrash(null, null); + await checkModeListItems("smart", [ + ...smartInboxFolders, + ...otherSmartFolders, + trashFolder, + ...localExtraFolders, + ]); +}); + +/** + * Tests that after moving a folder it is in the right place in the tree, + * with any subfolders if they should be shown. + */ +add_task(async function testFolderMove() { + rootFolder.createSubfolder("new parent", null); + let newParentFolder = rootFolder.getChildNamed("new parent"); + [...folderC.messages][6].markRead(false); + folderC.setFlag(Ci.nsMsgFolderFlags.Favorite); + + // Set up and check initial state. + + folderPane.activeModes = ["all", "unread", "favorite"]; + folderPane.isCompact = false; + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + newParentFolder, + ]); + await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]); + await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]); + + // Move `folderB` from `folderA` to `newParentFolder`. + + let copyListener = new PromiseTestUtils.PromiseCopyListener(); + MailServices.copy.copyFolder( + folderB, + newParentFolder, + true, + copyListener, + window.msgWindow + ); + await copyListener.promise; + + let movedFolderB = newParentFolder.getChildNamed("folderTreeQuirksB"); + let movedFolderC = movedFolderB.getChildNamed("folderTreeQuirksC"); + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + newParentFolder, + movedFolderB, + movedFolderC, + ]); + await checkModeListItems("unread", [ + rootFolder, + newParentFolder, + movedFolderB, + movedFolderC, + ]); + await checkModeListItems("favorite", [ + rootFolder, + newParentFolder, + movedFolderB, + movedFolderC, + ]); + + // Switch to compact mode for the return move. + + folderPane.isCompact = true; + await checkModeListItems("unread", [movedFolderC]); + await checkModeListItems("favorite", [movedFolderC]); + + // Move `movedFolderB` from `newParentFolder` back to `folderA`. + + copyListener = new PromiseTestUtils.PromiseCopyListener(); + MailServices.copy.copyFolder( + movedFolderB, + folderA, + true, + copyListener, + window.msgWindow + ); + await copyListener.promise; + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + newParentFolder, + ]); + await checkModeListItems("unread", [folderC]); + await checkModeListItems("favorite", [folderC]); + + // Clean up. + + newParentFolder.deleteSelf(null); + rootFolder.emptyTrash(null, null); + folderC.markAllMessagesRead(null); + folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite); + folderPane.isCompact = false; +}); + +/** + * Tests that after renaming a folder it is in the right place in the tree, + * with any subfolders if they should be shown. + */ +add_task(async function testFolderRename() { + let extraFolders = {}; + for (let name of ["aaa", "ggg", "zzz"]) { + rootFolder.createSubfolder(name, null); + extraFolders[name] = rootFolder + .getChildNamed(name) + .QueryInterface(Ci.nsIMsgLocalMailFolder); + extraFolders[name].addMessage(generator.makeMessage({}).toMboxString()); + extraFolders[name].setFlag(Ci.nsMsgFolderFlags.Favorite); + } + [...folderC.messages][4].markRead(false); + folderC.setFlag(Ci.nsMsgFolderFlags.Favorite); + + // Set up and check initial state. + + folderPane.activeModes = ["all", "unread", "favorite"]; + folderPane.isCompact = false; + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + extraFolders.aaa, + folderA, + folderB, + folderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + await checkModeListItems("unread", [ + rootFolder, + extraFolders.aaa, + folderA, + folderB, + folderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + await checkModeListItems("favorite", [ + rootFolder, + extraFolders.aaa, + folderA, + folderB, + folderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + + // Rename `folderA`. + + folderA.rename("renamedA", window.msgWindow); + let renamedFolderA = rootFolder.getChildNamed("renamedA"); + let renamedFolderB = renamedFolderA.getChildNamed("folderTreeQuirksB"); + let renamedFolderC = renamedFolderB.getChildNamed("folderTreeQuirksC"); + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + extraFolders.aaa, + extraFolders.ggg, + renamedFolderA, + renamedFolderB, + renamedFolderC, + extraFolders.zzz, + ]); + await checkModeListItems("unread", [ + rootFolder, + extraFolders.aaa, + extraFolders.ggg, + renamedFolderA, + renamedFolderB, + renamedFolderC, + extraFolders.zzz, + ]); + await checkModeListItems("favorite", [ + rootFolder, + extraFolders.aaa, + extraFolders.ggg, + renamedFolderA, + renamedFolderB, + renamedFolderC, + extraFolders.zzz, + ]); + + // Switch to compact mode. + + folderPane.isCompact = true; + await checkModeListItems("unread", [ + extraFolders.aaa, + renamedFolderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + await checkModeListItems("favorite", [ + extraFolders.aaa, + renamedFolderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + + // Rename the folder back to its original name. + + renamedFolderA.rename("folderTreeQuirksA", window.msgWindow); + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + extraFolders.aaa, + folderA, + folderB, + folderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + await checkModeListItems("unread", [ + extraFolders.aaa, + folderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + await checkModeListItems("favorite", [ + extraFolders.aaa, + folderC, + extraFolders.ggg, + extraFolders.zzz, + ]); + + // Clean up. + + extraFolders.aaa.deleteSelf(null); + extraFolders.ggg.deleteSelf(null); + extraFolders.zzz.deleteSelf(null); + rootFolder.emptyTrash(null, null); + folderC.markAllMessagesRead(null); + folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite); + folderPane.isCompact = false; +}); + +/** + * The creation of a virtual folder involves two "folderAdded" notifications. + * Check that only one entry in the folder tree is created. + */ +add_task(async function testSearchFolderAddedOnlyOnce() { + let context = about3Pane.document.getElementById("folderPaneContext"); + let searchMessagesItem = about3Pane.document.getElementById( + "folderPaneContext-searchMessages" + ); + let removeItem = about3Pane.document.getElementById( + "folderPaneContext-remove" + ); + + // Start searching for messages. + + let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + folderPane.getRowForFolder(rootFolder).querySelector(".name"), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + + let searchWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + null, + w => + w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml" + ); + context.activateItem(searchMessagesItem); + let searchWindow = await searchWindowPromise; + + EventUtils.synthesizeMouseAtCenter( + searchWindow.document.getElementById("searchVal0"), + {}, + searchWindow + ); + EventUtils.sendString("hovercraft", searchWindow); + + // Create a virtual folder for the search. + + let vfWindowPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + "chrome://messenger/content/virtualFolderProperties.xhtml", + { + async callback(vfWindow) { + EventUtils.synthesizeMouseAtCenter( + vfWindow.document.getElementById("name"), + {}, + vfWindow + ); + EventUtils.sendString("virtual folder", vfWindow); + EventUtils.synthesizeMouseAtCenter( + vfWindow.document.querySelector("dialog").getButton("accept"), + {}, + vfWindow + ); + }, + } + ); + EventUtils.synthesizeMouseAtCenter( + searchWindow.document.getElementById("saveAsVFButton"), + {}, + searchWindow + ); + await vfWindowPromise; + + await BrowserTestUtils.closeWindow(searchWindow); + + // Find the folder and the row for it in the tree. + + let virtualFolder = rootFolder.getChildNamed("virtual folder"); + let row = await TestUtils.waitForCondition(() => + folderPane.getRowForFolder(virtualFolder) + ); + + // Check it exists only once. + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + virtualFolder, + outboxFolder, + folderA, + folderB, + folderC, + ]); + + // Delete the virtual folder. + + shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".name"), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); + context.activateItem(removeItem); + await dialogPromise; + await new Promise(resolve => setTimeout(resolve)); + + // Check it went away. + + await checkModeListItems("all", [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + ]); +}); + +/** + * Tests deferred POP3 accounts are not displayed in All Folders mode, and + * that a change in their deferred status updates the folder tree. + */ +add_task(async function testDeferredAccount() { + let pop3Account = MailServices.accounts.createAccount(); + let pop3Server = MailServices.accounts.createIncomingServer( + `${pop3Account.key}user`, + "localhost", + "pop3" + ); + pop3Server.QueryInterface(Ci.nsIPop3IncomingServer); + pop3Account.incomingServer = pop3Server.QueryInterface( + Ci.nsIPop3IncomingServer + ); + + let pop3RootFolder = pop3Server.rootFolder; + let pop3Folders = [ + pop3RootFolder, + pop3RootFolder.getChildNamed("Inbox"), + pop3RootFolder.getChildNamed("Trash"), + ]; + let localFolders = [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + ]; + + folderPane.activeModes = ["all"]; + await checkModeListItems("all", [...pop3Folders, ...localFolders]); + + // Defer the account to Local Folders. + pop3Server.deferredToAccount = account.key; + await checkModeListItems("all", localFolders); + + // Remove and add the All mode again to check that an existing deferred + // folder is not shown when the mode initialises. + folderPane.activeModes = ["recent"]; + folderPane.activeModes = ["all"]; + await checkModeListItems("all", localFolders); + + // Stop deferring the account. + pop3Server.deferredToAccount = null; + await checkModeListItems("all", [...pop3Folders, ...localFolders]); + + MailServices.accounts.removeAccount(pop3Account, false); +}); + +/** + * We deliberately hide the special [Gmail] folder from the folder tree. + * Check that it doesn't appear when for a new or existing account. + */ +add_task(async function testGmailFolders() { + IMAPServer.open(); + // Set up a fake Gmail account. + let gmailAccount = MailServices.accounts.createAccount(); + let gmailServer = MailServices.accounts.createIncomingServer( + "user", + "localhost", + "imap" + ); + gmailServer.port = IMAPServer.port; + gmailServer.password = "password"; + gmailAccount.incomingServer = gmailServer; + + let gmailIdentity = MailServices.accounts.createIdentity(); + gmailIdentity.email = "imap@invalid"; + gmailAccount.addIdentity(gmailIdentity); + gmailAccount.defaultIdentity = gmailIdentity; + + let gmailRootFolder = gmailServer.rootFolder; + gmailServer.performExpand(window.msgWindow); + await TestUtils.waitForCondition( + () => gmailRootFolder.subFolders.length == 3, + "waiting for folders to be created" + ); + + let gmailInboxFolder = gmailRootFolder.getChildNamed("INBOX"); + let gmailTrashFolder = gmailRootFolder.getChildNamed("Trash"); + let gmailGmailFolder = gmailRootFolder.getChildNamed("[Gmail]"); + await TestUtils.waitForCondition( + () => gmailGmailFolder.subFolders.length == 1, + "waiting for All Mail folder to be created" + ); + let gmailAllMailFolder = gmailGmailFolder.getChildNamed("All Mail"); + + Assert.ok( + !folderPane._isGmailFolder(gmailRootFolder), + "_isGmailFolder should be false for the root folder" + ); + Assert.ok( + folderPane._isGmailFolder(gmailGmailFolder), + "_isGmailFolder should be true for the [Gmail] folder" + ); + Assert.ok( + !folderPane._isGmailFolder(gmailAllMailFolder), + "_isGmailFolder should be false for the All Mail folder" + ); + + Assert.equal( + folderPane._getNonGmailFolder(gmailRootFolder), + gmailRootFolder, + "_getNonGmailFolder should return the same folder for the root folder" + ); + Assert.equal( + folderPane._getNonGmailFolder(gmailGmailFolder), + gmailRootFolder, + "_getNonGmailFolder should return the root folder for the [Gmail] folder" + ); + Assert.equal( + folderPane._getNonGmailFolder(gmailAllMailFolder), + gmailAllMailFolder, + "_getNonGmailFolder should return the same folder for the All Mail folder" + ); + + Assert.equal( + folderPane._getNonGmailParent(gmailRootFolder), + null, + "_getNonGmailParent should return null for the root folder" + ); + Assert.equal( + folderPane._getNonGmailParent(gmailGmailFolder), + gmailRootFolder, + "_getNonGmailParent should return the root folder for the [Gmail] folder" + ); + Assert.equal( + folderPane._getNonGmailParent(gmailAllMailFolder), + gmailRootFolder, + "_getNonGmailParent should return the root folder for the All Mail folder" + ); + + await checkModeListItems("all", [ + gmailRootFolder, + gmailInboxFolder, + gmailAllMailFolder, + gmailTrashFolder, + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + ]); + + // The accounts didn't exist when about:3pane loaded, but we can simulate + // that by removing the mode and then re-adding it. + folderPane.activeModes = ["favorite"]; + folderPane.activeModes = ["all"]; + + await checkModeListItems("all", [ + gmailRootFolder, + gmailInboxFolder, + gmailAllMailFolder, + gmailTrashFolder, + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + ]); + + MailServices.accounts.removeAccount(gmailAccount, false); +}); + +add_task(async function testAccountOrder() { + // Make some changes to the main account so that it appears in all modes. + + [...folderA.messages][0].markRead(false); + folderA.setFlag(Ci.nsMsgFolderFlags.Favorite); + folderPane.activeModes = ["all", "smart", "unread", "favorite"]; + + let localFolders = [ + rootFolder, + inboxFolder, + trashFolder, + outboxFolder, + folderA, + folderB, + folderC, + ]; + let localExtraFolders = [rootFolder, outboxFolder, folderA, folderB, folderC]; + let smartServer = MailServices.accounts.findServer( + "nobody", + "smart mailboxes", + "none" + ); + let smartFolders = [ + smartServer.rootFolder.getChildNamed("Inbox"), + inboxFolder, + smartServer.rootFolder.getChildNamed("Drafts"), + smartServer.rootFolder.getChildNamed("Templates"), + smartServer.rootFolder.getChildNamed("Sent"), + smartServer.rootFolder.getChildNamed("Archives"), + smartServer.rootFolder.getChildNamed("Junk"), + smartServer.rootFolder.getChildNamed("Trash"), + // There are trash folders in each account, they go here. + ]; + + // Check the initial items in the folder tree. + + await checkModeListItems("all", localFolders); + await checkModeListItems("smart", [ + ...smartFolders, + trashFolder, + ...localExtraFolders, + ]); + await checkModeListItems("unread", [rootFolder, folderA]); + await checkModeListItems("favorite", [rootFolder, folderA]); + + // Create two new "none" accounts, foo and bar. + + let foo = MailServices.accounts.createAccount(); + foo.incomingServer = MailServices.accounts.createIncomingServer( + `${foo.key}user`, + "localhost", + "none" + ); + let fooRootFolder = foo.incomingServer.rootFolder; + let fooTrashFolder = fooRootFolder.getChildNamed("Trash"); + let fooOutboxFolder = fooRootFolder.getChildNamed("Outbox"); + let fooFolders = [fooRootFolder, fooTrashFolder, fooOutboxFolder]; + let fooExtraFolders = [fooRootFolder, fooOutboxFolder]; + + let bar = MailServices.accounts.createAccount(); + bar.incomingServer = MailServices.accounts.createIncomingServer( + `${bar.key}user`, + "localhost", + "none" + ); + let barRootFolder = bar.incomingServer.rootFolder; + let barTrashFolder = barRootFolder.getChildNamed("Trash"); + let barOutboxFolder = barRootFolder.getChildNamed("Outbox"); + let barFolders = [barRootFolder, barTrashFolder, barOutboxFolder]; + let barExtraFolders = [barRootFolder, barOutboxFolder]; + + let generator = new MessageGenerator(); + fooTrashFolder + .QueryInterface(Ci.nsIMsgLocalMailFolder) + .addMessage(generator.makeMessage({}).toMboxString()); + fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite); + barTrashFolder + .QueryInterface(Ci.nsIMsgLocalMailFolder) + .addMessage(generator.makeMessage({}).toMboxString()); + barTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite); + + // Check the addition of accounts has put them in the right order. + + Assert.deepEqual( + MailServices.accounts.accounts.map(a => a.key), + [foo.key, bar.key, account.key] + ); + await checkModeListItems("all", [ + ...fooFolders, + ...barFolders, + ...localFolders, + ]); + await checkModeListItems("smart", [ + ...smartFolders, + fooTrashFolder, + barTrashFolder, + trashFolder, + ...fooExtraFolders, + ...barExtraFolders, + ...localExtraFolders, + ]); + await checkModeListItems("unread", [ + fooRootFolder, + fooTrashFolder, + barRootFolder, + barTrashFolder, + rootFolder, + folderA, + ]); + await checkModeListItems("favorite", [ + fooRootFolder, + fooTrashFolder, + barRootFolder, + barTrashFolder, + rootFolder, + folderA, + ]); + + // Remove and add the modes again. This should reinitialise them. + + folderPane.activeModes = ["recent"]; + folderPane.activeModes = ["all", "smart", "unread", "favorite"]; + await checkModeListItems("all", [ + ...fooFolders, + ...barFolders, + ...localFolders, + ]); + await checkModeListItems("smart", [ + ...smartFolders, + fooTrashFolder, + barTrashFolder, + trashFolder, + ...fooExtraFolders, + ...barExtraFolders, + ...localExtraFolders, + ]); + await checkModeListItems("unread", [ + fooRootFolder, + fooTrashFolder, + barRootFolder, + barTrashFolder, + rootFolder, + folderA, + ]); + await checkModeListItems("favorite", [ + fooRootFolder, + fooTrashFolder, + barRootFolder, + barTrashFolder, + rootFolder, + folderA, + ]); + + // Reorder the accounts. + + MailServices.accounts.reorderAccounts([bar.key, account.key, foo.key]); + await checkModeListItems("all", [ + ...barFolders, + ...localFolders, + ...fooFolders, + ]); + await checkModeListItems("smart", [ + ...smartFolders, + barTrashFolder, + trashFolder, + fooTrashFolder, + ...barExtraFolders, + ...localExtraFolders, + ...fooExtraFolders, + ]); + await checkModeListItems("unread", [ + barRootFolder, + barTrashFolder, + rootFolder, + folderA, + fooRootFolder, + fooTrashFolder, + ]); + await checkModeListItems("favorite", [ + barRootFolder, + barTrashFolder, + rootFolder, + folderA, + fooRootFolder, + fooTrashFolder, + ]); + + // Reorder the accounts again. + + MailServices.accounts.reorderAccounts([foo.key, account.key, bar.key]); + await checkModeListItems("all", [ + ...fooFolders, + ...localFolders, + ...barFolders, + ]); + await checkModeListItems("smart", [ + ...smartFolders, + fooTrashFolder, + trashFolder, + barTrashFolder, + ...fooExtraFolders, + ...localExtraFolders, + ...barExtraFolders, + ]); + await checkModeListItems("unread", [ + fooRootFolder, + fooTrashFolder, + rootFolder, + folderA, + barRootFolder, + barTrashFolder, + ]); + await checkModeListItems("favorite", [ + fooRootFolder, + fooTrashFolder, + rootFolder, + folderA, + barRootFolder, + barTrashFolder, + ]); + + // Remove one of the added accounts. + + MailServices.accounts.removeAccount(foo, false); + await checkModeListItems("all", [...localFolders, ...barFolders]); + await checkModeListItems("smart", [ + ...smartFolders, + trashFolder, + barTrashFolder, + ...localExtraFolders, + ...barExtraFolders, + ]); + await checkModeListItems("unread", [ + rootFolder, + folderA, + barRootFolder, + barTrashFolder, + ]); + await checkModeListItems("favorite", [ + rootFolder, + folderA, + barRootFolder, + barTrashFolder, + ]); + + // Remove the other added account, folder flags, and the added folder. + + MailServices.accounts.removeAccount(bar, false); + folderA.markAllMessagesRead(null); + folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite); + rootFolder.emptyTrash(null); + + await checkModeListItems("all", localFolders); + await checkModeListItems("smart", [ + ...smartFolders, + trashFolder, + ...localExtraFolders, + ]); + await checkModeListItems("unread", [rootFolder, folderA]); + await checkModeListItems("favorite", []); + + let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane); + await shownPromise; + + moreContext.activateItem( + moreContext.querySelector("#folderPaneHeaderToggleLocalFolders") + ); + // Force a 500ms timeout due to a weird intermittent macOS issue that prevents + // the Escape key press from closing the menupopup. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + + let menuHiddenPromise = BrowserTestUtils.waitForEvent( + moreContext, + "popuphidden" + ); + EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane); + await menuHiddenPromise; + + // All instances of local folders shouldn't be present. + await checkModeListItems("all", []); + await checkModeListItems("smart", [...smartFolders, trashFolder]); + await checkModeListItems("unread", []); + await checkModeListItems("favorite", []); +}); + +async function checkModeListItems(modeName, folders) { + // Jump to the end of the event queue so that any code listening for changes + // can run first. + await new Promise(resolve => setTimeout(resolve)); + expandAll(modeName); + + Assert.deepEqual( + Array.from( + folderPane._modes[modeName].containerList.querySelectorAll("li"), + folderTreeRow => folderTreeRow.uri + ), + folders.map(folder => folder.URI) + ); +} + +function expandAll(modeName) { + for (let folderTreeRow of folderPane._modes[ + modeName + ].containerList.querySelectorAll("li")) { + folderTree.expandRow(folderTreeRow); + } +} + +var IMAPServer = { + open() { + const { + ImapDaemon, + ImapMessage, + IMAP_GMAIL_extension, + IMAP_RFC3348_extension, + IMAP_RFC3501_handler, + mixinExtension, + } = ChromeUtils.import("resource://testing-common/mailnews/Imapd.jsm"); + const { nsMailServer } = ChromeUtils.import( + "resource://testing-common/mailnews/Maild.jsm" + ); + IMAPServer.ImapMessage = ImapMessage; + + this.daemon = new ImapDaemon(); + this.daemon.getMailbox("INBOX").specialUseFlag = "\\Inbox"; + this.daemon.getMailbox("INBOX").subscribed = true; + this.daemon.createMailbox("Trash", { + flags: ["\\Trash"], + subscribed: true, + }); + this.daemon.createMailbox("[Gmail]", { + flags: ["\\NoSelect"], + subscribed: true, + }); + this.daemon.createMailbox("[Gmail]/All Mail", { + flags: ["\\Archive"], + subscribed: true, + specialUseFlag: "\\AllMail", + }); + this.server = new nsMailServer(daemon => { + let handler = new IMAP_RFC3501_handler(daemon); + mixinExtension(handler, IMAP_GMAIL_extension); + mixinExtension(handler, IMAP_RFC3348_extension); + return handler; + }, this.daemon); + this.server.start(); + + registerCleanupFunction(() => this.close()); + }, + close() { + this.server.stop(); + }, + get port() { + return this.server.port; + }, +}; diff --git a/comm/mail/base/test/browser/browser_formPickers.js b/comm/mail/base/test/browser/browser_formPickers.js new file mode 100644 index 0000000000..1b98a0584a --- /dev/null +++ b/comm/mail/base/test/browser/browser_formPickers.js @@ -0,0 +1,352 @@ +/* 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/. */ + +/* eslint-env webextensions */ + +var { MailE10SUtils } = ChromeUtils.import( + "resource:///modules/MailE10SUtils.jsm" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const TEST_DOCUMENT_URL = + "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/formContent.html"; + +let testFolder; + +async function checkABrowser(browser) { + if ( + browser.webProgress?.isLoadingDocument || + browser.currentURI?.spec == "about:blank" + ) { + await BrowserTestUtils.browserLoaded( + browser, + undefined, + url => url != "about:blank" + ); + } + + let win = browser.ownerGlobal; + let doc = browser.ownerDocument; + + // Date picker + + let picker = win.top.document.getElementById("DateTimePickerPanel"); + Assert.ok(picker, "date/time picker exists"); + + // Open the popup. + let shownPromise = BrowserTestUtils.waitForEvent(picker, "popupshown"); + await SpecialPowers.spawn(browser, [], function () { + content.document.notifyUserGestureActivation(); + content.document.querySelector(`input[type="date"]`).showPicker(); + }); + await shownPromise; + + // Allow the picker time to initialise. + await new Promise(r => win.setTimeout(r, 500)); + + // Click in the middle of the picker. This should always land on a date and + // close the picker. + let hiddenPromise = BrowserTestUtils.waitForEvent(picker, "popuphidden"); + let frame = picker.querySelector("#dateTimePopupFrame"); + EventUtils.synthesizeMouseAtCenter( + frame.contentDocument.querySelector(".days-view td"), + {}, + frame.contentWindow + ); + await hiddenPromise; + + // Check the date was assigned to the input. + await SpecialPowers.spawn(browser, [], () => { + Assert.ok(content.document.querySelector(`input[type="date"]`).value); + }); + + // Select drop-down + + let menulist = win.top.document.getElementById("ContentSelectDropdown"); + Assert.ok(menulist, "select menulist exists"); + let menupopup = menulist.menupopup; + + // Click on the select control to open the popup. + shownPromise = BrowserTestUtils.waitForEvent(menulist, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter("select", {}, browser); + await shownPromise; + + // Allow the menulist time to initialise. + await new Promise(r => win.setTimeout(r, 500)); + + Assert.equal(menulist.value, "0"); + Assert.equal(menupopup.childElementCount, 3); + // Item values do not match the content document, but are 0-indexed. + Assert.equal(menupopup.children[0].label, ""); + Assert.equal(menupopup.children[0].value, "0"); + Assert.equal(menupopup.children[1].label, "π"); + Assert.equal(menupopup.children[1].value, "1"); + Assert.equal(menupopup.children[2].label, "τ"); + Assert.equal(menupopup.children[2].value, "2"); + + // Click the second option. This sets the value and closes the menulist. + hiddenPromise = BrowserTestUtils.waitForEvent(menulist, "popuphidden"); + menupopup.activateItem(menupopup.children[1]); + await hiddenPromise; + + // Sometimes the next change doesn't happen soon enough. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1000)); + + // Check the value was assigned to the control. + await SpecialPowers.spawn(browser, [], () => { + Assert.equal(content.document.querySelector("select").value, "3.141592654"); + }); + + // Input auto-complete + + browser.focus(); + + let popup = doc.getElementById(browser.getAttribute("autocompletepopup")); + Assert.ok(popup, "auto-complete popup exists"); + + // Click on the input box and type some letters to open the popup. + shownPromise = BrowserTestUtils.waitForEvent(popup, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + `input[list="letters"]`, + {}, + browser + ); + await BrowserTestUtils.synthesizeKey("e", {}, browser); + await BrowserTestUtils.synthesizeKey("t", {}, browser); + await BrowserTestUtils.synthesizeKey("a", {}, browser); + await shownPromise; + + // Allow the popup time to initialise. + await new Promise(r => win.setTimeout(r, 500)); + + let list = popup.querySelector("richlistbox"); + Assert.ok(list, "list added to popup"); + Assert.equal(list.itemCount, 4); + Assert.equal(list.itemChildren[0].getAttribute("title"), "beta"); + Assert.equal(list.itemChildren[1].getAttribute("title"), "zeta"); + Assert.equal(list.itemChildren[2].getAttribute("title"), "eta"); + Assert.equal(list.itemChildren[3].getAttribute("title"), "theta"); + + // Click the second option. This sets the value and closes the popup. + hiddenPromise = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(list.itemChildren[1], {}, win); + await hiddenPromise; + + // Check the value was assigned to the input. + await SpecialPowers.spawn(browser, [], () => { + Assert.equal( + content.document.querySelector(`input[list="letters"]`).value, + "zeta" + ); + }); +} + +add_setup(async function () { + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + let rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("formPickerFolder", null); + testFolder = rootFolder + .getChildNamed("formPickerFolder") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + let messages = new MessageGenerator().makeMessages({ count: 5 }); + let messageStrings = messages.map(message => message.toMboxString()); + testFolder.addMessageBatch(messageStrings); + + registerCleanupFunction(async () => { + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function testMessagePane() { + let about3Pane = document.getElementById("tabmail").currentAbout3Pane; + about3Pane.restoreState({ + folderURI: testFolder.URI, + messagePaneVisible: true, + }); + + about3Pane.messagePane.displayWebPage(TEST_DOCUMENT_URL); + await checkABrowser(about3Pane.webBrowser); +}); + +add_task(async function testContentTab() { + let tab = window.openContentTab(TEST_DOCUMENT_URL); + await checkABrowser(tab.browser); + + let tabmail = document.getElementById("tabmail"); + tabmail.closeTab(tab); +}); + +add_task(async function testExtensionPopupWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background: async () => { + await browser.windows.create({ + url: "formContent.html", + type: "popup", + width: 800, + height: 500, + }); + browser.test.notifyPass("ready"); + }, + files: { + "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + }, + }); + + await extension.startup(); + await extension.awaitFinish("ready"); + + let extensionPopup = Services.wm.getMostRecentWindow("mail:extensionPopup"); + // extensionPopup.xhtml needs time to initialise properly. + await new Promise(resolve => extensionPopup.setTimeout(resolve, 500)); + await checkABrowser(extensionPopup.document.getElementById("requestFrame")); + await BrowserTestUtils.closeWindow(extensionPopup); + + await extension.unload(); +}); + +add_task(async function testExtensionBrowserAction() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + }, + manifest: { + applications: { + gecko: { + id: "formpickers@mochi.test", + }, + }, + browser_action: { + default_popup: "formContent.html", + }, + }, + }); + + await extension.startup(); + + let { panel, browser } = await openExtensionPopup( + window, + "ext-formpickers\\@mochi.test" + ); + await checkABrowser(browser); + panel.hidePopup(); + + await extension.unload(); +}); + +add_task(async function testExtensionComposeAction() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + }, + manifest: { + applications: { + gecko: { + id: "formpickers@mochi.test", + }, + }, + compose_action: { + default_popup: "formContent.html", + }, + }, + }); + + await extension.startup(); + + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + params.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + let composeWindowPromise = BrowserTestUtils.domWindowOpened(); + MailServices.compose.OpenComposeWindowWithParams(null, params); + let composeWindow = await composeWindowPromise; + await BrowserTestUtils.waitForEvent(composeWindow, "load"); + + let { panel, browser } = await openExtensionPopup( + composeWindow, + "formpickers_mochi_test-composeAction-toolbarbutton" + ); + await checkABrowser(browser); + panel.hidePopup(); + + await extension.unload(); + await BrowserTestUtils.closeWindow(composeWindow); +}); + +add_task(async function testExtensionMessageDisplayAction() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response => + response.text() + ), + }, + manifest: { + applications: { + gecko: { + id: "formpickers@mochi.test", + }, + }, + message_display_action: { + default_popup: "formContent.html", + }, + }, + }); + + await extension.startup(); + + let messageWindowPromise = BrowserTestUtils.domWindowOpened(); + window.MsgOpenNewWindowForMessage([...testFolder.messages][0]); + let messageWindow = await messageWindowPromise; + let { target: aboutMessage } = await BrowserTestUtils.waitForEvent( + messageWindow, + "aboutMessageLoaded" + ); + + let { panel, browser } = await openExtensionPopup( + aboutMessage, + "formpickers_mochi_test-messageDisplayAction-toolbarbutton" + ); + await checkABrowser(browser); + panel.hidePopup(); + + await extension.unload(); + await BrowserTestUtils.closeWindow(messageWindow); +}); + +add_task(async function testBrowserRequestWindow() { + let requestWindow = await new Promise(resolve => { + Services.ww.openWindow( + null, + "chrome://messenger/content/browserRequest.xhtml", + null, + "chrome,private,centerscreen,width=980,height=750", + { + url: TEST_DOCUMENT_URL, + cancelled() {}, + loaded(window, webProgress) { + resolve(window); + }, + } + ); + }); + + await checkABrowser(requestWindow.document.getElementById("requestFrame")); + await BrowserTestUtils.closeWindow(requestWindow); +}); diff --git a/comm/mail/base/test/browser/browser_goMenu.js b/comm/mail/base/test/browser/browser_goMenu.js new file mode 100644 index 0000000000..6a15a5e1d2 --- /dev/null +++ b/comm/mail/base/test/browser/browser_goMenu.js @@ -0,0 +1,35 @@ +/* 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/. */ + +/** @type MenuData */ +const goMenuData = { + goNextMenu: {}, + menu_nextMsg: { disabled: true }, + menu_nextUnreadMsg: { disabled: true }, + menu_nextFlaggedMsg: { disabled: true }, + menu_nextUnreadThread: { disabled: true }, + "calendar-go-menu-next": { hidden: true }, + goPreviousMenu: {}, + menu_prevMsg: { disabled: true }, + menu_prevUnreadMsg: { disabled: true }, + menu_prevFlaggedMsg: { disabled: true }, + "calendar-go-menu-previous": { hidden: true }, + menu_goForward: { disabled: true }, + menu_goBack: { disabled: true }, + "calendar-go-to-today-menuitem": { hidden: true }, + menu_goChat: {}, + goFolderMenu: {}, + goRecentlyClosedTabs: { disabled: true }, + goStartPage: {}, +}; +let helper = new MenuTestHelper("menu_Go", goMenuData); + +add_setup(async function () { + document.getElementById("tabmail").clearRecentlyClosedTabs(); + document.getElementById("toolbar-menubar").removeAttribute("autohide"); +}); + +add_task(async function test3PaneTab() { + await helper.testAllItems("mail3PaneTab"); +}); diff --git a/comm/mail/base/test/browser/browser_interactionTelemetry.js b/comm/mail/base/test/browser/browser_interactionTelemetry.js new file mode 100644 index 0000000000..a958a24bdf --- /dev/null +++ b/comm/mail/base/test/browser/browser_interactionTelemetry.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const AREAS = ["keyboard", "calendar", "chat", "message_display", "toolbox"]; + +// Checks that the correct number of clicks are registered against the correct +// keys in the scalars. +function assertInteractionScalars(expectedAreas) { + let processScalars = + Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent ?? {}; + + for (let source of AREAS) { + let scalars = processScalars?.[`tb.ui.interaction.${source}`] ?? {}; + + let expected = expectedAreas[source] ?? {}; + + let expectedKeys = new Set( + Object.keys(scalars).concat(Object.keys(expected)) + ); + for (let key of expectedKeys) { + Assert.equal( + scalars[key], + expected[key], + `Expected to see the correct value for ${key} in ${source}.` + ); + } + } +} + +add_task(async function () { + Services.telemetry.clearScalars(); + + EventUtils.synthesizeMouseAtCenter( + document.getElementById("calendarButton"), + {}, + window + ); + + let calendarWindowPromise = BrowserTestUtils.promiseAlertDialog( + "cancel", + "chrome://calendar/content/calendar-creation.xhtml" + ); + EventUtils.synthesizeMouseAtCenter( + document.querySelector("#newCalendarSidebarButton"), + {}, + window + ); + await calendarWindowPromise; + + EventUtils.synthesizeMouseAtCenter( + document.querySelector("#tabmail-tabs tab:nth-child(2) .tab-close-button"), + {}, + window + ); + + assertInteractionScalars({ + calendar: { + newCalendarSidebarButton: 1, + }, + toolbox: { + calendarButton: 1, + "tab-close-button": 1, + }, + }); +}); diff --git a/comm/mail/base/test/browser/browser_linkHandler.js b/comm/mail/base/test/browser/browser_linkHandler.js new file mode 100644 index 0000000000..38cb8e5b05 --- /dev/null +++ b/comm/mail/base/test/browser/browser_linkHandler.js @@ -0,0 +1,294 @@ +/* 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/. */ + +/* eslint-disable @microsoft/sdl/no-insecure-url */ + +let { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +const TEST_DOMAIN = "http://example.org"; +const TEST_IP = "http://127.0.0.1:8888"; +const TEST_PATH = "/browser/comm/mail/base/test/browser/files/links.html"; + +let links = new Map([ + ["#this-hash", `${TEST_PATH}#hash`], + ["#this-nohash", `${TEST_PATH}`], + [ + "#local-here", + "/browser/comm/mail/base/test/browser/files/sampleContent.html", + ], + [ + "#local-elsewhere", + "/browser/comm/mail/components/extensions/test/browser/data/content.html", + ], + ["#other-https", `https://example.org${TEST_PATH}`], + ["#other-port", `http://example.org:8000${TEST_PATH}`], + ["#other-subdomain", `http://test1.example.org${TEST_PATH}`], + ["#other-subsubdomain", `http://sub1.test1.example.org${TEST_PATH}`], + ["#other-domain", `http://mochi.test:8888${TEST_PATH}`], +]); + +/** @implements {nsIWebProgressListener} */ +let webProgressListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + _browser: null, + _deferred: null, + + onStateChange(webProgress, request, stateFlags, status) { + if ( + !(stateFlags & Ci.nsIWebProgressListener.STATE_STOP) || + this._browser?.currentURI.spec == "about:blank" + ) { + return; + } + + if (this._deferred) { + let deferred = this._deferred; + let url = this._browser.currentURI.spec; + this.cancelPromise(); + + deferred.resolve(url); + } else { + this.cancelPromise(); + Assert.ok(false, "unexpected state change"); + } + }, + + onLocationChange(webProgress, request, location, flags) { + if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_HASHCHANGE)) { + return; + } + + if (this._deferred) { + let deferred = this._deferred; + let url = this._browser.currentURI.spec; + this.cancelPromise(); + + deferred.resolve(url); + } else { + this.cancelPromise(); + Assert.ok(false, "unexpected location change"); + } + }, + + promiseEvent(browser) { + this._browser = browser; + browser.webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_ALL | Ci.nsIWebProgress.NOTIFY_LOCATION + ); + + this._deferred = PromiseUtils.defer(); + return this._deferred.promise; + }, + + cancelPromise() { + this._deferred = null; + this._browser.removeProgressListener(this); + this._browser = null; + }, +}; + +/** @implements {nsIExternalProtocolService} */ +let mockExternalProtocolService = { + QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]), + + _deferred: null, + + loadURI(aURI, aWindowContext) { + if (this._deferred) { + let deferred = this._deferred; + this._deferred = null; + + deferred.resolve(aURI.spec); + } else { + this.cancelPromise(); + Assert.ok(false, "unexpected call to external protocol service"); + } + }, + + promiseEvent() { + this._deferred = PromiseUtils.defer(); + return this._deferred.promise; + }, + + cancelPromise() { + this._deferred = null; + }, +}; + +let mockExternalProtocolServiceCID = MockRegistrar.register( + "@mozilla.org/uriloader/external-protocol-service;1", + mockExternalProtocolService +); + +registerCleanupFunction(() => { + let tabmail = document.getElementById("tabmail"); + Assert.equal(tabmail.tabInfo.length, 1); + + while (tabmail.tabInfo.length > 1) { + tabmail.closeTab(tabmail.tabInfo[1]); + } + + MockRegistrar.unregister(mockExternalProtocolServiceCID); +}); + +async function clickOnLink( + browser, + selector, + url, + pageURL, + shouldLoadInternally +) { + if ( + browser.webProgress?.isLoadingDocument || + browser.currentURI?.spec == "about:blank" + ) { + await BrowserTestUtils.browserLoaded(browser); + + // Clear the event queue. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 250)); + } + Assert.equal( + browser.currentURI?.spec, + pageURL, + "original URL should be loaded" + ); + + let webProgressPromise = webProgressListener.promiseEvent(browser); + let externalProtocolPromise = mockExternalProtocolService.promiseEvent(); + + info(`clicking on ${selector}`); + await BrowserTestUtils.synthesizeMouseAtCenter(selector, {}, browser); + + await Promise.any([webProgressPromise, externalProtocolPromise]); + + if (selector == "#this-hash") { + await SpecialPowers.spawn(browser, [], () => { + let doc = content.document; + let target = doc.querySelector("#hash"); + let targetRect = target.getBoundingClientRect(); + Assert.less( + targetRect.bottom, + doc.documentElement.clientHeight, + "page did scroll" + ); + }); + } + + if (shouldLoadInternally) { + Assert.equal( + await webProgressPromise, + url, + `${url} should load internally` + ); + mockExternalProtocolService.cancelPromise(); + } else { + Assert.equal( + await externalProtocolPromise, + url, + `${url} should load externally` + ); + webProgressListener.cancelPromise(); + } + + if (browser.currentURI?.spec != pageURL) { + let promise = webProgressListener.promiseEvent(browser); + browser.browsingContext.goBack(); + await promise; + Assert.equal(browser.currentURI?.spec, pageURL, "should have gone back"); + } +} + +async function subtest(pagePrePath, group, shouldLoadCB) { + let tabmail = document.getElementById("tabmail"); + let tab = window.openContentTab( + `${pagePrePath}${TEST_PATH}`, + undefined, + group + ); + + let expectedGroup = group; + if (group === null) { + expectedGroup = "browsers"; + } else if (group === undefined) { + expectedGroup = "single-site"; + } + Assert.equal(tab.browser.getAttribute("messagemanagergroup"), expectedGroup); + + try { + for (let [selector, url] of links) { + if (url.startsWith("/")) { + url = `${pagePrePath}${url}`; + } + await clickOnLink( + tab.browser, + selector, + url, + `${pagePrePath}${TEST_PATH}`, + shouldLoadCB(selector) + ); + } + } finally { + tabmail.closeTab(tab); + } +} + +add_task(function testNoGroup() { + return subtest( + TEST_DOMAIN, + undefined, + selector => selector != "#other-domain" + ); +}); + +add_task(function testBrowsersGroup() { + return subtest(TEST_DOMAIN, null, selector => true); +}); + +add_task(function testSingleSiteGroup() { + return subtest( + TEST_DOMAIN, + "single-site", + selector => selector != "#other-domain" + ); +}); + +add_task(function testSinglePageGroup() { + return subtest(TEST_DOMAIN, "single-page", selector => + selector.startsWith("#this") + ); +}); + +add_task(function testNoGroupWithIP() { + return subtest( + TEST_IP, + undefined, + selector => selector.startsWith("#this") || selector.startsWith("#local") + ); +}); + +add_task(function testBrowsersGroupWithIP() { + return subtest(TEST_IP, null, selector => true); +}); + +add_task(function testSingleSiteGroupWithIP() { + return subtest( + TEST_IP, + "single-site", + selector => selector.startsWith("#this") || selector.startsWith("#local") + ); +}); + +add_task(function testSinglePageGroupWithIP() { + return subtest(TEST_IP, "single-page", selector => + selector.startsWith("#this") + ); +}); diff --git a/comm/mail/base/test/browser/browser_mailContext.js b/comm/mail/base/test/browser/browser_mailContext.js new file mode 100644 index 0000000000..3b75b2827e --- /dev/null +++ b/comm/mail/base/test/browser/browser_mailContext.js @@ -0,0 +1,950 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { ConversationOpener } = ChromeUtils.import( + "resource:///modules/ConversationOpener.jsm" +); +var { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm"); +var { GlodaSyntheticView } = ChromeUtils.import( + "resource:///modules/gloda/GlodaSyntheticView.jsm" +); +var { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm"); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); +var { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const TEST_MESSAGE_URL = + "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.eml"; + +let tabmail = document.getElementById("tabmail"); +let testFolder, testMessages; +let draftsFolder, draftsMessages; +let templatesFolder, templatesMessages; +let listFolder, listMessages; + +let singleSelectionMessagePane = [ + "singleMessage", + "draftsFolder", + "templatesFolder", + "listFolder", + "syntheticFolderDraft", + "syntheticFolder", +]; +let singleSelectionThreadPane = [ + "singleMessageTree", + "draftsFolderTree", + "templatesFolderTree", + "listFolderTree", + "syntheticFolderDraftTree", + "syntheticFolderTree", +]; +let onePane = ["messageTab", "messageWindow"]; +let external = ["externalMessageTab", "externalMessageWindow"]; +let allSingleSelection = [ + ...singleSelectionMessagePane, + ...singleSelectionThreadPane, + ...onePane, + ...external, +]; +let allThreePane = [ + ...singleSelectionMessagePane, + ...singleSelectionThreadPane, + "multipleMessagesTree", + "collapsedThreadTree", + "multipleDraftsFolderTree", + "multipleTemplatesFolderTree", +]; +const noCollapsedThreads = [ + ...singleSelectionMessagePane, + ...singleSelectionThreadPane, + "multipleMessagesTree", + "multipleDraftsFolderTree", + "multipleTemplatesFolderTree", + ...onePane, + ...external, +]; +let notExternal = [...allThreePane, ...onePane]; +let singleNotExternal = [ + ...singleSelectionMessagePane, + ...singleSelectionThreadPane, + ...onePane, +]; + +const mailContextData = { + "mailContext-selectall": [ + ...singleSelectionMessagePane, + ...onePane, + ...external, + ], + "mailContext-editDraftMsg": [ + "draftsFolder", + "draftsFolderTree", + "multipleDraftsFolderTree", + "syntheticFolderDraft", + "syntheticFolderDraftTree", + ], + "mailContext-newMsgFromTemplate": [ + "templatesFolder", + "templatesFolderTree", + "multipleTemplatesFolderTree", + ], + "mailContext-editTemplateMsg": [ + "templatesFolder", + "templatesFolderTree", + "multipleTemplatesFolderTree", + ], + "mailContext-openNewTab": singleSelectionThreadPane, + "mailContext-openNewWindow": singleSelectionThreadPane, + "mailContext-openConversation": [ + ...singleSelectionMessagePane, + ...singleSelectionThreadPane, + ...onePane, + "collapsedThreadTree", + ], + "mailContext-openContainingFolder": [ + "syntheticFolderDraft", + "syntheticFolderDraftTree", + "syntheticFolder", + "syntheticFolderTree", + ...onePane, + ], + "mailContext-replySender": noCollapsedThreads, + "mailContext-replyAll": noCollapsedThreads, + "mailContext-replyList": ["listFolder", "listFolderTree"], + "mailContext-forward": allSingleSelection, + "mailContext-forwardAsMenu": allSingleSelection, + "mailContext-multiForwardAsAttachment": [ + "multipleMessagesTree", + "multipleDraftsFolderTree", + "multipleTemplatesFolderTree", + ], + "mailContext-redirect": noCollapsedThreads, + "mailContext-editAsNew": noCollapsedThreads, + "mailContext-tags": notExternal, + "mailContext-mark": notExternal, + "mailContext-archive": notExternal, + "mailContext-moveMenu": notExternal, + "mailContext-copyMenu": true, + "mailContext-decryptToFolder": [ + "multipleMessagesTree", + "collapsedThreadTree", + "multipleDraftsFolderTree", + "multipleTemplatesFolderTree", + ], + "mailContext-calendar-convert-menu": singleNotExternal, + "mailContext-delete": notExternal, + "mailContext-ignoreThread": allThreePane, + "mailContext-ignoreSubthread": allThreePane, + "mailContext-watchThread": notExternal, + "mailContext-saveAs": true, + "mailContext-print": true, + "mailContext-downloadSelected": [ + "multipleMessagesTree", + "collapsedThreadTree", + "multipleDraftsFolderTree", + "multipleTemplatesFolderTree", + ], +}; + +function checkMenuitems(menu, mode) { + if (!mode) { + // Menu should not be shown. + Assert.equal(menu.state, "closed"); + return; + } + + info(`Checking menus for ${mode} ...`); + + Assert.notEqual(menu.state, "closed", "Menu should be closed"); + + let expectedItems = []; + for (let [id, modes] of Object.entries(mailContextData)) { + if (modes === true || modes.includes(mode)) { + expectedItems.push(id); + } + } + + let actualItems = []; + for (let item of menu.children) { + if (["menu", "menuitem"].includes(item.localName) && !item.hidden) { + actualItems.push(item.id); + } + } + + let notFoundItems = expectedItems.filter(i => !actualItems.includes(i)); + if (notFoundItems.length) { + Assert.report( + true, + undefined, + undefined, + "items expected but not found: " + notFoundItems.join(", ") + ); + } + + let unexpectedItems = actualItems.filter(i => !expectedItems.includes(i)); + if (unexpectedItems.length) { + Assert.report( + true, + undefined, + undefined, + "items found but not expected: " + unexpectedItems.join(", ") + ); + } + + Assert.deepEqual(actualItems, expectedItems); + + menu.hidePopup(); +} + +add_setup(async function () { + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + let rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("mailContextFolder", null); + testFolder = rootFolder + .getChildNamed("mailContextFolder") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + let message = await fetch(TEST_MESSAGE_URL).then(r => r.text()); + testFolder.addMessageBatch([message]); + let messages = [ + ...generator.makeMessages({ count: 5 }), + ...generator.makeMessages({ count: 5, msgsPerThread: 5 }), + ...generator.makeMessages({ count: 200 }), + ]; + let messageStrings = messages.map(message => message.toMboxString()); + testFolder.addMessageBatch(messageStrings); + testMessages = [...testFolder.messages]; + rootFolder.createSubfolder("mailContextDrafts", null); + draftsFolder = rootFolder + .getChildNamed("mailContextDrafts") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + draftsFolder.setFlag(Ci.nsMsgFolderFlags.Drafts); + draftsFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + draftsMessages = [...draftsFolder.messages]; + rootFolder.createSubfolder("mailContextTemplates", null); + templatesFolder = rootFolder + .getChildNamed("mailContextTemplates") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + templatesFolder.setFlag(Ci.nsMsgFolderFlags.Templates); + templatesFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + templatesMessages = [...templatesFolder.messages]; + rootFolder.createSubfolder("mailContextMailingList", null); + listFolder = rootFolder + .getChildNamed("mailContextMailingList") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + listFolder.addMessage( + "From - Mon Jan 01 00:00:00 2001\n" + + "To: Mailing List <list@example.com>\n" + + "Date: Mon, 01 Jan 2001 00:00:00 +0100\n" + + "List-Help: <https://list.example.com>\n" + + "List-Post: <mailto:list@example.com>\n" + + "List-Software: Mailing List Software\n" + + "List-Subscribe: <https://subscribe.example.com>\n" + + "Precedence: list\n" + + "Subject: Mailing List Test Mail\n" + + `Message-ID: <${Date.now()}@example.com>\n` + + "From: Mailing List <list@example.com>\n" + + "List-Unsubscribe: <https://unsubscribe.example.com>,\n" + + " <mailto:unsubscribe@example.com?subject=Unsubscribe Test>\n" + + "MIME-Version: 1.0\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "Content-Transfer-Encoding: quoted-printable\n" + + "\n" + + "Mailing List Message Body\n" + ); + listMessages = [...listFolder.messages]; + + tabmail.currentAbout3Pane.restoreState({ + folderURI: testFolder.URI, + messagePaneVisible: true, + }); + + // Enable home calendar. + cal.manager.getCalendars()[0].setProperty("disabled", false); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + Services.prefs.clearUserPref("mail.openMessageBehavior"); + cal.manager.getCalendars()[0].setProperty("disabled", true); + }); +}); + +/** + * Tests the mailContext menu on the thread tree and message pane when no + * messages are selected. + */ +add_task(async function testNoMessages() { + let about3Pane = tabmail.currentAbout3Pane; + let mailContext = about3Pane.document.getElementById("mailContext"); + let { messageBrowser, messagePane, threadTree } = about3Pane; + messagePane.clearAll(); + + // The message pane browser isn't visible. + + Assert.ok( + BrowserTestUtils.is_hidden(messageBrowser), + "message browser should be hidden" + ); + Assert.equal(messageBrowser.currentURI.spec, "about:message"); + Assert.equal( + messageBrowser.contentWindow.getMessagePaneBrowser().currentURI.spec, + "about:blank" + ); + EventUtils.synthesizeMouseAtCenter( + about3Pane.document.getElementById("messagePane"), + { type: "contextmenu" } + ); + checkMenuitems(mailContext); + + // Open the menu from an empty part of the thread pane. + + let treeRect = threadTree.getBoundingClientRect(); + EventUtils.synthesizeMouse( + threadTree, + treeRect.x + treeRect.width / 2, + treeRect.bottom - 10, + { type: "contextmenu" }, + about3Pane + ); + checkMenuitems(mailContext); +}); + +/** + * Tests the mailContext menu on the thread tree and message pane when one + * message is selected. + */ +add_task(async function testSingleMessage() { + await TestUtils.waitForCondition( + () => ConversationOpener.isMessageIndexed(testMessages[0]), + "waiting for Gloda to finish indexing", + 500 + ); + + let about3Pane = tabmail.currentAbout3Pane; + let mailContext = about3Pane.document.getElementById("mailContext"); + let { gDBView, messageBrowser, threadTree } = about3Pane; + let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser(); + + let loadedPromise = BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + url => url.endsWith(gDBView.getKeyAt(0)) + ); + threadTree.selectedIndex = 0; + threadTree.scrollToIndex(0, true); + await loadedPromise; + + // Open the menu from the message pane. + + Assert.ok( + BrowserTestUtils.is_visible(messageBrowser), + "message browser should be visible" + ); + + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + messagePaneBrowser + ); + await shownPromise; + checkMenuitems(mailContext, "singleMessage"); + + // Open the menu from the thread pane. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(0), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "singleMessageTree"); + + // Open the menu through the keyboard. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + const row = threadTree.getRowAtIndex(0); + row.focus(); + EventUtils.synthesizeMouseAtCenter( + row, + { type: "contextmenu", button: 0 }, + about3Pane + ); + await shownPromise; + Assert.ok( + BrowserTestUtils.is_visible(mailContext), + "Context menu is shown through keyboard action" + ); + mailContext.hidePopup(); + + // Open the menu through the keyboard on a message that is scrolled slightly + // out of view. + + threadTree.selectedIndex = 5; + threadTree.scrollToIndex(threadTree.getLastVisibleIndex() + 7, true); + await new Promise(resolve => window.requestAnimationFrame(resolve)); + Assert.equal(threadTree.currentIndex, 5, "Row 5 is the current row"); + Assert.ok(row.parentNode, "Row element should still be attached"); + Assert.greater( + threadTree.getFirstVisibleIndex(), + 5, + "Selected row should no longer be visible" + ); + EventUtils.synthesizeMouseAtCenter( + threadTree, + { type: "contextmenu", button: 0 }, + about3Pane + ); + await new Promise(resolve => window.requestAnimationFrame(resolve)); + await BrowserTestUtils.waitForPopupEvent(mailContext, "shown"); + Assert.greaterOrEqual( + 5, + threadTree.getFirstVisibleIndex(), + "Current row is greater than or equal to first visible index" + ); + Assert.lessOrEqual( + 5, + threadTree.getLastVisibleIndex(), + "Current row is less than or equal to last visible index" + ); + mailContext.hidePopup(); + + // Open the menu on a message that is scrolled out of view. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + threadTree.scrollToIndex(200, true); + await new Promise(resolve => window.requestAnimationFrame(resolve)); + Assert.ok(!row.parentNode, "Row element should no longer be attached"); + Assert.equal(threadTree.currentIndex, 5, "Row 5 is the current row"); + Assert.ok( + !threadTree.getRowAtIndex(threadTree.currentIndex), + "Current row is scrolled out of view" + ); + EventUtils.synthesizeMouseAtCenter( + threadTree, + { type: "contextmenu", button: 0 }, + about3Pane + ); + await shownPromise; + Assert.ok( + threadTree.getRowAtIndex(threadTree.currentIndex), + "Current row is scrolled into view when showing context menu" + ); + Assert.greaterOrEqual( + 5, + threadTree.getFirstVisibleIndex(), + "Current row is greater than or equal to first visible index" + ); + Assert.lessOrEqual( + 5, + threadTree.getLastVisibleIndex(), + "Current row is less than or equal to last visible index" + ); + mailContext.hidePopup(); + + Assert.ok(BrowserTestUtils.is_hidden(mailContext), "Context menu is hidden"); +}); + +/** + * Tests the mailContext menu on the thread tree when more than one message is + * selected. + */ +add_task(async function testMultipleMessages() { + await TestUtils.waitForCondition( + () => ConversationOpener.isMessageIndexed(testMessages[6]), + "waiting for Gloda to finish indexing", + 500 + ); + + let about3Pane = tabmail.currentAbout3Pane; + let mailContext = about3Pane.document.getElementById("mailContext"); + let { messageBrowser, multiMessageBrowser, threadTree } = about3Pane; + threadTree.scrollToIndex(1, true); + threadTree.selectedIndices = [1, 2, 3]; + await TestUtils.waitForTick(); // Wait for rows to be added. + + // The message pane browser isn't visible. + + Assert.ok( + BrowserTestUtils.is_hidden(messageBrowser), + "message browser should be hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(multiMessageBrowser), + "multimessage browser should be visible" + ); + + // Open the menu from the thread pane. + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(2), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "multipleMessagesTree"); + + // Select a collapsed thread and open the menu. + + threadTree.scrollToIndex(6, true); + threadTree.selectedIndices = [6]; + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(6), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "collapsedThreadTree"); + + // Open the menu in the thread pane on a message scrolled out of view. + + threadTree.selectAll(); + threadTree.currentIndex = 200; + await TestUtils.waitForTick(); + await new Promise(resolve => window.requestAnimationFrame(resolve)); + threadTree.scrollToIndex(0, true); + await new Promise(resolve => window.requestAnimationFrame(resolve)); + Assert.ok( + !threadTree.getRowAtIndex(threadTree.currentIndex), + "Current row is scrolled out of view" + ); + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree, + { type: "contextmenu", button: 0 }, + about3Pane + ); + await shownPromise; + Assert.ok( + threadTree.getRowAtIndex(threadTree.currentIndex), + "Current row is scrolled into view when popup is shown" + ); + mailContext.hidePopup(); +}); + +/** + * Tests the mailContext menu on the thread tree and message pane of a Drafts + * folder. + */ +add_task(async function testDraftsFolder() { + let about3Pane = tabmail.currentAbout3Pane; + about3Pane.restoreState({ folderURI: draftsFolder.URI }); + + await TestUtils.waitForCondition( + () => ConversationOpener.isMessageIndexed(draftsMessages[1]), + "waiting for Gloda to finish indexing", + 500 + ); + + let mailContext = about3Pane.document.getElementById("mailContext"); + let { gDBView, messageBrowser, threadTree } = about3Pane; + let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser(); + + let loadedPromise = BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + url => url.endsWith(gDBView.getKeyAt(0)) + ); + threadTree.selectedIndex = 0; + await loadedPromise; + + // Open the menu from the message pane. + + Assert.ok( + BrowserTestUtils.is_visible(messageBrowser), + "message browser should be visible" + ); + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + messagePaneBrowser + ); + await shownPromise; + checkMenuitems(mailContext, "draftsFolder"); + + // Open the menu from the thread pane. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(0), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "draftsFolderTree"); + + threadTree.scrollToIndex(1, true); + threadTree.selectedIndices = [1, 2, 3]; + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(2), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "multipleDraftsFolderTree"); +}); + +/** + * Tests the mailContext menu on the thread tree and message pane of a Templates + * folder. + */ +add_task(async function testTemplatesFolder() { + let about3Pane = tabmail.currentAbout3Pane; + about3Pane.restoreState({ folderURI: templatesFolder.URI }); + + await TestUtils.waitForCondition( + () => ConversationOpener.isMessageIndexed(templatesMessages[1]), + "waiting for Gloda to finish indexing", + 500 + ); + + let mailContext = about3Pane.document.getElementById("mailContext"); + let { gDBView, messageBrowser, threadTree } = about3Pane; + let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser(); + + let loadedPromise = BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + url => url.endsWith(gDBView.getKeyAt(0)) + ); + threadTree.selectedIndex = 0; + await loadedPromise; + + // Open the menu from the message pane. + + Assert.ok( + BrowserTestUtils.is_visible(messageBrowser), + "message browser should be visible" + ); + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + messagePaneBrowser + ); + await shownPromise; + checkMenuitems(mailContext, "templatesFolder"); + + // Open the menu from the thread pane. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(0), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "templatesFolderTree"); + + threadTree.scrollToIndex(1, true); + threadTree.selectedIndices = [1, 2, 3]; + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(2), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "multipleTemplatesFolderTree"); +}); + +/** + * Tests the mailContext menu on the thread tree and message pane of a + * mailing list message. + */ + +add_task(async function testListMessage() { + let about3Pane = tabmail.currentAbout3Pane; + about3Pane.restoreState({ folderURI: listFolder.URI }); + + await TestUtils.waitForCondition( + () => ConversationOpener.isMessageIndexed(listMessages[0]), + "waiting for Gloda to finish indexing", + 500 + ); + + let mailContext = about3Pane.document.getElementById("mailContext"); + let { gDBView, messageBrowser, threadTree } = about3Pane; + let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser(); + + let loadedPromise = BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + url => url.endsWith(gDBView.getKeyAt(0)) + ); + threadTree.selectedIndex = 0; + await loadedPromise; + + // Open the menu from the message pane. + + Assert.ok( + BrowserTestUtils.is_visible(messageBrowser), + "message browser should be visible" + ); + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + messagePaneBrowser + ); + await shownPromise; + checkMenuitems(mailContext, "listFolder"); + + // Open the menu from the thread pane. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(0), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "listFolderTree"); +}); + +/** + * Tests the mailContext menu on the thread tree and message pane of a Gloda + * synthetic view (in this case a conversation, but a list of search results + * should be the same). + */ +add_task(async function testSyntheticFolder() { + await TestUtils.waitForCondition( + () => ConversationOpener.isMessageIndexed(testMessages[9]), + "waiting for Gloda to finish indexing", + 500 + ); + await TestUtils.waitForCondition( + () => ConversationOpener.isMessageIndexed(draftsMessages[4]), + "waiting for Gloda to finish indexing", + 500 + ); + + let tabPromise = BrowserTestUtils.waitForEvent(window, "aboutMessageLoaded"); + tabmail.openTab("mail3PaneTab", { + syntheticView: new GlodaSyntheticView({ + collection: Gloda.getMessageCollectionForHeaders([ + ...draftsMessages, + ...testMessages.slice(6), + ]), + }), + title: "Test gloda results", + }); + await tabPromise; + await new Promise(resolve => setTimeout(resolve)); + + let about3Pane = tabmail.currentAbout3Pane; + let mailContext = about3Pane.document.getElementById("mailContext"); + let { gDBView, messageBrowser, threadTree } = about3Pane; + let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser(); + + let loadedPromise = BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + url => url.endsWith(gDBView.getKeyAt(0)) + ); + threadTree.selectedIndex = 0; + await loadedPromise; + + // Open the menu from the message pane. + + Assert.ok( + BrowserTestUtils.is_visible(messageBrowser), + "message browser should be visible" + ); + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + messagePaneBrowser + ); + await shownPromise; + checkMenuitems(mailContext, "syntheticFolderDraft"); + + // Open the menu from the thread pane. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(0), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "syntheticFolderDraftTree"); + + loadedPromise = BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + url => url.endsWith(gDBView.getKeyAt(5)) + ); + threadTree.selectedIndex = 5; + await loadedPromise; + + // Open the menu from the message pane. + + Assert.ok( + BrowserTestUtils.is_visible(messageBrowser), + "message browser should be visible" + ); + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + messagePaneBrowser + ); + await shownPromise; + checkMenuitems(mailContext, "syntheticFolder"); + + // Open the menu from the thread pane. + + shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(5), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + checkMenuitems(mailContext, "syntheticFolderTree"); + + tabmail.closeOtherTabs(0); +}); + +/** + * Tests the mailContext menu on the message pane of a message in a tab. + */ +add_task(async function testMessageTab() { + let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + window.OpenMessageInNewTab(testMessages[0], { background: false }); + await tabPromise; + await new Promise(resolve => setTimeout(resolve)); + + let aboutMessage = tabmail.currentAboutMessage; + let mailContext = aboutMessage.document.getElementById("mailContext"); + + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + aboutMessage.getMessagePaneBrowser() + ); + await shownPromise; + checkMenuitems(mailContext, "messageTab"); + + tabmail.closeOtherTabs(0); +}); + +/** + * Tests the mailContext menu on the message pane of a file message in a tab. + */ +add_task(async function testExternalMessageTab() { + let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + let messageFile = new FileUtils.File( + getTestFilePath("files/sampleContent.eml") + ); + Services.prefs.setIntPref( + "mail.openMessageBehavior", + MailConsts.OpenMessageBehavior.NEW_TAB + ); + MailUtils.openEMLFile( + window, + messageFile, + Services.io.newFileURI(messageFile) + ); + await tabPromise; + await new Promise(resolve => setTimeout(resolve)); + + let aboutMessage = tabmail.currentAboutMessage; + let mailContext = aboutMessage.document.getElementById("mailContext"); + + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + aboutMessage.getMessagePaneBrowser() + ); + await shownPromise; + checkMenuitems(mailContext, "externalMessageTab"); + + tabmail.closeOtherTabs(0); +}); + +/** + * Tests the mailContext menu on the message pane of a message in a window. + */ +add_task(async function testMessageWindow() { + let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + window.MsgOpenNewWindowForMessage(testMessages[0]); + let win = await winPromise; + await BrowserTestUtils.waitForEvent(win, "MsgLoaded"); + await TestUtils.waitForCondition(() => Services.focus.activeWindow == win); + + let aboutMessage = win.messageBrowser.contentWindow; + let mailContext = aboutMessage.document.getElementById("mailContext"); + + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + aboutMessage.getMessagePaneBrowser() + ); + await shownPromise; + checkMenuitems(mailContext, "messageWindow"); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests the mailContext menu on the message pane of a file message in a window. + */ +add_task(async function testExternalMessageWindow() { + let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let messageFile = new FileUtils.File( + getTestFilePath("files/sampleContent.eml") + ); + Services.prefs.setIntPref( + "mail.openMessageBehavior", + MailConsts.OpenMessageBehavior.NEW_WINDOW + ); + MailUtils.openEMLFile( + window, + messageFile, + Services.io.newFileURI(messageFile) + ); + let win = await winPromise; + await BrowserTestUtils.waitForEvent(win, "MsgLoaded"); + await TestUtils.waitForCondition(() => Services.focus.activeWindow == win); + + let aboutMessage = win.messageBrowser.contentWindow; + let mailContext = aboutMessage.document.getElementById("mailContext"); + + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ":root", + { type: "contextmenu" }, + aboutMessage.getMessagePaneBrowser() + ); + await shownPromise; + checkMenuitems(mailContext, "externalMessageWindow"); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/comm/mail/base/test/browser/browser_mailTabsAndWindows.js b/comm/mail/base/test/browser/browser_mailTabsAndWindows.js new file mode 100644 index 0000000000..3da815edd9 --- /dev/null +++ b/comm/mail/base/test/browser/browser_mailTabsAndWindows.js @@ -0,0 +1,355 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); +var { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let folderA, messagesA, folderB, messagesB; + +add_setup(async function () { + let tabmail = document.getElementById("tabmail"); + if (tabmail.tabInfo.length > 1) { + info(`Will close ${tabmail.tabInfo.length - 1} tabs left over from others`); + for (let i = tabmail.tabInfo.length - 1; i > 0; i--) { + tabmail.closeTab(i); + } + } + Assert.equal(tabmail.tabInfo.length, 1, "should be set up with one tab"); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + let rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("mailTabsA", null); + folderA = rootFolder + .getChildNamed("mailTabsA") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderA.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + messagesA = [...folderA.messages]; + + rootFolder.createSubfolder("mailTabsB", null); + folderB = rootFolder + .getChildNamed("mailTabsB") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderB.addMessageBatch( + generator.makeMessages({ count: 2 }).map(message => message.toMboxString()) + ); + messagesB = [...folderB.messages]; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function testTabs() { + let tabmail = document.getElementById("tabmail"); + Assert.equal(tabmail.tabInfo.length, 1, "should start off with one tab open"); + Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[0], "should show tab0"); + + // Check the first tab. + + let firstTab = tabmail.currentTabInfo; + Assert.equal(firstTab.mode.name, "mail3PaneTab"); + Assert.equal(firstTab.mode.tabType.name, "mailTab"); + + let firstChromeBrowser = firstTab.chromeBrowser; + Assert.equal(firstChromeBrowser.currentURI.spec, "about:3pane"); + Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow); + + let firstMessageBrowser = + firstChromeBrowser.contentDocument.getElementById("messageBrowser"); + Assert.equal(firstMessageBrowser.currentURI.spec, "about:message"); + + let firstMessagePane = + firstMessageBrowser.contentDocument.getElementById("messagepane"); + Assert.equal(firstMessagePane.currentURI.spec, "about:blank"); + Assert.equal( + tabmail.currentAboutMessage, + null, + "currentAboutMessage should be null with no message selected" + ); + Assert.equal(firstTab.browser, null); + Assert.equal(firstTab.linkedBrowser, null); + + let { folderTree, threadTree, messagePane, paneLayout } = + firstChromeBrowser.contentWindow; + + firstTab.folder = folderA; + Assert.equal(firstTab.folder, folderA); + Assert.equal( + folderTree.querySelector(".selected .name").textContent, + "mailTabsA" + ); + Assert.equal(threadTree.view.rowCount, 5); + Assert.equal(threadTree.selectedIndex, -1); + + Assert.equal(firstTab.message, null); + threadTree.selectedIndex = 0; + Assert.equal( + tabmail.currentAboutMessage, + firstMessageBrowser.contentWindow, + "currentAboutMessage should have a value with a message selected" + ); + Assert.equal(firstTab.message, messagesA[0]); + Assert.equal(firstTab.browser, firstMessagePane); + Assert.equal(firstTab.linkedBrowser, firstMessagePane); + + Assert.ok(BrowserTestUtils.is_visible(folderTree)); + Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser)); + + paneLayout.folderPaneVisible = false; + Assert.ok(BrowserTestUtils.is_hidden(folderTree)); + Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser)); + + paneLayout.messagePaneVisible = false; + Assert.ok(BrowserTestUtils.is_hidden(folderTree)); + Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser)); + Assert.equal( + tabmail.currentAboutMessage, + null, + "currentAboutMessage should be null with the message pane hidden" + ); + Assert.equal(firstTab.browser, null); + Assert.equal(firstTab.linkedBrowser, null); + + paneLayout.folderPaneVisible = true; + Assert.ok(BrowserTestUtils.is_visible(folderTree)); + Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser)); + + paneLayout.messagePaneVisible = true; + Assert.ok(BrowserTestUtils.is_visible(folderTree)); + Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser)); + Assert.equal( + tabmail.currentAboutMessage, + firstMessageBrowser.contentWindow, + "currentAboutMessage should have a value with the message pane shown" + ); + Assert.equal(firstTab.browser, firstMessagePane); + Assert.equal(firstTab.linkedBrowser, firstMessagePane); + + Assert.equal(firstChromeBrowser.contentWindow.tabOrWindow, firstTab); + Assert.equal(firstMessageBrowser.contentWindow.tabOrWindow, firstTab); + + // Select multiple messages. + + let firstMultiMessageBrowser = + firstChromeBrowser.contentDocument.getElementById("multiMessageBrowser"); + let firstWebBrowser = + firstChromeBrowser.contentDocument.getElementById("webBrowser"); + + threadTree.selectedIndices = [1, 2]; + Assert.ok(BrowserTestUtils.is_hidden(firstWebBrowser)); + Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser)); + Assert.ok(BrowserTestUtils.is_visible(firstMultiMessageBrowser)); + Assert.equal( + tabmail.currentAboutMessage, + null, + "currentAboutMessage should be null with multiple messages selected" + ); + Assert.equal(firstTab.browser, null); + Assert.equal(firstTab.linkedBrowser, null); + + // Load a web page. + + let loadedPromise = BrowserTestUtils.browserLoaded( + firstWebBrowser, + false, + "http://mochi.test:8888/" + ); + messagePane.displayWebPage("http://mochi.test:8888/"); + await loadedPromise; + Assert.ok(BrowserTestUtils.is_visible(firstWebBrowser)); + Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser)); + Assert.ok(BrowserTestUtils.is_hidden(firstMultiMessageBrowser)); + Assert.equal(firstWebBrowser.currentURI.spec, "http://mochi.test:8888/"); + Assert.equal( + tabmail.currentAboutMessage, + null, + "currentAboutMessage should be null with a web page loaded" + ); + Assert.equal(firstTab.browser, firstWebBrowser); + Assert.equal(firstTab.linkedBrowser, firstWebBrowser); + + // Go back to a single selection. + + threadTree.selectedIndex = 0; + Assert.ok(BrowserTestUtils.is_hidden(firstWebBrowser)); + Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser)); + Assert.ok(BrowserTestUtils.is_hidden(firstMultiMessageBrowser)); + Assert.equal( + tabmail.currentAboutMessage, + firstMessageBrowser.contentWindow, + "currentAboutMessage should have a value with a single message selected" + ); + Assert.equal(firstTab.browser, firstMessagePane); + Assert.equal(firstTab.linkedBrowser, firstMessagePane); + + // Open some more tabs. These should open in the background. + + window.MsgOpenNewTabForFolders([folderB], { + folderPaneVisible: true, + messagePaneVisible: true, + }); + + for (let message of messagesB) { + window.OpenMessageInNewTab(message, {}); + } + + Assert.equal(tabmail.tabInfo.length, 4); + Assert.equal(tabmail.currentTabInfo, firstTab); + Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow); + Assert.equal(tabmail.currentAboutMessage, firstMessageBrowser.contentWindow); + + // Check the second tab. + + tabmail.switchToTab(1); + Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[1]); + + let secondTab = tabmail.currentTabInfo; + Assert.equal(secondTab.mode.name, "mail3PaneTab"); + Assert.equal(secondTab.mode.tabType.name, "mailTab"); + + let secondChromeBrowser = secondTab.chromeBrowser; + await ensureBrowserLoaded(secondChromeBrowser); + Assert.equal(secondChromeBrowser.currentURI.spec, "about:3pane"); + Assert.equal(tabmail.currentAbout3Pane, secondChromeBrowser.contentWindow); + + let secondMessageBrowser = + secondChromeBrowser.contentDocument.getElementById("messageBrowser"); + await ensureBrowserLoaded(secondMessageBrowser); + Assert.equal(secondMessageBrowser.currentURI.spec, "about:message"); + + let secondMessagePane = + secondMessageBrowser.contentDocument.getElementById("messagepane"); + Assert.equal(secondMessagePane.currentURI.spec, "about:blank"); + Assert.equal( + tabmail.currentAboutMessage, + null, + "currentAboutMessage should be null with no message selected" + ); + Assert.equal(secondTab.browser, null); + Assert.equal(secondTab.linkedBrowser, null); + + Assert.equal(secondTab.folder, folderB); + + secondChromeBrowser.contentWindow.threadTree.selectedIndex = 0; + Assert.equal( + tabmail.currentAboutMessage, + secondMessageBrowser.contentWindow, + "currentAboutMessage should have a value with a message selected" + ); + + Assert.equal(secondChromeBrowser.contentWindow.tabOrWindow, secondTab); + Assert.equal(secondMessageBrowser.contentWindow.tabOrWindow, secondTab); + + // Check the third tab. + + tabmail.switchToTab(2); + Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[2]); + + let thirdTab = tabmail.currentTabInfo; + Assert.equal(thirdTab.mode.name, "mailMessageTab"); + Assert.equal(thirdTab.mode.tabType.name, "mailTab"); + + let thirdChromeBrowser = thirdTab.chromeBrowser; + await ensureBrowserLoaded(thirdChromeBrowser); + Assert.equal(thirdChromeBrowser.currentURI.spec, "about:message"); + Assert.equal(tabmail.currentAbout3Pane, null); + Assert.equal(tabmail.currentAboutMessage, thirdChromeBrowser.contentWindow); + + let thirdMessagePane = + thirdChromeBrowser.contentDocument.getElementById("messagepane"); + Assert.equal(thirdMessagePane.currentURI.spec, messageToURL(messagesB[0])); + Assert.equal(thirdTab.browser, thirdMessagePane); + Assert.equal(thirdTab.linkedBrowser, thirdMessagePane); + + Assert.equal(thirdTab.folder, folderB); + Assert.equal(thirdTab.message, messagesB[0]); + + Assert.equal(thirdChromeBrowser.contentWindow.tabOrWindow, thirdTab); + + // Check the fourth tab. + + tabmail.switchToTab(3); + Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[3]); + + let fourthTab = tabmail.currentTabInfo; + Assert.equal(fourthTab.mode.name, "mailMessageTab"); + Assert.equal(fourthTab.mode.tabType.name, "mailTab"); + + let fourthChromeBrowser = fourthTab.chromeBrowser; + await ensureBrowserLoaded(fourthChromeBrowser); + Assert.equal(fourthChromeBrowser.currentURI.spec, "about:message"); + Assert.equal(tabmail.currentAbout3Pane, null); + Assert.equal(tabmail.currentAboutMessage, fourthChromeBrowser.contentWindow); + + let fourthMessagePane = + fourthChromeBrowser.contentDocument.getElementById("messagepane"); + Assert.equal(fourthMessagePane.currentURI.spec, messageToURL(messagesB[1])); + Assert.equal(fourthTab.browser, fourthMessagePane); + Assert.equal(fourthTab.linkedBrowser, fourthMessagePane); + + Assert.equal(fourthTab.folder, folderB); + Assert.equal(fourthTab.message, messagesB[1]); + + Assert.equal(fourthChromeBrowser.contentWindow.tabOrWindow, fourthTab); + + // Close tabs. + + tabmail.closeTab(3); + Assert.equal(tabmail.currentTabInfo, thirdTab); + Assert.equal(tabmail.currentAbout3Pane, null); + Assert.equal(tabmail.currentAboutMessage, thirdChromeBrowser.contentWindow); + + tabmail.closeTab(2); + Assert.equal(tabmail.currentTabInfo, secondTab); + Assert.equal(tabmail.currentAbout3Pane, secondChromeBrowser.contentWindow); + Assert.equal(tabmail.currentAboutMessage, secondMessageBrowser.contentWindow); + + tabmail.closeTab(1); + Assert.equal(tabmail.currentTabInfo, firstTab); + Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow); + Assert.equal(tabmail.currentAboutMessage, firstMessageBrowser.contentWindow); +}); + +add_task(async function testMessageWindow() { + let messageWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + undefined, + async win => + win.document.documentURI == + "chrome://messenger/content/messageWindow.xhtml" + ); + MailUtils.openMessageInNewWindow(messagesB[0]); + + let messageWindow = await messageWindowPromise; + let messageBrowser = messageWindow.messageBrowser; + await ensureBrowserLoaded(messageBrowser); + Assert.equal(messageBrowser.contentWindow.tabOrWindow, messageWindow); + + await BrowserTestUtils.closeWindow(messageWindow); +}); + +async function ensureBrowserLoaded(browser) { + await TestUtils.waitForCondition( + () => + browser.currentURI.spec != "about:blank" && + browser.contentDocument.readyState == "complete", + "waiting for browser to finish loading" + ); +} + +function messageToURL(message) { + let messageService = MailServices.messageServiceFromURI("mailbox-message://"); + let uri = message.folder.getUriForMsg(message); + return messageService.getUrlForUri(uri).spec; +} diff --git a/comm/mail/base/test/browser/browser_markAsRead.js b/comm/mail/base/test/browser/browser_markAsRead.js new file mode 100644 index 0000000000..62b19d5b4e --- /dev/null +++ b/comm/mail/base/test/browser/browser_markAsRead.js @@ -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/. */ + +/** + * Tests that a message does not get marked as read if it is opened in a + * background tab. + */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let localTestFolder; + +add_setup(async function () { + // We need to get messages directly from the server when displaying them, + // or this test isn't really testing what it should. + Services.prefs.setBoolPref("mail.server.default.offline_download", false); + + const generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + const account = MailServices.accounts.accounts[0]; + const rootFolder = account.incomingServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + localTestFolder = rootFolder + .createLocalSubfolder("markAsRead") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + localTestFolder.addMessageBatch( + generator.makeMessages({}).map(message => message.toMboxString()) + ); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + Services.prefs.clearUserPref("mail.server.default.offline_download"); + Services.prefs.clearUserPref("mailnews.mark_message_read.auto"); + Services.prefs.clearUserPref("mailnews.mark_message_read.delay"); + Services.prefs.clearUserPref("mailnews.mark_message_read.delay.interval"); + }); +}); + +add_task(async function testLocal() { + await subtest(localTestFolder); +}); + +async function subtest(testFolder) { + const tabmail = document.getElementById("tabmail"); + const firstAbout3Pane = tabmail.currentAbout3Pane; + firstAbout3Pane.displayFolder(testFolder); + const testMessages = testFolder.messages; + + // Open a message in the first tab. It should get marked as read immediately. + + let message = testMessages.getNext(); + Assert.ok(!message.isRead, "message 0 should not be read before load"); + firstAbout3Pane.threadTree.selectedIndex = + firstAbout3Pane.gDBView.findIndexOfMsgHdr(message, false); + await BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + await TestUtils.waitForCondition( + () => message.isRead, + "waiting for message 0 to be marked as read" + ); + + firstAbout3Pane.threadTree.selectedIndex = -1; // Unload the message. + + // Open a message in a background tab. It should not get marked as read. + + message = testMessages.getNext(); + Assert.ok(!message.isRead, "message 1 should not be read before load"); + window.OpenMessageInNewTab(message, { background: true }); + await BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + Assert.ok( + !message.isRead, + "message 1 should not be read after opening in a background tab" + ); + + // Switch to the tab. The message should get marked as read immediately. + + tabmail.switchToTab(1); + await TestUtils.waitForTick(); + Assert.ok( + message.isRead, + "message 1 should be read after switching to the background tab" + ); + tabmail.closeTab(1); + + // With the marking delayed by preferences, open a message in a background tab. + // It should not get marked as read. + + Services.prefs.setBoolPref("mailnews.mark_message_read.delay", true); + Services.prefs.setIntPref("mailnews.mark_message_read.delay.interval", 2); + + message = testMessages.getNext(); + Assert.ok(!message.isRead, "message 2 should not be read before load"); + window.OpenMessageInNewTab(message, { background: true }); + await BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 3000)); + Assert.ok( + !message.isRead, + "message 2 should not be read after opening in a background tab" + ); + + // Switch to the tab. The message should get marked as read after the delay. + + const timeBeforeSwitchingTab = Date.now(); + tabmail.switchToTab(1); + Assert.ok( + !message.isRead, + "message 2 should not be read immediately after switching to the background tab" + ); + await TestUtils.waitForCondition( + () => message.isRead, + "waiting for message 2 to be marked as read" + ); + Assert.greaterOrEqual( + Date.now() - timeBeforeSwitchingTab, + 2000, + "message 2 should be read after switching to the background tab and the 2s delay" + ); + tabmail.closeTab(1); + + Services.prefs.setBoolPref("mailnews.mark_message_read.delay", false); + + // With the marking disabled by preferences, open a message in a background + // tab. It should not get marked as read. + + Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false); + + message = testMessages.getNext(); + Assert.ok(!message.isRead, "message 3 should not be read before load"); + window.OpenMessageInNewTab(message, { background: true }); + await BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + Assert.ok( + !message.isRead, + "message 3 should not be read after opening in a background tab" + ); + + // Switch to the tab. The message should not get marked as read. + + tabmail.switchToTab(1); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + Assert.ok( + !message.isRead, + "message 3 should not be read after switching to the background tab" + ); + tabmail.closeTab(1); + + Services.prefs.setBoolPref("mailnews.mark_message_read.auto", true); + + // Open a new 3-pane tab in the background and load a message in it. The + // message should not get marked as read. + + window.MsgOpenNewTabForFolders([testFolder], { + background: true, + messagePaneVisible: true, + }); + const secondAbout3Pane = tabmail.tabInfo[1].chromeBrowser.contentWindow; + await TestUtils.waitForCondition( + () => secondAbout3Pane.gDBView, + "waiting for view to load" + ); + + message = testMessages.getNext(); + Assert.ok(!message.isRead, "message 4 should not be read before load"); + secondAbout3Pane.threadTree.selectedIndex = + secondAbout3Pane.gDBView.findIndexOfMsgHdr(message, false); + await BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + Assert.ok( + !message.isRead, + "message 4 should not be read after opening in a background tab" + ); + + tabmail.switchToTab(1); + await TestUtils.waitForTick(); + Assert.ok( + message.isRead, + "message 4 should be read after switching to the background tab" + ); + tabmail.closeTab(1); + + // Open a message in a new foreground tab. It should get marked as read + // immediately. + + message = testMessages.getNext(); + Assert.ok(!message.isRead, "message 5 should not be read before load"); + window.OpenMessageInNewTab(message, { background: false }); + await BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + Assert.ok( + message.isRead, + "message 5 should be read after opening the foreground tab" + ); + tabmail.closeTab(1); +} diff --git a/comm/mail/base/test/browser/browser_menulist.js b/comm/mail/base/test/browser/browser_menulist.js new file mode 100644 index 0000000000..39a6a840d2 --- /dev/null +++ b/comm/mail/base/test/browser/browser_menulist.js @@ -0,0 +1,183 @@ +/* import-globals-from ../../content/utilityOverlay.js */ + +add_task(async () => { + let TEST_DOCUMENT_URL = getRootDirectory(gTestPath) + "files/menulist.xhtml"; + let testDocument = await new Promise(resolve => { + Services.obs.addObserver(function documentLoaded(subject) { + if (subject.URL == TEST_DOCUMENT_URL) { + Services.obs.removeObserver(documentLoaded, "chrome-document-loaded"); + resolve(subject); + } + }, "chrome-document-loaded"); + openContentTab(TEST_DOCUMENT_URL); + }); + ok(testDocument.URL == TEST_DOCUMENT_URL); + let testWindow = testDocument.ownerGlobal; + let MENULIST_CLASS = testWindow.customElements.get("menulist"); + let MENULIST_EDITABLE_CLASS = + testWindow.customElements.get("menulist-editable"); + + let menulists = testDocument.querySelectorAll("menulist"); + is(menulists.length, 3); + + // Menulist 0 is an ordinary, non-editable menulist. + ok(menulists[0] instanceof MENULIST_CLASS); + ok(!(menulists[0] instanceof MENULIST_EDITABLE_CLASS)); + ok(!("editable" in menulists[0])); + + // Menulist 1 is an editable menulist, but not in editing mode. + ok(menulists[1] instanceof MENULIST_CLASS); + ok(menulists[1] instanceof MENULIST_EDITABLE_CLASS); + ok("editable" in menulists[1]); + ok(!menulists[1].editable); + + // Menulist 2 is an editable menulist, in editing mode. + ok(menulists[2] instanceof MENULIST_CLASS); + ok(menulists[2] instanceof MENULIST_EDITABLE_CLASS); + ok("editable" in menulists[2]); + ok(menulists[2].editable); + + // Okay, let's check the focus order. + let testBrowser = document.getElementById("tabmail").currentTabInfo.browser; + EventUtils.synthesizeMouseAtCenter(testBrowser, { clickCount: 1 }); + await new Promise(resolve => setTimeout(resolve)); + + let beforeButton = testDocument.querySelector("button#before"); + beforeButton.focus(); + is(testDocument.activeElement, beforeButton); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow); + is(testDocument.activeElement, menulists[0]); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow); + is(testDocument.activeElement, menulists[1]); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow); + is(testDocument.activeElement, menulists[2]); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow); + is(testDocument.activeElement, menulists[2]); + is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow); + is(testDocument.activeElement, testDocument.querySelector("button#after")); + + // Now go back again. + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow); + is(testDocument.activeElement, menulists[2]); + is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow); + is(testDocument.activeElement, menulists[2]); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow); + is(testDocument.activeElement, menulists[1]); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow); + is(testDocument.activeElement, menulists[0]); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow); + is(testDocument.activeElement, beforeButton, "focus back to the start"); + + let popup = menulists[2].menupopup; + // The dropmarker should open and close the popup. + let openEvent = BrowserTestUtils.waitForEvent(popup, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + menulists[2]._dropmarker, + { clickCount: 1 }, + testWindow + ); + await openEvent; + ok(menulists[2].hasAttribute("open"), "popup open"); + + let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.hidePopup(); + await hiddenEvent; + ok(!menulists[2].hasAttribute("open"), "closed again"); + + // Open the popup and choose an item. + EventUtils.synthesizeMouseAtCenter( + menulists[2]._dropmarker, + { clickCount: 1 }, + testWindow + ); + await BrowserTestUtils.waitForEvent(popup, "popupshown"); + ok(menulists[2].hasAttribute("open"), "open for item click"); + + await new Promise(resolve => { + menulists[2].addEventListener("select", () => setTimeout(resolve), { + once: true, + }); + popup.activateItem(menulists[2].querySelectorAll("menuitem")[0]); + }); + ok(!menulists[2].hasAttribute("open")); + is(testDocument.activeElement, menulists[2]); + is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField); + is(menulists[2]._inputField.value, "foo"); + is(menulists[2].value, "foo"); + is(menulists[2].getAttribute("value"), "foo"); + + // Again. + EventUtils.synthesizeMouseAtCenter( + menulists[2]._dropmarker, + { clickCount: 1 }, + testWindow + ); + await BrowserTestUtils.waitForEvent(popup, "popupshown"); + ok(menulists[2].hasAttribute("open")); + + await new Promise(resolve => { + menulists[2].addEventListener("select", () => setTimeout(resolve), { + once: true, + }); + popup.activateItem(menulists[2].querySelectorAll("menuitem")[1]); + }); + ok(!menulists[2].hasAttribute("open")); + is(testDocument.activeElement, menulists[2]); + is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField); + is(menulists[2]._inputField.value, "bar"); + is(menulists[2].value, "bar"); + is(menulists[2].getAttribute("value"), "bar"); + + // Type in a value. + is(menulists[2]._inputField.selectionStart, 0); + is(menulists[2]._inputField.selectionEnd, 3); + EventUtils.sendString("quux", testWindow); + await new Promise(resolve => { + menulists[2].addEventListener( + "change", + event => { + is(event.target, menulists[2]); + resolve(); + }, + { once: true } + ); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow); + }); + is(menulists[2].value, "quux"); + is(menulists[2].getAttribute("value"), "quux"); + + // Open the popup and choose an item. + EventUtils.synthesizeMouseAtCenter( + menulists[2]._dropmarker, + { clickCount: 1 }, + testWindow + ); + await BrowserTestUtils.waitForEvent(popup, "popupshown"); + ok(menulists[2].hasAttribute("open")); + + await new Promise(resolve => { + menulists[2].addEventListener("select", () => setTimeout(resolve), { + once: true, + }); + popup.activateItem(menulists[2].querySelectorAll("menuitem")[0]); + }); + ok(!menulists[2].hasAttribute("open")); + is(testDocument.activeElement, menulists[2]); + is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField); + is(menulists[2]._inputField.value, "foo"); + is(menulists[2].value, "foo"); + is(menulists[2].getAttribute("value"), "foo"); + + document.getElementById("tabmail").closeOtherTabs(0); +}); diff --git a/comm/mail/base/test/browser/browser_messageMenu.js b/comm/mail/base/test/browser/browser_messageMenu.js new file mode 100644 index 0000000000..7b397f922e --- /dev/null +++ b/comm/mail/base/test/browser/browser_messageMenu.js @@ -0,0 +1,355 @@ +/* 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/. */ + +const { GlodaIndexer } = ChromeUtils.import( + "resource:///modules/gloda/GlodaIndexer.jsm" +); +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const nothingSelected = ["rootFolder", "noSelection", "contentTab"]; +const nothingOrMultiSelected = [...nothingSelected, "multiSelection"]; + +/** @type MenuData */ +const messageMenuData = { + newMsgCmd: {}, + replyMainMenu: { disabled: nothingSelected }, + replyNewsgroupMainMenu: { hidden: true }, + replySenderMainMenu: { hidden: true }, + menu_replyToAll: { disabled: nothingSelected }, + menu_replyToList: { disabled: true }, + menu_forwardMsg: { disabled: nothingSelected }, + forwardAsMenu: { disabled: nothingSelected }, + menu_forwardAsInline: { disabled: nothingSelected }, + menu_forwardAsAttachment: { disabled: nothingSelected }, + menu_redirectMsg: { disabled: nothingSelected }, + menu_editMsgAsNew: { disabled: nothingSelected }, + menu_editDraftMsg: { hidden: true }, + menu_newMsgFromTemplate: { hidden: true }, + menu_editTemplate: { hidden: true }, + openMessageWindowMenuitem: { + disabled: [...nothingSelected, "message", "externalMessage"], + }, + openConversationMenuitem: { + disabled: [...nothingOrMultiSelected, "externalMessage"], + }, + openFeedMessage: { hidden: true }, + menu_openFeedWebPage: { disabled: nothingSelected }, + menu_openFeedSummary: { disabled: nothingSelected }, + menu_openFeedWebPageInMessagePane: { + disabled: nothingSelected, + }, + msgAttachmentMenu: { disabled: true }, + tagMenu: { disabled: [...nothingSelected, "externalMessage"] }, + "tagMenu-addNewTag": { disabled: nothingSelected }, + "tagMenu-manageTags": { disabled: nothingSelected }, + "tagMenu-tagRemoveAll": { disabled: nothingSelected }, + markMenu: { disabled: ["rootFolder", "externalMessage", "contentTab"] }, + markReadMenuItem: { disabled: nothingSelected }, + markUnreadMenuItem: { disabled: true }, + menu_markThreadAsRead: { disabled: nothingSelected }, + menu_markReadByDate: { disabled: nothingSelected }, + menu_markAllRead: { disabled: ["rootFolder"] }, + markFlaggedMenuItem: { disabled: nothingSelected }, + menu_markAsJunk: { disabled: nothingSelected }, + menu_markAsNotJunk: { disabled: nothingSelected }, + menu_recalculateJunkScore: { + disabled: [...nothingSelected, "message"], + }, + archiveMainMenu: { disabled: [...nothingSelected, "externalMessage"] }, + menu_cancel: { hidden: true }, + moveMenu: { disabled: [...nothingSelected, "externalMessage"] }, + copyMenu: { disabled: nothingSelected }, + moveToFolderAgain: { disabled: true }, + createFilter: { disabled: [...nothingOrMultiSelected, "externalMessage"] }, + killThread: { disabled: [...nothingSelected, "message", "externalMessage"] }, + killSubthread: { + disabled: [...nothingSelected, "message", "externalMessage"], + }, + watchThread: { disabled: [...nothingSelected, "externalMessage"] }, +}; +let helper = new MenuTestHelper("messageMenu", messageMenuData); + +let tabmail = document.getElementById("tabmail"); +let rootFolder, testFolder, testMessages; +let draftsFolder, draftsMessages, templatesFolder, templatesMessages; + +add_setup(async function () { + Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false); + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("messageMenu", null); + testFolder = rootFolder + .getChildNamed("messageMenu") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + const messages = [ + ...generator.makeMessages({ count: 5 }), + ...generator.makeMessages({ count: 5, msgsPerThread: 5 }), + ]; + testFolder.addMessageBatch(messages.map(message => message.toMboxString())); + testFolder.addMessage( + generator + .makeMessage({ + attachments: [ + { + body: "an attachment", + contentType: "text/plain", + filename: "attachment.txt", + }, + ], + }) + .toMboxString() + ); + testFolder.addMessage( + "From - Mon Jan 01 00:00:00 2001\n" + + "To: Mailing List <list@example.com>\n" + + "Date: Mon, 01 Jan 2001 00:00:00 +0100\n" + + "List-Help: <https://list.example.com>\n" + + "List-Post: <mailto:list@example.com>\n" + + "List-Software: Mailing List Software\n" + + "List-Subscribe: <https://subscribe.example.com>\n" + + "Precedence: list\n" + + "Subject: Mailing List Test Mail\n" + + `Message-ID: <${Date.now()}@example.com>\n` + + "From: Mailing List <list@example.com>\n" + + "List-Unsubscribe: <https://unsubscribe.example.com>,\n" + + " <mailto:unsubscribe@example.com?subject=Unsubscribe Test>\n" + + "MIME-Version: 1.0\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "Content-Transfer-Encoding: quoted-printable\n" + + "\n" + + "Mailing List Message Body\n" + ); + testMessages = [...testFolder.messages]; + + rootFolder.createSubfolder("messageMenuDrafts", null); + draftsFolder = rootFolder + .getChildNamed("messageMenuDrafts") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + draftsFolder.setFlag(Ci.nsMsgFolderFlags.Drafts); + draftsFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + draftsMessages = [...draftsFolder.messages]; + rootFolder.createSubfolder("messageMenuTemplates", null); + templatesFolder = rootFolder + .getChildNamed("messageMenuTemplates") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + templatesFolder.setFlag(Ci.nsMsgFolderFlags.Templates); + templatesFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + templatesMessages = [...templatesFolder.messages]; + + window.OpenMessageInNewTab(testMessages[0], { background: true }); + await BrowserTestUtils.waitForEvent( + tabmail.tabInfo[1].chromeBrowser, + "MsgLoaded" + ); + + let messageFile = new FileUtils.File( + getTestFilePath("files/sampleContent.eml") + ); + let messageURI = + Services.io.newFileURI(messageFile).spec + + "?type=application/x-message-display"; + tabmail.openTab("mailMessageTab", { background: true, messageURI }); + + window.openTab("contentTab", { + url: "https://example.com/", + background: true, + }); + + await TestUtils.waitForCondition( + () => !GlodaIndexer.indexing, + "waiting for Gloda to finish indexing", + 500 + ); + + registerCleanupFunction(() => { + tabmail.closeOtherTabs(0); + MailServices.accounts.removeAccount(account, false); + Services.prefs.clearUserPref("mailnews.mark_message_read.auto"); + }); +}); + +add_task(async function testRootFolder() { + tabmail.currentAbout3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + folderURI: rootFolder, + }); + await new Promise(resolve => setTimeout(resolve)); + await helper.testAllItems("rootFolder"); +}); + +add_task(async function testNoSelection() { + tabmail.currentAbout3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + folderURI: testFolder, + }); + await new Promise(resolve => setTimeout(resolve)); + await helper.testAllItems("noSelection"); +}); + +add_task(async function testSingleSelection() { + tabmail.currentAbout3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + folderURI: testFolder, + }); + await new Promise(resolve => setTimeout(resolve)); + + // This message is not marked as read. + tabmail.currentAbout3Pane.threadTree.selectedIndex = 1; + await helper.testAllItems("singleSelection"); + + // Mark it as read. + testMessages[1].markRead(true); + await helper.testItems({ + markMenu: {}, + markReadMenuItem: { disabled: true }, + markUnreadMenuItem: {}, + menu_markThreadAsRead: { disabled: true }, + }); + + // Mark it as starred. + testMessages[1].markFlagged(true); + await helper.testItems({ + markMenu: {}, + markFlaggedMenuItem: { checked: true }, + }); + + testFolder.addKeywordsToMessages([testMessages[1]], "$label1"); + await helper.testItems({ + tagMenu: {}, + "tagMenu-tagRemoveAll": {}, + }); + + // This message has an attachment. + tabmail.currentAbout3Pane.threadTree.selectedIndex = 6; + await BrowserTestUtils.browserLoaded( + tabmail.currentAboutMessage.getMessagePaneBrowser() + ); + + await helper.testItems({ + msgAttachmentMenu: {}, + "menu-openAllAttachments": {}, + "menu-saveAllAttachments": {}, + "menu-detachAllAttachments": {}, + "menu-deleteAllAttachments": {}, + }); + + // This message is from a mailing list. + tabmail.currentAbout3Pane.threadTree.selectedIndex = 7; + await BrowserTestUtils.browserLoaded( + tabmail.currentAboutMessage.getMessagePaneBrowser() + ); + await helper.testItems({ + menu_replyToList: { disabled: false }, + }); + + // FIXME: Select another message and wait for it load in order to properly + // clear about:message. + tabmail.currentAbout3Pane.threadTree.selectedIndex = 1; + await BrowserTestUtils.browserLoaded( + tabmail.currentAboutMessage.getMessagePaneBrowser() + ); +}); + +add_task(async function testMultiSelection() { + tabmail.currentAbout3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + folderURI: testFolder, + }); + await new Promise(resolve => setTimeout(resolve)); + + // These messages aren't marked as read or flagged, or have a tag. + tabmail.currentAbout3Pane.threadTree.selectedIndices = [2, 4]; + await helper.testAllItems("multiSelection"); + + // ONE of these messages IS marked as read and flagged, and it has a tag. + tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4]; + await helper.testItems({ + markMenu: {}, + markReadMenuItem: {}, + markUnreadMenuItem: {}, + menu_markThreadAsRead: { disabled: false }, + markFlaggedMenuItem: { checked: true }, + tagMenu: {}, + "tagMenu-tagRemoveAll": {}, + }); + + // Messages in a collapsed thread. + tabmail.currentAbout3Pane.threadTree.selectedIndex = 5; + await helper.testItems({ + replyMainMenu: { disabled: true }, + menu_replyToAll: { disabled: true }, + menu_redirectMsg: { disabled: true }, + menu_editMsgAsNew: { disabled: true }, + }); +}); + +add_task(async function testDraftsFolder() { + tabmail.currentAbout3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + folderURI: draftsFolder, + }); + await new Promise(resolve => setTimeout(resolve)); + + tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4]; + await helper.testItems({ + menu_editDraftMsg: { hidden: false }, + }); + tabmail.currentAbout3Pane.threadTree.selectedIndices = [3]; + await helper.testItems({ + menu_editDraftMsg: { hidden: false }, + }); +}); + +add_task(async function testTemplatesFolder() { + tabmail.currentAbout3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + folderURI: templatesFolder, + }); + await new Promise(resolve => setTimeout(resolve)); + + tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4]; + await helper.testItems({ + menu_newMsgFromTemplate: { hidden: false }, + menu_editTemplate: { hidden: false }, + }); + tabmail.currentAbout3Pane.threadTree.selectedIndices = [3]; + await helper.testItems({ + menu_newMsgFromTemplate: { hidden: false }, + menu_editTemplate: { hidden: false }, + }); +}); + +add_task(async function testMessageTab() { + tabmail.switchToTab(1); + await helper.testAllItems("message"); +}); + +add_task(async function testExternalMessageTab() { + tabmail.switchToTab(2); + await helper.testAllItems("externalMessage"); +}); + +add_task(async function testContentTab() { + tabmail.switchToTab(3); + await helper.testAllItems("contentTab"); +}); diff --git a/comm/mail/base/test/browser/browser_navigation.js b/comm/mail/base/test/browser/browser_navigation.js new file mode 100644 index 0000000000..baa7bc6142 --- /dev/null +++ b/comm/mail/base/test/browser/browser_navigation.js @@ -0,0 +1,1035 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +const { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/PromiseTestUtils.jsm" +); + +let tabmail = document.getElementById("tabmail"); +let about3Pane = tabmail.currentAbout3Pane; +let { messageBrowser, multiMessageBrowser, threadTree } = about3Pane; +let mailboxService = MailServices.messageServiceFromURI("mailbox:"); +let folderA, + folderAMessages, + folderB, + folderBMessages, + folderC, + folderCMessages, + folderD, + folderDMessages; + +add_setup(async function () { + Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + let rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("Navigation A", null); + folderA = rootFolder + .getChildNamed("Navigation A") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderA.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + folderAMessages = [...folderA.messages]; + folderA.markAllMessagesRead(null); + + rootFolder.createSubfolder("Navigation B", null); + folderB = rootFolder + .getChildNamed("Navigation B") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderB.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + folderBMessages = [...folderB.messages]; + folderB.markAllMessagesRead(null); + + rootFolder.createSubfolder("Navigation C", null); + folderC = rootFolder + .getChildNamed("Navigation C") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + // Add a lot of messages so scrolling can be tested. + folderC.addMessageBatch( + generator + .makeMessages({ count: 500 }) + .map(message => message.toMboxString()) + ); + folderC.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + folderCMessages = [...folderC.messages]; + folderC.markAllMessagesRead(null); + + rootFolder.createSubfolder("Navigation D", null); + folderD = rootFolder + .getChildNamed("Navigation D") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderD.addMessageBatch( + generator + .makeMessages({ + count: 12, + msgsPerThread: 3, + }) + .map(message => message.toMboxString()) + ); + folderDMessages = [...folderD.messages]; + folderD.markAllMessagesRead(null); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + Services.prefs.clearUserPref("mailnews.mark_message_read.auto"); + }); +}); + +/** Tests the next message/previous message commands. */ +add_task(async function testNextPreviousMessageInAbout3Pane() { + const aboutMessage = messageBrowser.contentWindow; + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + about3Pane.displayFolder(folderA.URI); + assertSelectedMessage(); + await assertNoDisplayedMessage(aboutMessage); + + for (let i = 0; i < 5; i++) { + goDoCommand("cmd_nextMsg"); + assertSelectedMessage(folderAMessages[i]); + await assertDisplayedMessage(aboutMessage, folderAMessages[i]); + } + + threadTree.addEventListener("select", reportBadSelectEvent); + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + goDoCommand("cmd_nextMsg"); + assertSelectedMessage( + folderAMessages[4], + "the selected message should not change" + ); + await assertDisplayedMessage(aboutMessage, folderAMessages[4]); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + threadTree.removeEventListener("select", reportBadSelectEvent); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + for (let i = 3; i >= 0; i--) { + goDoCommand("cmd_previousMsg"); + assertSelectedMessage(folderAMessages[i]); + await assertDisplayedMessage(aboutMessage, folderAMessages[i]); + } + + threadTree.addEventListener("select", reportBadSelectEvent); + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + goDoCommand("cmd_previousMsg"); + assertSelectedMessage( + folderAMessages[0], + "the selected message should not change" + ); + await assertDisplayedMessage(aboutMessage, folderAMessages[0]); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + threadTree.removeEventListener("select", reportBadSelectEvent); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + threadTree.selectedIndex = -1; + await assertNoDisplayedMessage(aboutMessage); +}); + +async function subtestNextPreviousMessage(win, aboutMessage) { + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + await assertDisplayedMessage(aboutMessage, folderAMessages[2]); + + for (let i = 3; i < 5; i++) { + win.goDoCommand("cmd_nextMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[i]); + } + + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + win.goDoCommand("cmd_nextMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[4]); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + for (let i = 3; i >= 0; i--) { + win.goDoCommand("cmd_previousMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[i]); + } + + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + win.goDoCommand("cmd_previousMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[0]); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); +} + +/** Tests the next message/previous message commands in a message tab. */ +add_task(async function testNextPreviousMessageInATab() { + await withMessageInATab(folderAMessages[2], subtestNextPreviousMessage); +}); + +/** Tests the next message/previous message commands in a message window. */ +add_task(async function testNextPreviousMessageInAWindow() { + await withMessageInAWindow(folderAMessages[2], subtestNextPreviousMessage); +}); + +/** Tests the next unread message command. */ +add_task(async function testNextUnreadMessageInAbout3Pane() { + const aboutMessage = messageBrowser.contentWindow; + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false); + folderC.markMessagesRead( + [folderCMessages[500], folderCMessages[501], folderCMessages[504]], + false + ); + folderD.markMessagesRead( + [ + folderDMessages[3], + folderDMessages[4], + folderDMessages[6], + folderDMessages[7], + ], + false + ); + + about3Pane.displayFolder(folderA.URI); + threadTree.selectedIndex = -1; + assertSelectedMessage(); + await assertNoDisplayedMessage(aboutMessage); + + // Select the first unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderAMessages[3]); + await assertDisplayedMessage(aboutMessage, folderAMessages[3]); + + // Select the next unread message. Loops to start of folder. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + // Mark the message as read. + goDoCommand("cmd_markAsRead"); + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderAMessages[3]); + await assertDisplayedMessage(aboutMessage, folderAMessages[3]); + + // Select the next unread message. Changes to the next folder. + let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept"); + goDoCommand("cmd_nextUnreadMsg"); + await dialogPromise; + await new Promise(resolve => setTimeout(resolve)); + assertSelectedFolder(folderC); + assertSelectedMessage(folderCMessages[500]); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderCMessages[501]); + await assertDisplayedMessage(aboutMessage, folderCMessages[501]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderCMessages[504]); + await assertDisplayedMessage(aboutMessage, folderCMessages[504]); + + // Select the first message in folder D and make sure all threads are + // collapsed. + about3Pane.displayFolder(folderD.URI); + threadTree.selectedIndex = 0; + let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_collapseAllThreads"); + await selectPromise; + assertSelectedMessage(folderDMessages[0]); + + // Go to the next thread without expanding it. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertSelectedMessage(folderDMessages[3]); + + // The next displayed message should be the root message of the now expanded + // thread. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[3]); + await assertDisplayedMessage(aboutMessage, folderDMessages[3]); + + // Select the next unread message in the thread. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[4]); + await assertDisplayedMessage(aboutMessage, folderDMessages[4]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[6]); + await assertDisplayedMessage(aboutMessage, folderDMessages[6]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[7]); + await assertDisplayedMessage(aboutMessage, folderDMessages[7]); + + // Mark folder D read again. + folderD.markAllMessagesRead(null); + + // Go back to the first folder. The previous selection should be restored. + about3Pane.displayFolder(folderA.URI); + assertSelectedMessage(folderAMessages[3]); + await assertDisplayedMessage(aboutMessage, folderAMessages[3]); + + // Select the next unread message. Changes to the next folder. + // The previous selection should NOT be restored. + dialogPromise = BrowserTestUtils.promiseAlertDialog("accept"); + goDoCommand("cmd_nextUnreadMsg"); + await dialogPromise; + await new Promise(resolve => setTimeout(resolve)); + assertSelectedFolder(folderC); + assertSelectedMessage(folderCMessages[500]); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + folderC.markAllMessagesRead(null); + // No more unread messages, prompt to move to the next folder. + // Cancel the prompt. + threadTree.addEventListener("select", reportBadSelectEvent); + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel"); + goDoCommand("cmd_nextUnreadMsg"); + await dialogPromise; + assertSelectedFolder(folderC); + assertSelectedMessage(folderCMessages[500]); + + folderA.markAllMessagesRead(null); + // No unread messages anywhere, do nothing. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedFolder(folderC); + assertSelectedMessage(folderCMessages[500]); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + threadTree.removeEventListener("select", reportBadSelectEvent); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + threadTree.selectedIndex = -1; + await assertNoDisplayedMessage(aboutMessage); +}); + +async function subtestNextUnreadMessage(win, aboutMessage) { + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false); + folderC.markMessagesRead( + [folderCMessages[500], folderCMessages[501], folderCMessages[504]], + false + ); + Assert.equal(folderC.getNumUnread(false), 3); + + await assertDisplayedMessage(aboutMessage, folderAMessages[0]); + + // Select the first unread message. + win.goDoCommand("cmd_nextUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + // Select the next unread message. + win.goDoCommand("cmd_nextUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[3]); + + // Select the next unread message. Loops to start of folder. + win.goDoCommand("cmd_nextUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + // Mark the message as read. + win.goDoCommand("cmd_markAsRead"); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + // Select the next unread message. + win.goDoCommand("cmd_nextUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[3]); + + // Select the next unread message. Changes to the next folder. + let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept"); + win.goDoCommand("cmd_nextUnreadMsg"); + await dialogPromise; + await new Promise(resolve => setTimeout(resolve)); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + // Select the next unread message. + win.goDoCommand("cmd_nextUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderCMessages[501]); + + // Select the next unread message. + win.goDoCommand("cmd_nextUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderCMessages[504]); + + folderC.markAllMessagesRead(null); + // No more unread messages, prompt to move to the next folder. + // Cancel the prompt. + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel"); + win.goDoCommand("cmd_nextUnreadMsg"); + await dialogPromise; + + folderA.markAllMessagesRead(null); + // No unread messages anywhere, do nothing. + win.goDoCommand("cmd_nextUnreadMsg"); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); +} + +/** Tests the next unread message command in a message tab. */ +add_task(async function testNextUnreadMessageInATab() { + await withMessageInATab(folderAMessages[0], subtestNextUnreadMessage); +}); + +/** Tests the next unread message command in a message window. */ +add_task(async function testNextUnreadMessageInAWindow() { + await withMessageInAWindow(folderAMessages[0], subtestNextUnreadMessage); +}); + +/** Tests the previous unread message command. This doesn't cross folders. */ +add_task(async function testPreviousUnreadMessageInAbout3Pane() { + const aboutMessage = messageBrowser.contentWindow; + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false); + folderC.markMessagesRead( + [folderCMessages[500], folderCMessages[501], folderCMessages[504]], + false + ); + + about3Pane.displayFolder(folderC.URI); + threadTree.scrollToIndex(504, true); + // Ensure the scrolling from the previous line happens. + await new Promise(resolve => requestAnimationFrame(resolve)); + threadTree.selectedIndex = 504; + assertSelectedMessage(folderCMessages[504]); + await assertDisplayedMessage(aboutMessage, folderCMessages[504]); + + goDoCommand("cmd_previousUnreadMsg"); + assertSelectedMessage(folderCMessages[501]); + await assertDisplayedMessage(aboutMessage, folderCMessages[501]); + + goDoCommand("cmd_previousUnreadMsg"); + assertSelectedMessage(folderCMessages[500]); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + threadTree.addEventListener("select", reportBadSelectEvent); + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + goDoCommand("cmd_previousUnreadMsg"); + assertSelectedMessage(folderCMessages[500]); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + threadTree.removeEventListener("select", reportBadSelectEvent); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + threadTree.selectedIndex = -1; + await assertNoDisplayedMessage(aboutMessage); +}); + +async function subtestPreviousUnreadMessage(win, aboutMessage) { + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false); + folderC.markMessagesRead( + [folderCMessages[500], folderCMessages[501], folderCMessages[504]], + false + ); + + await assertDisplayedMessage(aboutMessage, folderCMessages[504]); + + win.goDoCommand("cmd_previousUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderCMessages[501]); + + win.goDoCommand("cmd_previousUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + win.goDoCommand("cmd_previousUnreadMsg"); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); +} + +/** Tests the previous unread message command in a message tab. */ +add_task(async function testPreviousUnreadMessageInATab() { + await withMessageInATab(folderCMessages[504], subtestPreviousUnreadMessage); +}); + +/** Tests the previous unread message command in a message window. */ +add_task(async function testPreviousUnreadMessageInAWindow() { + await withMessageInAWindow( + folderCMessages[504], + subtestPreviousUnreadMessage + ); +}); + +/** + * Tests the next unread thread command. This command depends on marking the + * thread as read, despite mailnews.mark_message_read.auto being false in this + * test. Seems wrong, but it does make this test less complicated! + */ +add_task(async function testNextUnreadThreadInAbout3Pane() { + const aboutMessage = messageBrowser.contentWindow; + + folderC.markMessagesRead( + [folderCMessages[500], folderCMessages[501], folderCMessages[504]], + false + ); + folderD.markMessagesRead( + [ + folderDMessages[0], + folderDMessages[1], + folderDMessages[2], + folderDMessages[8], + folderDMessages[9], + folderDMessages[10], + folderDMessages[11], + ], + false + ); + + // In folder C, there are no threads. Going to the next unread thread is the + // same as going to the next unread message. But as stated above, it does + // mark the current message as read. + about3Pane.displayFolder(folderC.URI); + threadTree.scrollToIndex(504, true); + // Ensure the scrolling from the previous line happens. + await new Promise(resolve => requestAnimationFrame(resolve)); + threadTree.selectedIndex = 500; + assertSelectedMessage(folderCMessages[500]); + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + goDoCommand("cmd_nextUnreadThread"); + assertSelectedMessage(folderCMessages[501]); + await assertDisplayedMessage(aboutMessage, folderCMessages[501]); + + goDoCommand("cmd_nextUnreadThread"); + assertSelectedMessage(folderCMessages[504]); + await assertDisplayedMessage(aboutMessage, folderCMessages[504]); + + // No more unread messages, we'll move to folder D. + let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept"); + goDoCommand("cmd_nextUnreadThread"); + await dialogPromise; + assertSelectedFolder(folderD); + assertSelectedMessage(folderDMessages[0]); + await assertDisplayedMessage(aboutMessage, folderDMessages[0]); + + goDoCommand("cmd_nextUnreadThread"); + // The root message is read, we're looking at a single message in the thread. + assertSelectedMessage(folderDMessages[8]); + await assertDisplayedMessage(aboutMessage, folderDMessages[8]); + + goDoCommand("cmd_nextUnreadThread"); + // The root message is unread. + assertSelectedMessage(folderDMessages[9]); + await assertDisplayedMessage(aboutMessage, folderDMessages[9]); + + // No more unread messages, prompt to move to the next folder. + // Cancel the prompt. + dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel"); + goDoCommand("cmd_nextUnreadThread"); + await dialogPromise; + assertSelectedMessage(folderDMessages[9]); + + threadTree.selectedIndex = -1; + await assertNoDisplayedMessage(aboutMessage); +}); + +async function subtestNextUnreadThread(win, aboutMessage) { + folderC.markMessagesRead( + [folderCMessages[500], folderCMessages[501], folderCMessages[504]], + false + ); + folderD.markMessagesRead( + [ + folderDMessages[0], + folderDMessages[1], + folderDMessages[2], + folderDMessages[8], + folderDMessages[9], + folderDMessages[10], + folderDMessages[11], + ], + false + ); + + await assertDisplayedMessage(aboutMessage, folderCMessages[500]); + + win.goDoCommand("cmd_nextUnreadThread"); + await assertDisplayedMessage(aboutMessage, folderCMessages[501]); + + win.goDoCommand("cmd_nextUnreadThread"); + await assertDisplayedMessage(aboutMessage, folderCMessages[504]); + + // No more unread messages, we'll move to folder D. + let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept"); + win.goDoCommand("cmd_nextUnreadThread"); + await dialogPromise; + await assertDisplayedMessage(aboutMessage, folderDMessages[0]); + + win.goDoCommand("cmd_nextUnreadThread"); + // The root message is read, we're looking at a single message in the thread. + await assertDisplayedMessage(aboutMessage, folderDMessages[8]); + + win.goDoCommand("cmd_nextUnreadThread"); + // The root message is unread. + await assertDisplayedMessage(aboutMessage, folderDMessages[9]); + + // No more unread messages, prompt to move to the next folder. + // Cancel the prompt. + dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel"); + win.goDoCommand("cmd_nextUnreadThread"); + await dialogPromise; +} + +/** Tests the next unread thread command in a message tab. */ +add_task(async function testNextUnreadThreadInATab() { + await withMessageInATab(folderCMessages[500], subtestNextUnreadThread); +}); + +/** Tests the next unread thread command in a message window. */ +add_task(async function testNextUnreadThreadInAWindow() { + await withMessageInAWindow(folderCMessages[500], subtestNextUnreadThread); +}); + +/** Tests that navigation with a closed message pane does not load messages. */ +add_task(async function testHiddenMessagePaneInAbout3Pane() { + const aboutMessage = messageBrowser.contentWindow; + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + about3Pane.paneLayout.messagePaneVisible = false; + about3Pane.displayFolder(folderA.URI); + threadTree.selectedIndex = 0; + assertSelectedMessage(folderAMessages[0]); + await assertNoDisplayedMessage(aboutMessage); + + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + goDoCommand("cmd_nextMsg"); + assertSelectedMessage(folderAMessages[1]); + await assertNoDisplayedMessage(aboutMessage); + + goDoCommand("cmd_previousMsg"); + assertSelectedMessage(folderAMessages[0]); + await assertNoDisplayedMessage(aboutMessage); + + // Wait to prove bad things didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + threadTree.selectedIndex = -1; + about3Pane.paneLayout.messagePaneVisible = true; +}); + +/** Tests the go back/forward commands. */ +add_task(async function testMessageHistoryInAbout3Pane() { + const aboutMessage = messageBrowser.contentWindow; + const { messageHistory } = aboutMessage; + messageHistory.clear(); + about3Pane.displayFolder(folderA.URI); + threadTree.selectedIndex = 0; + assertSelectedMessage(folderAMessages[0]); + await assertDisplayedMessage(aboutMessage, folderAMessages[0]); + + goDoCommand("cmd_nextMsg"); + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + Assert.ok(messageHistory.canPop(-1), "Going back should be available"); + Assert.ok( + !messageHistory.canPop(0), + "Should not be able to go back to the current message" + ); + Assert.ok( + !messageHistory.canPop(1), + "Should not have any message to go forward to" + ); + Assert.ok( + !window.getEnabledControllerForCommand("cmd_goForward"), + "Go forward should be disabled" + ); + + goDoCommand("cmd_goBack"); + assertSelectedMessage(folderAMessages[0]); + await assertDisplayedMessage(aboutMessage, folderAMessages[0]); + + Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to"); + Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to"); + Assert.ok( + !window.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be disabled" + ); + + goDoCommand("cmd_goForward"); + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok( + !messageHistory.canPop(1), + "Should have no message to go forward to" + ); + Assert.ok( + window.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be enabled" + ); + + // Switching folder to test going back/forward between folders. + about3Pane.displayFolder(folderB.URI); + threadTree.selectedIndex = 0; + assertSelectedMessage(folderBMessages[0]); + await assertDisplayedMessage(aboutMessage, folderBMessages[0]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok( + !messageHistory.canPop(1), + "Should have no message to go forward to" + ); + Assert.ok( + !window.getEnabledControllerForCommand("cmd_goForward"), + "Go forward should be disabled" + ); + + goDoCommand("cmd_goBack"); + + assertSelectedFolder(folderA); + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to"); + Assert.ok( + window.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be enabled" + ); + + goDoCommand("cmd_goForward"); + + assertSelectedFolder(folderB); + assertSelectedMessage(folderBMessages[0]); + await assertDisplayedMessage(aboutMessage, folderBMessages[0]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok( + !messageHistory.canPop(1), + "Should have no message to go forward to" + ); + Assert.ok( + !window.getEnabledControllerForCommand("cmd_goForward"), + "Go forward should be disabled" + ); + + goDoCommand("cmd_goBack"); + + assertSelectedFolder(folderA); + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to"); + Assert.ok( + window.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be enabled" + ); + + // Select a different message while going forward is possible, clearing the + // previous forward history. + + goDoCommand("cmd_nextMsg"); + + assertSelectedMessage(folderAMessages[2]); + await assertDisplayedMessage(aboutMessage, folderAMessages[2]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok( + !messageHistory.canPop(1), + "Should have no message to go forward to" + ); + Assert.ok( + !window.getEnabledControllerForCommand("cmd_goForward"), + "Go forward should be disabled" + ); + + goDoCommand("cmd_goBack"); + + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to"); + Assert.ok( + window.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be enabled" + ); + + // Remove the previous message in the history from the folder it was + // displayed in. + + let movedMessage = folderAMessages[0]; + await moveMessage(folderA, movedMessage, folderB); + + Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to"); + Assert.ok( + !window.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be disabled" + ); + + // Display no message, so going back goes to the previously displayed message, + // which is also the current history entry. + threadTree.selectedIndex = -1; + await assertNoDisplayedMessage(aboutMessage); + + Assert.ok( + messageHistory.canPop(0), + "Can go back to current history entry without selected message" + ); + Assert.ok( + window.getEnabledControllerForCommand("cmd_goForward"), + "Go forward should be enabled" + ); + + goDoCommand("cmd_goBack"); + + assertSelectedMessage(folderAMessages[1]); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + threadTree.selectedIndex = -1; + let currentFolderBMessages = [...folderB.messages]; + movedMessage = currentFolderBMessages.find( + message => !folderBMessages.includes(message) + ); + await moveMessage(folderB, movedMessage, folderA); + folderAMessages = [...folderA.messages]; +}); + +async function subtestMessageHistory(win, aboutMessage) { + const { messageHistory } = aboutMessage; + await assertDisplayedMessage(aboutMessage, folderAMessages[0]); + + Assert.ok(win.getEnabledControllerForCommand("cmd_nextMsg")); + win.goDoCommand("cmd_nextMsg"); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + Assert.ok(messageHistory.canPop(-1), "Going back should be available"); + Assert.ok( + !messageHistory.canPop(0), + "Should not be able to go back to the current message" + ); + Assert.ok( + !messageHistory.canPop(1), + "Should not have any message to go forward to" + ); + Assert.ok( + !win.getEnabledControllerForCommand("cmd_goForward"), + "Go forward should be disabled" + ); + + Assert.ok(win.getEnabledControllerForCommand("cmd_goBack")); + win.goDoCommand("cmd_goBack"); + await assertDisplayedMessage(aboutMessage, folderAMessages[0]); + + Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to"); + Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to"); + Assert.ok( + !win.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be disabled" + ); + + Assert.ok(win.getEnabledControllerForCommand("cmd_goForward")); + win.goDoCommand("cmd_goForward"); + await assertDisplayedMessage(aboutMessage, folderAMessages[1]); + + Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to"); + Assert.ok( + !messageHistory.canPop(1), + "Should have no message to go forward to" + ); + Assert.ok( + win.getEnabledControllerForCommand("cmd_goBack"), + "Go back should be enabled" + ); +} + +/** Tests the go back/forward commands in a message tab. */ +add_task(async function testMessageHistoryInATab() { + await withMessageInATab(folderAMessages[0], subtestMessageHistory); +}); + +/** Tests the go back/forward commands in a message window. */ +add_task(async function testMessageHistoryInAWindow() { + await withMessageInAWindow(folderAMessages[0], subtestMessageHistory); +}); + +function assertSelectedFolder(expected) { + Assert.equal(about3Pane.gFolder.URI, expected.URI, "selected folder"); +} + +function assertSelectedMessage(expected, comment) { + if (expected) { + Assert.notEqual( + threadTree.selectedIndex, + -1, + "a message should be selected" + ); + Assert.ok( + threadTree.getRowAtIndex(threadTree.selectedIndex), + "row for selected message should exist and be in view" + ); + Assert.equal( + about3Pane.gDBView.getMsgHdrAt(threadTree.selectedIndex).messageId, + expected.messageId, + comment ?? "selected message" + ); + } else { + Assert.equal(threadTree.selectedIndex, -1, "no message should be selected"); + } +} + +async function assertDisplayedMessage(aboutMessage, expected) { + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + let mailboxURL = expected.folder.getUriForMsg(expected); + let messageURI = mailboxService.getUrlForUri(mailboxURL); + + if ( + messagePaneBrowser.webProgess?.isLoadingDocument || + !messagePaneBrowser.currentURI.equals(messageURI) + ) { + await BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + messageURI.spec + ); + } + Assert.equal( + aboutMessage.gMessage.messageId, + expected.messageId, + "correct message loaded" + ); + Assert.equal( + messagePaneBrowser.currentURI.spec, + messageURI.spec, + "correct message displayed" + ); +} + +async function assertDisplayedThread(firstMessage) { + let items = multiMessageBrowser.contentDocument.querySelectorAll("li"); + Assert.equal( + items[0].dataset.messageId, + firstMessage.messageId, + "correct thread displayed" + ); + Assert.ok( + BrowserTestUtils.is_visible(multiMessageBrowser), + "multimessageview visible" + ); +} + +async function assertNoDisplayedMessage(aboutMessage) { + const messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + if ( + messagePaneBrowser.webProgess?.isLoadingDocument || + messagePaneBrowser.currentURI.spec != "about:blank" + ) { + await BrowserTestUtils.browserLoaded( + messagePaneBrowser, + undefined, + "about:blank" + ); + } + + Assert.equal(aboutMessage.gMessage, null, "no message loaded"); + Assert.equal( + messagePaneBrowser.currentURI.spec, + "about:blank", + "no message displayed" + ); + Assert.ok(BrowserTestUtils.is_hidden(messageBrowser), "about:message hidden"); +} + +function reportBadSelectEvent() { + Assert.report( + true, + undefined, + undefined, + "should not have fired a select event" + ); +} + +function reportBadLoad() { + Assert.report( + true, + undefined, + undefined, + "should not have reloaded the message" + ); +} + +function moveMessage(sourceFolder, message, targetFolder) { + let copyListener = new PromiseTestUtils.PromiseCopyListener(); + MailServices.copy.copyMessages( + sourceFolder, + [message], + targetFolder, + true, + copyListener, + window.msgWindow, + true + ); + return copyListener.promise; +} + +async function withMessageInATab(message, subtest) { + let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded"); + window.OpenMessageInNewTab(message, { background: false }); + await tabPromise; + await new Promise(resolve => setTimeout(resolve)); + + await subtest(window, tabmail.currentAboutMessage); + + tabmail.closeOtherTabs(0); +} + +async function withMessageInAWindow(message, subtest) { + let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + window.MsgOpenNewWindowForMessage(message); + let win = await winPromise; + await BrowserTestUtils.waitForEvent(win, "MsgLoaded"); + await TestUtils.waitForCondition(() => Services.focus.activeWindow == win); + + await subtest(win, win.messageBrowser.contentWindow); + + await BrowserTestUtils.closeWindow(win); +} diff --git a/comm/mail/base/test/browser/browser_orderableTreeListbox.js b/comm/mail/base/test/browser/browser_orderableTreeListbox.js new file mode 100644 index 0000000000..54634cbd88 --- /dev/null +++ b/comm/mail/base/test/browser/browser_orderableTreeListbox.js @@ -0,0 +1,481 @@ +/* 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/. */ + +/* eslint mozilla/no-arbitrary-setTimeout: off */ + +let dragService = Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService +); + +let WAIT_TIME = 0; + +let tabmail = document.getElementById("tabmail"); +registerCleanupFunction(() => { + tabmail.closeOtherTabs(tabmail.tabInfo[0]); + Services.prefs.clearUserPref("ui.prefersReducedMotion"); + Services.prefs.clearUserPref("mailnews.default_view_flags"); +}); + +async function withMotion(subtest) { + Services.prefs.setIntPref("ui.prefersReducedMotion", 0); + WAIT_TIME = 300; + await TestUtils.waitForCondition( + () => !matchMedia("(prefers-reduced-motion)").matches + ); + return subtest(); +} + +async function withoutMotion(subtest) { + Services.prefs.setIntPref("ui.prefersReducedMotion", 1); + WAIT_TIME = 0; + await TestUtils.waitForCondition( + () => matchMedia("(prefers-reduced-motion)").matches + ); + await subtest(); +} + +let win, doc, list, dataTransfer; + +async function orderWithKeys(key) { + selectHandler.reset(); + orderedHandler.reset(); + + list.addEventListener("select", selectHandler); + list.addEventListener("ordered", orderedHandler); + EventUtils.synthesizeKey(key, { altKey: true }, win); + await new Promise(resolve => win.setTimeout(resolve, WAIT_TIME)); + list.removeEventListener("select", selectHandler); + list.removeEventListener("ordered", orderedHandler); + + await checkNoTransformations(); +} + +async function startDrag(index) { + let listRect = list.getBoundingClientRect(); + let clientY = listRect.top + index * 32 + 4; + + dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE); + [, dataTransfer] = EventUtils.synthesizeDragOver( + list.rows[index], + list, + null, + null, + win, + win, + { + clientY, + _domDispatchOnly: true, + } + ); + + await new Promise(resolve => setTimeout(resolve, WAIT_TIME)); +} + +async function continueDrag(index) { + let listRect = list.getBoundingClientRect(); + let destClientX = listRect.left + listRect.width / 2; + let destClientY = listRect.top + index * 32 + 4; + let destScreenX = win.mozInnerScreenX + destClientX; + let destScreenY = win.mozInnerScreenY + destClientY; + + let result = EventUtils.sendDragEvent( + { + type: "dragover", + screenX: destScreenX, + screenY: destScreenY, + clientX: destClientX, + clientY: destClientY, + dataTransfer, + _domDispatchOnly: true, + }, + list, + win + ); + + await new Promise(resolve => setTimeout(resolve, WAIT_TIME)); + return result; +} + +async function endDrag(index) { + let listRect = list.getBoundingClientRect(); + let clientY = listRect.top + index * 32 + 4; + + EventUtils.synthesizeDropAfterDragOver(false, dataTransfer, list, win, { + clientY, + _domDispatchOnly: true, + }); + list.dispatchEvent(new CustomEvent("dragend", { bubbles: true })); + dragService.endDragSession(true); + + await new Promise(resolve => setTimeout(resolve, WAIT_TIME)); +} + +function checkRowOrder(expectedOrder) { + expectedOrder = expectedOrder.split(" ").map(i => `row-${i}`); + Assert.equal(list.rowCount, expectedOrder.length, "rowCount is correct"); + Assert.deepEqual( + list.rows.map(row => row.id), + expectedOrder, + "order in DOM is correct" + ); + + let apparentOrder = list.rows.sort( + (a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top + ); + Assert.deepEqual( + apparentOrder.map(row => row.id), + expectedOrder, + "order on screen is correct" + ); + + if (orderedHandler.orderAtEvent) { + Assert.deepEqual( + orderedHandler.orderAtEvent, + expectedOrder, + "order at the last 'ordered' event was correct" + ); + } +} + +function checkYPositions(...expectedPositions) { + let offset = list.getBoundingClientRect().top; + + for (let i = 0; i < 5; i++) { + let id = `row-${i + 1}`; + let row = doc.getElementById(id); + Assert.equal( + row.getBoundingClientRect().top - offset, + expectedPositions[i], + id + ); + } +} + +async function checkNoTransformations() { + for (let row of list.children) { + await TestUtils.waitForCondition( + () => win.getComputedStyle(row).transform == "none", + `${row.id} has no transforms` + ); + Assert.equal( + row + .getAnimations() + .filter(animation => animation.transitionProperty != "opacity").length, + 0, + `${row.id} has no animations` + ); + } +} + +let selectHandler = { + seenEvent: null, + + reset() { + this.seenEvent = null; + }, + handleEvent(event) { + this.seenEvent = event; + }, +}; + +let orderedHandler = { + seenEvent: null, + orderAtEvent: null, + + reset() { + this.seenEvent = null; + this.orderAtEvent = null; + }, + handleEvent(event) { + if (this.seenEvent) { + throw new Error("we already have an 'ordered' event"); + } + this.seenEvent = event; + this.orderAtEvent = list.rows.map(row => row.id); + }, +}; + +/** Test Alt+Up and Alt+Down. */ +async function subtestKeyReorder() { + list.focus(); + list.selectedIndex = 0; + + // Move row 1 down the list to the bottom. + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 1 3 3-1 3-2 3-3 4 5 5-1 5-2"); + + // Some additional checks to prove the right row is selected. + + Assert.ok(!selectHandler.seenEvent); + Assert.equal(list.selectedIndex, 3, "correct index is selected"); + Assert.equal( + list.querySelector(".selected").id, + "row-1", + "correct row is selected" + ); + + EventUtils.synthesizeKey("KEY_ArrowUp", {}, win); + Assert.equal( + list.querySelector(".selected").id, + "row-2-2", + "key press moved to the correct row" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2 1"); + + // Move row 1 back to the top. + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 1 3 3-1 3-2 3-3 4 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2"); + + // Move row 3 around. Row 3 has children, so we're checking they move with it. + + list.selectedIndex = 4; + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 3 3-1 3-2 3-3 2 2-1 2-2 4 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("3 3-1 3-2 3-3 1 2 2-1 2-2 4 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 3 3-1 3-2 3-3 2 2-1 2-2 4 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 2 2-1 2-2 4 3 3-1 3-2 3-3 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 2 2-1 2-2 4 5 5-1 5-2 3 3-1 3-2 3-3"); + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 2 2-1 2-2 4 3 3-1 3-2 3-3 5 5-1 5-2"); + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2"); +} + +/** Drag the first item to the end. */ +async function subtestDragReorder1() { + orderedHandler.reset(); + list.addEventListener("ordered", orderedHandler); + + checkYPositions(1, 33, 129, 257, 289); + + await startDrag(0); + checkYPositions(1, 33, 129, 257, 289); + + await continueDrag(2); + checkYPositions(52, 1, 129, 257, 289); + await continueDrag(3); + checkYPositions(84, 1, 129, 257, 289); + await continueDrag(4); + checkYPositions(116, 1, 129, 257, 289); + await continueDrag(5); + checkYPositions(148, 1, 129, 257, 289); + await continueDrag(6); + checkYPositions(180, 1, 97, 257, 289); + await continueDrag(7); + checkYPositions(212, 1, 97, 257, 289); + await continueDrag(8); + checkYPositions(244, 1, 97, 225, 289); + await continueDrag(9); + checkYPositions(276, 1, 97, 225, 289); + await continueDrag(10); + checkYPositions(308, 1, 97, 225, 257); + await continueDrag(11); + checkYPositions(340, 1, 97, 225, 257); + await continueDrag(12); + checkYPositions(353, 1, 97, 225, 257); + + await endDrag(12); + list.removeEventListener("ordered", orderedHandler); + + Assert.ok(orderedHandler.seenEvent); + checkYPositions(353, 1, 97, 225, 257); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2 1"); + await checkNoTransformations(); +} + +/** Drag the (now) last item back to the start. */ +async function subtestDragReorder2() { + orderedHandler.reset(); + list.addEventListener("ordered", orderedHandler); + + await startDrag(11); + checkYPositions(340, 1, 97, 225, 257); + + await continueDrag(9); + checkYPositions(276, 1, 97, 225, 289); + + await continueDrag(7); + checkYPositions(212, 1, 97, 257, 289); + + await continueDrag(4); + checkYPositions(116, 1, 129, 257, 289); + + await continueDrag(1); + checkYPositions(20, 33, 129, 257, 289); + + await endDrag(0); + list.removeEventListener("ordered", orderedHandler); + + Assert.ok(orderedHandler.seenEvent); + checkYPositions(1, 33, 129, 257, 289); + checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2"); + await checkNoTransformations(); +} + +/** + * Listen for the 'ordering' event and prevent dropping on some rows. + * + * In this test, we'll prevent dragging an item below the last one - row-5 and + * its descendants. Other use cases may be possible but haven't been needed + * yet, so they are untested. + */ +async function subtestDragUndroppable() { + let originalGetter = list.__lookupGetter__("_orderableChildren"); + list.__defineGetter__("_orderableChildren", function () { + let rows = [...this.children]; + rows.pop(); + return rows; + }); + + orderedHandler.reset(); + list.addEventListener("ordered", orderedHandler); + + checkYPositions(1, 33, 129, 257, 289); + + await startDrag(0); + checkYPositions(1, 33, 129, 257, 289); + + await continueDrag(8); + checkYPositions(244, 1, 97, 225, 289); + await continueDrag(9); + checkYPositions(257, 1, 97, 225, 289); + await continueDrag(10); + checkYPositions(257, 1, 97, 225, 289); + await continueDrag(11); + checkYPositions(257, 1, 97, 225, 289); + await continueDrag(12); + checkYPositions(257, 1, 97, 225, 289); + + await endDrag(12); + list.removeEventListener("ordered", orderedHandler); + + Assert.ok(orderedHandler.seenEvent); + checkYPositions(257, 1, 97, 225, 289); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2"); + await checkNoTransformations(); + + // Move row-3 down with the keyboard. + + list.selectedIndex = 7; + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2"); + + // It should not move further down. + + await orderWithKeys("KEY_ArrowDown"); + Assert.ok(!orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2"); + + // Reset the order. + + await orderWithKeys("KEY_ArrowUp"); + Assert.ok(orderedHandler.seenEvent); + checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2"); + + orderedHandler.reset(); + await startDrag(8); + await continueDrag(1); + await endDrag(1); + checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2"); + + list.__defineGetter__("_orderableChildren", originalGetter); +} + +add_setup(async function () { + // Make sure the whole test runs with an unthreaded view in all folders. + Services.prefs.setIntPref("mailnews.default_view_flags", 0); + + let tab = tabmail.openTab("contentTab", { + url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml", + }); + + await BrowserTestUtils.browserLoaded(tab.browser); + tab.browser.focus(); + + win = tab.browser.contentWindow; + doc = win.document; + + list = doc.querySelector(`ol[is="orderable-tree-listbox"]`); + Assert.ok(!!list, "the list exists"); + + checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2"); + Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0"); +}); + +add_task(async function testKeyReorder() { + await withMotion(subtestKeyReorder); +}); +add_task(async function testDragReorder1() { + await withMotion(subtestDragReorder1); +}); +add_task(async function testDragReorder2() { + await withMotion(subtestDragReorder2); +}); +add_task(async function testDragUndroppable() { + await withMotion(subtestDragUndroppable); +}); + +add_task(async function testKeyReorderReducedMotion() { + await withoutMotion(subtestKeyReorder); +}); +add_task(async function testDragReorder1ReducedMotion() { + await withoutMotion(subtestDragReorder1); +}); +add_task(async function testDragReorder2ReducedMotion() { + await withoutMotion(subtestDragReorder2); +}); +add_task(async function testDragUndroppableReducedMotion() { + await withoutMotion(subtestDragUndroppable); +}); diff --git a/comm/mail/base/test/browser/browser_paneFocus.js b/comm/mail/base/test/browser/browser_paneFocus.js new file mode 100644 index 0000000000..9b85b4afe3 --- /dev/null +++ b/comm/mail/base/test/browser/browser_paneFocus.js @@ -0,0 +1,375 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let mailButton = document.getElementById("mailButton"); +let globalSearch = document.querySelector("#unifiedToolbar global-search-bar"); +let addressBookButton = document.getElementById("addressBookButton"); +let calendarButton = document.getElementById("calendarButton"); +let tasksButton = document.getElementById("tasksButton"); +let tabmail = document.getElementById("tabmail"); + +let rootFolder, testFolder, testMessages, addressBook; + +add_setup(async function () { + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + // Quick Filter Bar needs to be toggled on for F6 focus shift to be accurate. + goDoCommand("cmd_showQuickFilterBar"); + + rootFolder.createSubfolder("paneFocus", null); + testFolder = rootFolder + .getChildNamed("paneFocus") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + testFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + testMessages = [...testFolder.messages]; + + let prefName = MailServices.ab.newAddressBook( + "paneFocus", + null, + Ci.nsIAbManager.JS_DIRECTORY_TYPE + ); + addressBook = MailServices.ab.getDirectoryFromId(prefName); + let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance( + Ci.nsIAbCard + ); + contact.displayName = "contact 1"; + contact.firstName = "contact"; + contact.lastName = "1"; + contact.primaryEmail = "contact.1@invalid"; + addressBook.addCard(contact); + + registerCleanupFunction(async () => { + MailServices.accounts.removeAccount(account, false); + let removePromise = TestUtils.topicObserved("addrbook-directory-deleted"); + MailServices.ab.deleteAddressBook(addressBook.URI); + await removePromise; + }); +}); + +add_task(async function testMail3PaneTab() { + document.body.focus(); + + let about3Pane = tabmail.currentAbout3Pane; + about3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + }); + let { + folderTree, + threadTree, + webBrowser, + messageBrowser, + multiMessageBrowser, + accountCentralBrowser, + } = about3Pane; + + // Reset focus to accountCentralBrowser because QFB was toggled on. + accountCentralBrowser.focus(); + info("Displaying the root folder"); + about3Pane.displayFolder(rootFolder.URI); + cycle( + mailButton, + globalSearch, + folderTree, + accountCentralBrowser, + mailButton + ); + + info("Displaying the test folder"); + about3Pane.displayFolder(testFolder.URI); + threadTree.selectedIndex = 0; + cycle( + globalSearch, + folderTree, + threadTree.table.body, + messageBrowser.contentWindow.getMessagePaneBrowser(), + mailButton, + globalSearch + ); + + info("Hiding the folder pane"); + about3Pane.restoreState({ folderPaneVisible: false }); + cycle( + threadTree.table.body, + messageBrowser.contentWindow.getMessagePaneBrowser(), + mailButton, + globalSearch, + threadTree.table.body + ); + + info("Showing the folder pane, hiding the message pane"); + about3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: false, + }); + cycle( + mailButton, + globalSearch, + folderTree, + threadTree.table.body, + mailButton + ); + + info("Showing the message pane, selecting multiple messages"); + about3Pane.restoreState({ messagePaneVisible: true }); + threadTree.selectedIndices = [1, 2]; + cycle( + globalSearch, + folderTree, + threadTree.table.body, + multiMessageBrowser, + mailButton, + globalSearch + ); + + info("Showing a web page"); + about3Pane.messagePane.displayWebPage("https://example.com/"); + cycle( + folderTree, + threadTree.table.body, + webBrowser, + mailButton, + globalSearch, + folderTree + ); + + info("Testing focus from secondary focus targets"); + about3Pane.document.getElementById("folderPaneMoreButton").focus(); + EventUtils.synthesizeKey("KEY_F6", {}, about3Pane); + Assert.equal( + getActiveElement(), + folderTree, + "F6 moved the focus to the folder tree" + ); + + about3Pane.document.getElementById("folderPaneMoreButton").focus(); + EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, about3Pane); + Assert.equal( + getActiveElement().id, + globalSearch.id, + "Shift+F6 moved the focus to the toolbar" + ); + + about3Pane.document.getElementById("qfb-qs-textbox").focus(); + EventUtils.synthesizeKey("KEY_F6", {}, about3Pane); + Assert.equal( + getActiveElement(), + threadTree.table.body, + "F6 moved the focus to the threadTree" + ); + + about3Pane.document.getElementById("qfb-qs-textbox").focus(); + EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, about3Pane); + Assert.equal( + getActiveElement(), + folderTree, + "Shift+F6 moved the focus to the folder tree" + ); +}); + +add_task(async function testMailMessageTab() { + document.body.focus(); + + window.OpenMessageInNewTab(testMessages[0], { background: false }); + await BrowserTestUtils.waitForEvent( + tabmail.tabInfo[1].chromeBrowser, + "MsgLoaded" + ); + cycle(mailButton, globalSearch, tabmail.tabInfo[1].browser, mailButton); + + tabmail.closeOtherTabs(0); +}); + +add_task(async function testAddressBookTab() { + EventUtils.synthesizeMouseAtCenter(addressBookButton, {}); + await BrowserTestUtils.browserLoaded(tabmail.currentTabInfo.browser); + + let abWindow = tabmail.currentTabInfo.browser.contentWindow; + let abDocument = abWindow.document; + let booksList = abDocument.getElementById("books"); + let searchInput = abDocument.getElementById("searchInput"); + let cardsList = abDocument.getElementById("cards"); + let detailsPane = abDocument.getElementById("detailsPane"); + let editButton = abDocument.getElementById("editButton"); + + // Switch to the table view so the edit button isn't falling off the window. + abWindow.cardsPane.toggleLayout(true); + + // Check what happens with a contact selected. + let row = booksList.getRowForUID(addressBook.UID); + EventUtils.synthesizeMouseAtCenter(row.querySelector("span"), {}, abWindow); + + Assert.ok(BrowserTestUtils.is_hidden(detailsPane)); + // NOTE: When the "cards" element first receives focus it will select the + // first item, which causes the panel to be displayed. + cycle( + searchInput, + cardsList.table.body, + editButton, + addressBookButton, + globalSearch, + booksList, + searchInput + ); + Assert.ok(BrowserTestUtils.is_visible(detailsPane)); + + // Check with no selection. + EventUtils.synthesizeMouseAtCenter( + cardsList.getRowAtIndex(0), + { accelKey: true }, + abWindow + ); + Assert.equal(getActiveElement(), cardsList.table.body); + Assert.ok(BrowserTestUtils.is_hidden(detailsPane)); + cycle( + addressBookButton, + globalSearch, + booksList, + searchInput, + cardsList.table.body, + addressBookButton + ); + // Still hidden. + Assert.ok(BrowserTestUtils.is_hidden(detailsPane)); + + // Check what happens while editing. It should be nothing. + EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow); + Assert.equal(getActiveElement(), cardsList.table.body); + Assert.ok(BrowserTestUtils.is_visible(detailsPane)); + + editButton.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow); + Assert.equal(abDocument.activeElement.id, "vcard-n-firstname"); + EventUtils.synthesizeKey("KEY_F6", {}, abWindow); + Assert.equal( + abDocument.activeElement.id, + "vcard-n-firstname", + "F6 did nothing" + ); + EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, abWindow); + Assert.equal( + abDocument.activeElement.id, + "vcard-n-firstname", + "Shift+F6 did nothing" + ); + + tabmail.closeOtherTabs(0); +}); + +add_task(async function testCalendarTab() { + EventUtils.synthesizeMouseAtCenter(calendarButton, {}); + + cycle(calendarButton, globalSearch, calendarButton); + + tabmail.closeOtherTabs(0); +}); + +add_task(async function testTasksTab() { + EventUtils.synthesizeMouseAtCenter(tasksButton, {}); + + cycle(tasksButton, globalSearch, tasksButton); + + tabmail.closeOtherTabs(0); +}); + +add_task(async function testContentTab() { + document.body.focus(); + + window.openTab("contentTab", { + url: "https://example.com/", + background: false, + }); + await BrowserTestUtils.browserLoaded( + tabmail.currentTabInfo.browser, + undefined, + "https://example.com/" + ); + cycle(mailButton, globalSearch, tabmail.currentTabInfo.browser, mailButton); + + document.body.focus(); + + window.openTab("contentTab", { url: "about:mozilla", background: false }); + await BrowserTestUtils.browserLoaded( + tabmail.currentTabInfo.browser, + undefined, + "about:mozilla" + ); + cycle( + globalSearch, + tabmail.currentTabInfo.browser.contentDocument.body, + mailButton, + globalSearch + ); + + tabmail.closeOtherTabs(0); +}); + +/** + * Gets the active element. If it is a browser, returns the browser in some + * special cases we're interested in, or the browser's active element. + * + * @returns {Element} + */ +function getActiveElement() { + let activeElement = document.activeElement; + if (globalSearch.contains(activeElement)) { + return globalSearch; + } + if (activeElement.localName == "browser" && !activeElement.isRemoteBrowser) { + activeElement = activeElement.contentDocument.activeElement; + } + if ( + activeElement.localName == "browser" && + activeElement.id == "messageBrowser" + ) { + activeElement = activeElement.contentDocument.activeElement; + } + return activeElement; +} + +/** + * Presses F6 for each element in `elements`, and checks the element has focus. + * Then presses Shift+F6 to go back through the elements. + * Note that the currently selected element should *not* be the first element. + * + * @param {Element[]} + */ +function cycle(...elements) { + let activeElement = getActiveElement(); + + for (let i = 0; i < elements.length; i++) { + EventUtils.synthesizeKey("KEY_F6", {}, activeElement.ownerGlobal); + activeElement = getActiveElement(); + Assert.equal( + activeElement.id || activeElement.localName, + elements[i].id || elements[i].localName, + "F6 moved the focus" + ); + } + + for (let i = elements.length - 2; i >= 0; i--) { + EventUtils.synthesizeKey( + "KEY_F6", + { shiftKey: true }, + activeElement.ownerGlobal + ); + activeElement = getActiveElement(); + Assert.equal( + activeElement.id || activeElement.localName, + elements[i].id || elements[i].localName, + "Shift+F6 moved the focus" + ); + } +} diff --git a/comm/mail/base/test/browser/browser_paneSplitter.js b/comm/mail/base/test/browser/browser_paneSplitter.js new file mode 100644 index 0000000000..1646703b96 --- /dev/null +++ b/comm/mail/base/test/browser/browser_paneSplitter.js @@ -0,0 +1,572 @@ +/* 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/. */ + +let tabmail = document.getElementById("tabmail"); +registerCleanupFunction(() => { + tabmail.closeOtherTabs(tabmail.tabInfo[0]); +}); + +// Increase this value to slow the test down if you want to see what it is doing. +let MOUSE_DELAY = 0; + +let win, doc; + +let resizingEvents = 0; +let resizedEvents = 0; +let collapsedEvents = 0; +let expandedEvents = 0; + +// This object keeps the test simple by removing the differences between +// horizontal and vertical, and which pane is controlled by the splitter. +let testRunner = { + outer: null, // The container for the splitter and panes. + splitter: null, // The splitter. + resizedIsBefore: null, // Whether resized is before the splitter. + resized: null, // The pane the splitter controls the size of. + fill: null, // The pane that splitter doesn't control. + dimension: null, // Which dimension the splitter resizes. + + getSize(element) { + return element.getBoundingClientRect()[this.dimension]; + }, + + assertElementSizes(size, msg = "") { + Assert.equal( + this.getSize(this.resized), + size, + `Resized element should take up the expected ${this.dimension}: ${msg}` + ); + Assert.equal( + this.getSize(this.fill), + 500 - size, + `Fill element should take up the rest of the ${this.dimension}: ${msg}` + ); + }, + + assertSplitterSize(size, msg = "") { + Assert.equal( + this.splitter[this.dimension], + size, + `Splitter ${this.dimension} should match expected ${size}: ${msg}` + ); + }, + + get minSizeProperty() { + return this.dimension == "width" ? "minWidth" : "minHeight"; + }, + + get maxSizeProperty() { + return this.dimension == "width" ? "maxWidth" : "maxHeight"; + }, + + get collapseSizeAttribute() { + return this.dimension == "width" ? "collapse-width" : "collapse-height"; + }, + + setCollapseSize(size) { + this.splitter.setAttribute(this.collapseSizeAttribute, size); + }, + + clearCollapseSize() { + this.splitter.removeAttribute(this.collapseSizeAttribute); + }, + + async synthMouse(position, type = "mousemove", otherPosition = 50) { + let x, y; + if (!this.resizedIsBefore) { + position = 500 - position; + } + if (this.dimension == "width") { + [x, y] = [position, otherPosition]; + } else { + [x, y] = [otherPosition, position]; + } + EventUtils.synthesizeMouse( + this.splitter.parentNode, + x, + y, + { type, buttons: 1 }, + win + ); + + if (MOUSE_DELAY) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, MOUSE_DELAY)); + } + + await new Promise(resolve => requestAnimationFrame(resolve)); + }, +}; + +add_setup(async function () { + let tab = tabmail.openTab("contentTab", { + url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/paneSplitter.xhtml", + }); + + await BrowserTestUtils.browserLoaded(tab.browser); + tab.browser.focus(); + + win = tab.browser.contentWindow; + doc = win.document; + + win.addEventListener("splitter-resizing", event => resizingEvents++); + win.addEventListener("splitter-resized", event => resizedEvents++); + win.addEventListener("splitter-collapsed", event => collapsedEvents++); + win.addEventListener("splitter-expanded", event => expandedEvents++); +}); + +add_task(async function testHorizontalBefore() { + let outer = doc.getElementById("horizontal-before"); + let resized = outer.querySelector(".resized"); + let splitter = outer.querySelector(`hr[is="pane-splitter"]`); + let fill = outer.querySelector(".fill"); + + Assert.equal(resized.clientWidth, 200); + Assert.equal(fill.clientWidth, 300); + Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize"); + + testRunner.outer = outer; + testRunner.splitter = splitter; + testRunner.resizedIsBefore = true; + testRunner.resized = resized; + testRunner.fill = fill; + testRunner.dimension = "width"; + + await subtestDrag(); + await subtestDragSizeBounds(); + await subtestDragAutoCollapse(); + await subtestCollapseExpand(); +}); + +add_task(async function testHorizontalAfter() { + let outer = doc.getElementById("horizontal-after"); + let fill = outer.querySelector(".fill"); + let splitter = outer.querySelector(`hr[is="pane-splitter"]`); + let resized = outer.querySelector(".resized"); + + Assert.equal(fill.clientWidth, 300); + Assert.equal(resized.clientWidth, 200); + Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize"); + + testRunner.outer = outer; + testRunner.splitter = splitter; + testRunner.resizedIsBefore = false; + testRunner.resized = resized; + testRunner.fill = fill; + testRunner.dimension = "width"; + + await subtestDrag(); + await subtestDragSizeBounds(); + await subtestDragAutoCollapse(); + await subtestCollapseExpand(); +}); + +add_task(async function testVerticalBefore() { + let outer = doc.getElementById("vertical-before"); + let resized = outer.querySelector(".resized"); + let splitter = outer.querySelector(`hr[is="pane-splitter"]`); + let fill = outer.querySelector(".fill"); + + Assert.equal(resized.clientHeight, 200); + Assert.equal(fill.clientHeight, 300); + Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize"); + + testRunner.outer = outer; + testRunner.splitter = splitter; + testRunner.resizedIsBefore = true; + testRunner.resized = resized; + testRunner.fill = fill; + testRunner.dimension = "height"; + + await subtestDrag(); + await subtestDragSizeBounds(); + await subtestDragAutoCollapse(); + await subtestCollapseExpand(); +}); + +add_task(async function testVerticalAfter() { + let outer = doc.getElementById("vertical-after"); + let fill = outer.querySelector(".fill"); + let splitter = outer.querySelector(`hr[is="pane-splitter"]`); + let resized = outer.querySelector(".resized"); + + testRunner.outer = outer; + testRunner.splitter = splitter; + testRunner.resizedIsBefore = false; + testRunner.resized = resized; + testRunner.fill = fill; + testRunner.dimension = "height"; + + Assert.equal(fill.clientHeight, 300); + Assert.equal(resized.clientHeight, 200); + Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize"); + + await subtestDrag(); + await subtestDragSizeBounds(); + await subtestDragAutoCollapse(); + await subtestCollapseExpand(); +}); + +async function subtestDrag() { + info("subtestDrag"); + resizingEvents = 0; + resizedEvents = 0; + + let originalPosition = testRunner.getSize(testRunner.resized); + let position = 200; + + await testRunner.synthMouse(position, "mousedown"); + + await testRunner.synthMouse(position, "mousemove", 25); + Assert.equal(resizingEvents, 0, "moving up the splitter does nothing"); + await testRunner.synthMouse(position, "mousemove", 75); + Assert.equal(resizingEvents, 0, "moving down the splitter does nothing"); + + position--; + await testRunner.synthMouse(position); + Assert.equal(resizingEvents, 0, "moving 1px does nothing"); + + position--; + await testRunner.synthMouse(position); + Assert.equal(resizingEvents, 0, "moving 2px does nothing"); + + position--; + await testRunner.synthMouse(position); + Assert.equal(resizingEvents, 1, "a resizing event fired"); + + // Drag in steps to the left-hand/top end. + for (; position >= 0; position -= 50) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes(position); + } + + // Drag beyond the left-hand/top end. + position = -50; + await testRunner.synthMouse(position); + testRunner.assertElementSizes(0); + + // Drag in steps to the right-hand/bottom end. + for (let position = 0; position <= 500; position += 50) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes(position); + } + + // Drag beyond the right-hand/bottom end. + position = 550; + await testRunner.synthMouse(position); + testRunner.assertElementSizes(500); + + // Drop. + position = 400; + Assert.equal(resizingEvents, 1, "no more resizing events fired"); + Assert.equal(resizedEvents, 0, "no resized events fired"); + await testRunner.synthMouse(position); + await testRunner.synthMouse(position, "mouseup"); + testRunner.assertElementSizes(400); + Assert.equal(resizingEvents, 1, "no more resizing events fired"); + Assert.equal(resizedEvents, 1, "a resized event fired"); + + // Pick up again. + await testRunner.synthMouse(position, "mousedown"); + + // Move. + for (; position >= originalPosition; position -= 50) { + await testRunner.synthMouse(position); + } + + // Drop. + Assert.equal(resizingEvents, 2, "a resizing event fired"); + Assert.equal(resizedEvents, 1, "no more resized events fired"); + await testRunner.synthMouse(position, "mouseup"); + testRunner.assertElementSizes(originalPosition); + Assert.equal(resizingEvents, 2, "no more resizing events fired"); + Assert.equal(resizedEvents, 2, "a resized event fired"); +} + +async function subtestDragSizeBounds() { + info("subtestDragSizeBounds"); + + let { splitter, resized, fill, minSizeProperty, maxSizeProperty } = + testRunner; + + // Various min or max sizes to set on the resized and fill elements. + // NOTE: the sum of the max sizes is greater than 500px. + // Moreover, the resized element's min size is below 200px, and the max size + // above it. Similarly, the fill element's min size is below 300px. This + // ensures that the initial sizes of 200px and 300px are within their + // respective min-max bounds. + // NOTE: We do not set a max size on the fill element. The grid layout does + // not handle this. Nor is it an expected usage of the splitter. + for (let [minResized, min] of [ + [null, 0], + ["100.5px", 100.5], + ]) { + for (let [maxResized, expectMax1] of [ + [null, 500], + ["360px", 360], + ]) { + for (let [minFill, expectMax2] of [ + [null, 500], + ["148px", 352], + ]) { + info(`Bounds [${minResized}, ${maxResized}] and [${minFill}, none]`); + let max = Math.min(expectMax1, expectMax2); + info(`Overall bound [${min}px, ${max}px]`); + + // Construct a set of positions we are interested in. + let roundMin = Math.floor(min); + let roundMax = Math.ceil(max); + let positionSet = [-50, 150, 350, 550]; + // Include specific positions around the minimum and maximum points. + positionSet.push(roundMin - 1, roundMin, roundMin + 1); + positionSet.push(roundMax - 1, roundMax, roundMax + 1); + positionSet.sort(); + + // Reset the splitter. + splitter.width = null; + splitter.height = null; + + resized.style[minSizeProperty] = minResized; + resized.style[maxSizeProperty] = maxResized; + fill.style[minSizeProperty] = minFill; + + testRunner.assertElementSizes(200, "initial position"); + await testRunner.synthMouse(200, "mousedown"); + + for (let position of positionSet) { + await testRunner.synthMouse(position); + let size = Math.min(Math.max(position, min), max); + testRunner.assertElementSizes(size, `Moved forward to ${position}`); + testRunner.assertSplitterSize(size, `Moved forward to ${position}`); + } + + await testRunner.synthMouse(500); + await testRunner.synthMouse(500, "mouseup"); + testRunner.assertElementSizes(max, "positioned at max"); + testRunner.assertSplitterSize(max, "positioned at max"); + + // Reverse. + await testRunner.synthMouse(max, "mousedown"); + + for (let position of positionSet.reverse()) { + await testRunner.synthMouse(position); + let size = Math.min(Math.max(position, min), max); + testRunner.assertElementSizes(size, `Moved backward to ${position}`); + testRunner.assertSplitterSize(size, `Moved backward to ${position}`); + } + + await testRunner.synthMouse(0); + await testRunner.synthMouse(0, "mouseup"); + testRunner.assertElementSizes(min, "positioned at min"); + testRunner.assertSplitterSize(min, "positioned at min"); + } + } + } + + // Reset. + splitter.width = null; + splitter.height = null; + resized.style[minSizeProperty] = null; + resized.style[maxSizeProperty] = null; + fill.style[minSizeProperty] = null; +} + +async function subtestDragAutoCollapse() { + info("subtestDragAutoCollapse"); + testRunner.setCollapseSize(78); + + collapsedEvents = 0; + expandedEvents = 0; + + let { splitter } = testRunner; + + let originalPosition = 200; + + // Drag in steps toward the left-hand/top end. + await testRunner.synthMouse(200, "mousedown"); + for (let position of [180, 160, 140, 120, 100, 80, 78]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes( + position, + `Should have ${position} size at ${position}` + ); + Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`); + } + + // For the first 20 pixels inside the minimum size, nothing happens. + for (let position of [74, 68, 64, 60, 58]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes( + 78, + `Should be at collapse-size at ${position}` + ); + Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`); + } + + // Then the pane collapses. + await testRunner.synthMouse(57); + Assert.equal(collapsedEvents, 1, "collapsed event fired"); + for (let position of [57, 55, 51, 40, 20, 0, -20]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes(0, `Should have no size at ${position}`); + Assert.ok(splitter.isCollapsed, `Should be collapsed at ${position}`); + } + + await testRunner.synthMouse(-20, "mouseup"); + testRunner.assertElementSizes( + 0, + "Should be at min size after releasing mouse" + ); + Assert.ok(splitter.isCollapsed, "Should be collapsed after releasing mouse"); + + // Drag it from the collapsed state. + await testRunner.synthMouse(0, "mousedown"); + for (let position of [0, 8, 16, 19]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes( + 0, + `Should still have no size at ${position}` + ); + Assert.ok(splitter.isCollapsed, `Should still be collapsed at ${position}`); + } + + // Then the pane expands. For the first 20 pixels, nothing happens. + await testRunner.synthMouse(20); + Assert.equal(expandedEvents, 1, "expanded event fired"); + for (let position of [40, 60, 78]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes( + 78, + `Should expand to collapse-size at ${position}` + ); + Assert.ok( + !splitter.isCollapsed, + `Should no longer be collapsed at ${position}` + ); + } + + for (let position of [79, 100, 120, 200, 250, 300, 400, 450]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes( + position, + `Should have ${position} size at ${position}` + ); + Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`); + } + + await testRunner.synthMouse(450, "mouseup"); + testRunner.assertElementSizes( + 450, + "Should be at final size after releasing mouse" + ); + Assert.ok( + !splitter.isCollapsed, + "Should not be collapsed after releasing mouse" + ); + + // Test that collapse and expand can happen in the same drag. + await testRunner.synthMouse(450, "mousedown"); + let position; + let expectedSize; + for ([position, expectedSize] of [ + [58, 78], + [57, 0], + [58, 78], + [57, 0], + ]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes( + expectedSize, + `Should have ${expectedSize} size at ${position}` + ); + } + Assert.equal(collapsedEvents, 3, "collapsed events fired"); + Assert.equal(expandedEvents, 2, "expanded events fired"); + await testRunner.synthMouse(position, "mouseup"); + + // Test that expansion from collapsed reverts to normal behaviour after + // dragging out to the minimum size. + await testRunner.synthMouse(0, "mousedown"); + for ([position, expectedSize] of [ + [0, 0], + [10, 0], + [20, 78], + [40, 78], + [60, 78], + [40, 0], + [60, 78], + [40, 0], + [80, 80], + [100, 100], + ]) { + await testRunner.synthMouse(position); + testRunner.assertElementSizes( + expectedSize, + `Should have ${expectedSize} size at ${position}` + ); + } + Assert.equal(collapsedEvents, 5, "collapsed events fired"); + Assert.equal(expandedEvents, 5, "expanded events fired"); + await testRunner.synthMouse(position, "mouseup"); + + // Restore the original position. + await testRunner.synthMouse(position, "mousedown"); + position = originalPosition; + await testRunner.synthMouse(position); + await testRunner.synthMouse(position, "mouseup"); + testRunner.assertElementSizes(originalPosition); + + testRunner.clearCollapseSize(); +} + +async function subtestCollapseExpand() { + info("subtestCollapseExpand"); + collapsedEvents = 0; + expandedEvents = 0; + + let { splitter } = testRunner; + + let originalSize = testRunner.getSize(testRunner.resized); + + // Collapse. + Assert.ok(!splitter.isCollapsed, "splitter is not collapsed"); + Assert.equal(collapsedEvents, 0, "no collapsed events have fired"); + + splitter.collapse(); + testRunner.assertElementSizes(0); + Assert.ok(splitter.isCollapsed, "splitter is collapsed"); + Assert.equal(collapsedEvents, 1, "a collapsed event fired"); + + splitter.collapse(); + Assert.ok(splitter.isCollapsed, "splitter is collapsed"); + Assert.equal(collapsedEvents, 1, "no more collapsed events have fired"); + + // Expand. + splitter.expand(); + testRunner.assertElementSizes(originalSize); + Assert.ok(!splitter.isCollapsed, "splitter is not collapsed"); + Assert.equal(expandedEvents, 1, "an expanded event fired"); + + splitter.expand(); + Assert.ok(!splitter.isCollapsed, "splitter is not collapsed"); + Assert.equal(expandedEvents, 1, "no more expanded events have fired"); + + collapsedEvents = 0; + expandedEvents = 0; + + // Collapse again. Then drag to expand. + splitter.collapse(); + Assert.equal(collapsedEvents, 1, "a collapsed event fired"); + + testRunner.setCollapseSize(78); + + await testRunner.synthMouse(0, "mousedown"); + await testRunner.synthMouse(200); + await testRunner.synthMouse(200, "mouseup"); + testRunner.assertElementSizes(200); + Assert.ok(!splitter.isCollapsed, "splitter is not collapsed"); + Assert.equal(expandedEvents, 1, "an expanded event fired"); + + testRunner.clearCollapseSize(); +} diff --git a/comm/mail/base/test/browser/browser_preferDisplayName.js b/comm/mail/base/test/browser/browser_preferDisplayName.js new file mode 100644 index 0000000000..d041b3bd37 --- /dev/null +++ b/comm/mail/base/test/browser/browser_preferDisplayName.js @@ -0,0 +1,456 @@ +/* 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/. */ + +const { AddrBookCard } = ChromeUtils.import( + "resource:///modules/AddrBookCard.jsm" +); +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let book, emily, felix, testFolder; + +add_setup(async function () { + book = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite"); + + emily = new AddrBookCard(); + emily.displayName = "This is Emily!"; + emily.primaryEmail = "emily@ekberg.invalid"; + book.addCard(emily); + + felix = new AddrBookCard(); + felix.displayName = "Felix's Flower Co."; + felix.primaryEmail = "felix@flowers.invalid"; + felix.setPropertyAsBool("PreferDisplayName", false); + book.addCard(felix); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + + registerCleanupFunction(async () => { + await ensure_cards_view(); + book.deleteCards(book.childCards); + MailServices.accounts.removeAccount(account, false); + }); + + let rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("preferDisplayName", null); + testFolder = rootFolder + .getChildNamed("preferDisplayName") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + testFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); +}); + +add_task(async function () { + let about3Pane = document.getElementById("tabmail").currentAbout3Pane; + let { threadPane, threadTree, messageBrowser } = about3Pane; + // Not `currentAboutMessage` as that's null right now. + let aboutMessage = messageBrowser.contentWindow; + let messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); + + // Set up the UI. + + about3Pane.restoreState({ + folderURI: testFolder.URI, + messagePaneVisible: true, + }); + threadPane.onColumnsVisibilityChanged({ + value: "senderCol", + target: { hasAttribute: () => true }, + }); + threadPane.onColumnsVisibilityChanged({ + value: "recipientCol", + target: { hasAttribute: () => true }, + }); + + // Switch to classic view and table layout as the test requires this state. + await ensure_table_view(); + + // It's important that we don't cause the thread tree to invalidate the row + // in question, and selecting it would do that, so select it first. + threadTree.selectedIndex = 2; + await BrowserTestUtils.browserLoaded(messagePaneBrowser); + + // Check the initial state of everything. + + let fromLabel = aboutMessage.document.querySelector( + `.header-recipient[data-header-name="from"]` + ); + let fromSingleLine = fromLabel.querySelector(".recipient-single-line"); + let fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name"); + let fromMultiLineAddress = fromLabel.querySelector( + ".recipient-multi-line-address" + ); + + let toLabel = aboutMessage.document.querySelector( + `.header-recipient[data-header-name="to"]` + ); + let toSingleLine = toLabel.querySelector(".recipient-single-line"); + + let row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".correspondentcol-column").textContent, + "This is Emily!", + "initial state of Correspondent column" + ); + Assert.equal( + row.querySelector(".sendercol-column").textContent, + "This is Emily!", + "initial state of Sender column" + ); + Assert.equal( + row.querySelector(".recipientcol-column").textContent, + "Felix Flowers", + "initial state of Recipient column" + ); + Assert.equal( + fromSingleLine.textContent, + "This is Emily!", + "initial state of From single-line label" + ); + Assert.equal( + fromSingleLine.title, + "Emily Ekberg <emily@ekberg.invalid>", + "initial state of From single-line title" + ); + Assert.equal( + fromMultiLineName.textContent, + "This is Emily!", + "initial state of From multi-line name" + ); + Assert.equal( + fromMultiLineAddress.textContent, + "emily@ekberg.invalid", + "initial state of From multi-line address" + ); + Assert.equal( + toSingleLine.textContent, + "Felix Flowers <felix@flowers.invalid>", + "initial state of To single-line label" + ); + Assert.equal(toSingleLine.title, "", "initial state of To single-line title"); + + // Change Emily's display name. + + emily.displayName = "I'm Emily!"; + book.modifyCard(emily); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".correspondentcol-column").textContent, + "I'm Emily!", + "Correspondent column should be the new display name" + ); + Assert.equal( + row.querySelector(".sendercol-column").textContent, + "I'm Emily!", + "Sender column should be the new display name" + ); + Assert.equal( + fromSingleLine.textContent, + "I'm Emily!", + "From single-line label should be the new display name" + ); + Assert.equal( + fromSingleLine.title, + "Emily Ekberg <emily@ekberg.invalid>", + "From single-line title should not change" + ); + Assert.equal( + fromMultiLineName.textContent, + "I'm Emily!", + "From multi-line name should be the new display name" + ); + Assert.equal( + fromMultiLineAddress.textContent, + "emily@ekberg.invalid", + "From multi-line address should not change" + ); + + // Stop preferring Emily's display name. + + emily.setPropertyAsBool("PreferDisplayName", false); + book.modifyCard(emily); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".correspondentcol-column").textContent, + "Emily Ekberg", + "Correspondent column should be the name from the header" + ); + Assert.equal( + row.querySelector(".sendercol-column").textContent, + "Emily Ekberg", + "Sender column should be the name from the header" + ); + Assert.equal( + fromSingleLine.textContent, + "Emily Ekberg <emily@ekberg.invalid>", + "From single-line label should match the header" + ); + Assert.equal( + fromSingleLine.title, + "", + "From single-line title should be cleared" + ); + Assert.equal( + fromMultiLineName.textContent, + "Emily Ekberg", + "From multi-line name should be the name from the header" + ); + Assert.equal( + fromMultiLineAddress.textContent, + "emily@ekberg.invalid", + "From multi-line address should not change" + ); + + // Prefer Emily's display name. + + emily.setPropertyAsBool("PreferDisplayName", true); + book.modifyCard(emily); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".correspondentcol-column").textContent, + "I'm Emily!", + "Correspondent column should be the display name" + ); + Assert.equal( + row.querySelector(".sendercol-column").textContent, + "I'm Emily!", + "Sender column should be the display name" + ); + Assert.equal( + fromSingleLine.textContent, + "I'm Emily!", + "From single-line label should be the display name" + ); + Assert.equal( + fromSingleLine.title, + "Emily Ekberg <emily@ekberg.invalid>", + "From single-line title should match the header" + ); + Assert.equal( + fromMultiLineName.textContent, + "I'm Emily!", + "From multi-line name should be the display name" + ); + Assert.equal( + fromMultiLineAddress.textContent, + "emily@ekberg.invalid", + "From multi-line address should not change" + ); + + // Prefer Felix's display name. + + felix.setPropertyAsBool("PreferDisplayName", true); + book.modifyCard(felix); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".recipientcol-column").textContent, + "Felix's Flower Co.", + "Recipient column should be the display name" + ); + Assert.equal( + toSingleLine.textContent, + "Felix's Flower Co.", + "To single-line label should be the display name" + ); + Assert.equal( + toSingleLine.title, + "Felix Flowers <felix@flowers.invalid>", + "To single-line title should match the header" + ); + + // Stop preferring Felix's display name. + + felix.setPropertyAsBool("PreferDisplayName", false); + book.modifyCard(felix); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".recipientcol-column").textContent, + "Felix Flowers", + "Recipient column should be the name from the header" + ); + Assert.equal( + toSingleLine.textContent, + "Felix Flowers <felix@flowers.invalid>", + "To single-line label should match the header" + ); + Assert.equal( + toSingleLine.title, + "", + "To single-line title should be cleared" + ); + + // Prefer Felix's display name. + + felix.setPropertyAsBool("PreferDisplayName", true); + book.modifyCard(felix); + + // Set global prefer display name preference to false. + + Services.prefs.setBoolPref("mail.showCondensedAddresses", false); + await TestUtils.waitForCondition( + () => !toLabel.parentNode, + "Waiting for the header labels to reload." + ); + fromLabel = aboutMessage.document.querySelector( + `.header-recipient[data-header-name="from"]` + ); + toLabel = aboutMessage.document.querySelector( + `.header-recipient[data-header-name="to"]` + ); + + fromSingleLine = fromLabel.querySelector(".recipient-single-line"); + fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name"); + fromMultiLineAddress = fromLabel.querySelector( + ".recipient-multi-line-address" + ); + toSingleLine = toLabel.querySelector(".recipient-single-line"); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".correspondentcol-column").textContent, + "Emily Ekberg", + "Correspondent column should be the name from the header" + ); + Assert.equal( + row.querySelector(".sendercol-column").textContent, + "Emily Ekberg", + "Sender column should be the name from the header" + ); + Assert.equal( + fromSingleLine.textContent, + "Emily Ekberg <emily@ekberg.invalid>", + "From single-line label should match the header" + ); + Assert.equal( + fromSingleLine.title, + "", + "From single-line title should be cleared" + ); + Assert.equal( + fromMultiLineName.textContent, + "Emily Ekberg <emily@ekberg.invalid>", + "From multi-line name should be the name from the header" + ); + Assert.equal( + fromMultiLineAddress.textContent, + "emily@ekberg.invalid", + "From multi-line address should not change" + ); + Assert.equal( + row.querySelector(".recipientcol-column").textContent, + "Felix Flowers", + "Recipient column should be the name from the header" + ); + Assert.equal( + toSingleLine.textContent, + "Felix Flowers <felix@flowers.invalid>", + "To single-line label should match the header" + ); + Assert.equal( + toSingleLine.title, + "", + "To single-line title should be cleared" + ); + + // Reset prefer display name global preference to true. + + Services.prefs.setBoolPref("mail.showCondensedAddresses", true); + await TestUtils.waitForCondition( + () => !toLabel.parentNode, + "Waiting for the header labels to reload." + ); + fromLabel = aboutMessage.document.querySelector( + `.header-recipient[data-header-name="from"]` + ); + toLabel = aboutMessage.document.querySelector( + `.header-recipient[data-header-name="to"]` + ); + + fromSingleLine = fromLabel.querySelector(".recipient-single-line"); + fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name"); + fromMultiLineAddress = fromLabel.querySelector( + ".recipient-multi-line-address" + ); + toSingleLine = toLabel.querySelector(".recipient-single-line"); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".correspondentcol-column").textContent, + "I'm Emily!", + "Correspondent column should be the new display name" + ); + Assert.equal( + row.querySelector(".sendercol-column").textContent, + "I'm Emily!", + "Sender column should be the new display name" + ); + Assert.equal( + fromSingleLine.textContent, + "I'm Emily!", + "From single-line label should be the new display name" + ); + Assert.equal( + fromSingleLine.title, + "Emily Ekberg <emily@ekberg.invalid>", + "From single-line title should not change" + ); + Assert.equal( + fromMultiLineName.textContent, + "I'm Emily!", + "From multi-line name should be the new display name" + ); + Assert.equal( + fromMultiLineAddress.textContent, + "emily@ekberg.invalid", + "From multi-line address should not change" + ); + Assert.equal( + row.querySelector(".recipientcol-column").textContent, + "Felix's Flower Co.", + "Recipient column should be the display name" + ); + Assert.equal( + toSingleLine.textContent, + "Felix's Flower Co.", + "To single-line label should be the display name" + ); + Assert.equal( + toSingleLine.title, + "Felix Flowers <felix@flowers.invalid>", + "To single-line title should match the header" + ); + + // Restore the default for Felix. + + felix.deleteProperty("PreferDisplayName"); + book.modifyCard(felix); + + row = about3Pane.threadTree.getRowAtIndex(2); + Assert.equal( + row.querySelector(".recipientcol-column").textContent, + "Felix's Flower Co.", + "Recipient column should be the display name" + ); + Assert.equal( + toSingleLine.textContent, + "Felix's Flower Co.", + "To single-line label should be the display name" + ); + Assert.equal( + toSingleLine.title, + "Felix Flowers <felix@flowers.invalid>", + "To single-line title should match the header" + ); +}); diff --git a/comm/mail/base/test/browser/browser_searchMessages.js b/comm/mail/base/test/browser/browser_searchMessages.js new file mode 100644 index 0000000000..4207b0019c --- /dev/null +++ b/comm/mail/base/test/browser/browser_searchMessages.js @@ -0,0 +1,460 @@ +/* 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/. */ + +const { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +const { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/PromiseTestUtils.jsm" +); + +const tabmail = document.getElementById("tabmail"); +let rootFolder, testFolder, otherFolder; + +add_setup(async function () { + const generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + const account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder); + testFolder = rootFolder.createLocalSubfolder("searchMessagesFolder"); + testFolder.QueryInterface(Ci.nsIMsgLocalMailFolder); + const messageStrings = generator + .makeMessages({ count: 20 }) + .map(message => message.toMboxString()); + testFolder.addMessageBatch(messageStrings); + otherFolder = rootFolder.createLocalSubfolder("searchMessagesOtherFolder"); + + tabmail.currentAbout3Pane.paneLayout.messagePaneVisible = true; + Services.xulStore.removeDocument( + "chrome://messenger/content/SearchDialog.xhtml" + ); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function () { + const windowOpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + null, + w => + w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml" + ); + goDoCommand("cmd_searchMessages"); + const win = await windowOpenedPromise; + const doc = win.document; + + await SimpleTest.promiseFocus(win); + + const searchButton = doc.getElementById("search-button"); + const clearButton = doc.querySelector( + "#searchTerms > vbox > hbox:nth-child(2) > button" + ); + const searchTermList = doc.getElementById("searchTermList"); + const threadTree = doc.getElementById("threadTree"); + const columns = threadTree.columns; + const picker = threadTree.querySelector("treecolpicker"); + const popup = picker.querySelector("menupopup"); + const openButton = doc.getElementById("openButton"); + const deleteButton = doc.getElementById("deleteButton"); + const fileMessageButton = doc.getElementById("fileMessageButton"); + const fileMessagePopup = fileMessageButton.querySelector("menupopup"); + const openInFolderButton = doc.getElementById("openInFolderButton"); + const saveAsVFButton = doc.getElementById("saveAsVFButton"); + const statusText = doc.getElementById("statusText"); + + const treeClick = mailTestUtils.treeClick.bind( + null, + EventUtils, + win, + threadTree + ); + + // Test search criteria. The search results are deterministic unless + // MessageGenerator is changed. + + await TestUtils.waitForCondition( + () => searchTermList.itemCount == 1, + "waiting for a search term to exist" + ); + const searchTerm0 = searchTermList.getItemAtIndex(0); + const input0 = searchTerm0.querySelector("search-value input"); + const button0 = searchTerm0.querySelector("button.small-button:first-child"); + + // Row 0 will look for subjects including "hovercraft". + Assert.equal(input0.value, ""); + input0.focus(); + EventUtils.sendString("hovercraft", win); + + // Add another row. + EventUtils.synthesizeMouseAtCenter(button0, {}, win); + await TestUtils.waitForCondition( + () => searchTermList.itemCount == 2, + "waiting for a second search term to exist" + ); + + const searchTerm1 = searchTermList.getItemAtIndex(1); + const menulist = searchTerm1.querySelector("search-attribute menulist"); + const menuitem = menulist.querySelector(`menuitem[value="1"]`); + const input1 = searchTerm1.querySelector("search-value input"); + + // Change row 1's search attribute. + EventUtils.synthesizeMouseAtCenter(menulist, {}, win); + await BrowserTestUtils.waitForPopupEvent(menulist, "shown"); + menulist.menupopup.activateItem(menuitem); + await BrowserTestUtils.waitForPopupEvent(menulist, "hidden"); + + // Row 1 will look for the sender Emily Ekberg. + Assert.equal(input1.value, ""); + EventUtils.synthesizeMouseAtCenter(input1, {}, win); + EventUtils.sendString("emily@ekberg.invalid", win); + + // Search. Emily didn't send a message about hovercraft, so no results. + EventUtils.synthesizeMouseAtCenter(searchButton, {}, win); + // Allows 5 seconds for expected statusText to appear. + await TestUtils.waitForCondition( + () => statusText.value == "No matches found", + "waiting for status text to update" + ); + + // Change the search from AND to OR. + EventUtils.synthesizeMouseAtCenter( + doc.querySelector(`#booleanAndGroup > radio[value="or"]`), + {}, + win + ); + // Change the subject search to something more common. + input0.select(); + EventUtils.sendString("in", win); + + // Search. 10 messages should be found. + EventUtils.synthesizeMouseAtCenter(searchButton, {}, win); + await TestUtils.waitForCondition( + () => threadTree.view.rowCount == 10, + "waiting for tree view to be filled" + ); + // statusText changes on 500 ms time base. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + await TestUtils.waitForCondition( + () => statusText.value == "10 matches found", + "waiting for status text to update" + ); + + // Test tree sort column and direction. + + EventUtils.synthesizeMouseAtCenter(columns.subjectCol.element, {}, win); + Assert.equal( + columns.subjectCol.element.getAttribute("sortDirection"), + "ascending" + ); + EventUtils.synthesizeMouseAtCenter(columns.dateCol.element, {}, win); + Assert.equal( + columns.dateCol.element.getAttribute("sortDirection"), + "ascending" + ); + EventUtils.synthesizeMouseAtCenter(columns.dateCol.element, {}, win); + Assert.equal( + columns.dateCol.element.getAttribute("sortDirection"), + "descending" + ); + + // Test tree column visibility and order. + + checkTreeColumnsInOrder(threadTree, [ + "flaggedCol", + "attachmentCol", + "subjectCol", + "unreadButtonColHeader", + "correspondentCol", + "junkStatusCol", + "dateCol", + "locationCol", + ]); + EventUtils.synthesizeMouseAtCenter(picker, {}, win); + await BrowserTestUtils.waitForPopupEvent(popup, "shown"); + popup.activateItem( + popup.querySelector(`[colindex="${columns.selectCol.index}"]`) + ); + popup.activateItem( + popup.querySelector(`[colindex="${columns.deleteCol.index}"]`) + ); + popup.hidePopup(); + await BrowserTestUtils.waitForPopupEvent(popup, "hidden"); + // Wait for macOS to catch up. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + checkTreeColumnsInOrder(threadTree, [ + "selectCol", + "flaggedCol", + "attachmentCol", + "subjectCol", + "unreadButtonColHeader", + "correspondentCol", + "junkStatusCol", + "dateCol", + "locationCol", + "deleteCol", + ]); + + threadTree._reorderColumn( + columns.deleteCol.element, + columns.selectCol.element, + false + ); + threadTree.invalidate(); + // Wait for macOS to catch up. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + checkTreeColumnsInOrder(threadTree, [ + "selectCol", + "deleteCol", + "flaggedCol", + "attachmentCol", + "subjectCol", + "unreadButtonColHeader", + "correspondentCol", + "junkStatusCol", + "dateCol", + "locationCol", + ]); + + // Test message selection with the select column. + + treeClick(0, "subjectCol", {}); + await TestUtils.waitForCondition( + () => threadTree.view.selection.count == 1, + "waiting for first message to be selected" + ); + Assert.ok(!openButton.disabled); + Assert.ok(!deleteButton.disabled); + Assert.ok(!fileMessageButton.disabled); + Assert.ok(!openInFolderButton.disabled); + treeClick(1, "selectCol", {}); + await TestUtils.waitForCondition( + () => threadTree.view.selection.count == 2, + "waiting for second message to be selected" + ); + Assert.ok(!openButton.disabled); + Assert.ok(!deleteButton.disabled); + Assert.ok(!fileMessageButton.disabled); + Assert.ok(openInFolderButton.disabled); + treeClick(1, "selectCol", {}); + await TestUtils.waitForCondition( + () => threadTree.view.selection.count == 1, + "waiting for second message to be unselected" + ); + Assert.ok(!openButton.disabled); + Assert.ok(!deleteButton.disabled); + Assert.ok(!fileMessageButton.disabled); + Assert.ok(!openInFolderButton.disabled); + treeClick(0, "selectCol", {}); + await TestUtils.waitForCondition( + () => threadTree.view.selection.count == 0, + "waiting for first message to be selected" + ); + Assert.ok(openButton.disabled); + Assert.ok(deleteButton.disabled); + Assert.ok(fileMessageButton.disabled); + Assert.ok(openInFolderButton.disabled); + + // Opening messages. + + // Test opening a message with the "Open" button. + treeClick(0, "subjectCol", {}); + let tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen"); + EventUtils.synthesizeMouseAtCenter(openButton, {}, win); + const { + detail: { tabInfo: tab1 }, + } = await tabOpenPromise; + await BrowserTestUtils.waitForEvent(tab1.chromeBrowser, "MsgLoaded"); + Assert.equal(tab1.mode.name, "mailMessageTab"); + + await SimpleTest.promiseFocus(win); + + // Test opening a message with a double click. + tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen"); + treeClick(0, "subjectCol", { clickCount: 2 }); + const { + detail: { tabInfo: tab2 }, + } = await tabOpenPromise; + await BrowserTestUtils.waitForEvent(tab2.chromeBrowser, "MsgLoaded"); + Assert.equal(tab2.mode.name, "mailMessageTab"); + + await SimpleTest.promiseFocus(win); + + // Test opening a message with the keyboard. + tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen"); + threadTree.focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + const { + detail: { tabInfo: tab3 }, + } = await tabOpenPromise; + await BrowserTestUtils.waitForEvent(tab3.chromeBrowser, "MsgLoaded"); + Assert.equal(tab3.mode.name, "mailMessageTab"); + + await SimpleTest.promiseFocus(win); + + // Test opening a message with the "Open in Folder" button. + const tabSelectPromise = BrowserTestUtils.waitForEvent(window, "TabSelect"); + EventUtils.synthesizeMouseAtCenter(openInFolderButton, {}, win); + const { + detail: { tabInfo: tab0 }, + } = await tabSelectPromise; + await BrowserTestUtils.waitForEvent(tab0.chromeBrowser, "MsgLoaded"); + Assert.equal(tab0, tabmail.tabInfo[0]); + + tabmail.closeOtherTabs(tab0); + + await SimpleTest.promiseFocus(win); + + // Deleting messages. + + // Test deleting a message with the delete column. + let deletePromise = PromiseTestUtils.promiseFolderEvent( + testFolder, + "DeleteOrMoveMsgCompleted" + ); + treeClick(0, "deleteCol", {}); + await deletePromise; + await TestUtils.waitForCondition( + () => threadTree.view.rowCount == 9, + "waiting for row to be removed from tree view" + ); + + // Test deleting a message with the "Delete" button. + deletePromise = PromiseTestUtils.promiseFolderEvent( + testFolder, + "DeleteOrMoveMsgCompleted" + ); + EventUtils.synthesizeMouseAtCenter(deleteButton, {}, win); + await deletePromise; + await TestUtils.waitForCondition( + () => threadTree.view.rowCount == 8, + "waiting for row to be removed from tree view" + ); + + // Test deleting a message with the keyboard. + treeClick(0, "subjectCol", {}); + deletePromise = PromiseTestUtils.promiseFolderEvent( + testFolder, + "DeleteOrMoveMsgCompleted" + ); + EventUtils.synthesizeKey("VK_DELETE", { shiftKey: true }, win); + await deletePromise; + await TestUtils.waitForCondition( + () => threadTree.view.rowCount == 7, + "waiting for row to be removed from tree view" + ); + + // Moving messages. + + // Test moving a message to another folder with the "Move To" button. + treeClick(0, "subjectCol", {}); + const movePromise = PromiseTestUtils.promiseFolderEvent( + testFolder, + "DeleteOrMoveMsgCompleted" + ); + + EventUtils.synthesizeMouseAtCenter(fileMessageButton, {}, win); + await BrowserTestUtils.waitForPopupEvent(fileMessagePopup, "shown"); + const rootFolderMenu = [...fileMessagePopup.children].find( + i => i._folder == rootFolder + ); + rootFolderMenu.openMenu(true); + await BrowserTestUtils.waitForPopupEvent(rootFolderMenu.menupopup, "shown"); + const otherFolderItem = [...rootFolderMenu.menupopup.children].find( + i => i._folder == otherFolder + ); + rootFolderMenu.menupopup.activateItem(otherFolderItem); + await BrowserTestUtils.waitForPopupEvent(fileMessagePopup, "hidden"); + + await movePromise; + await TestUtils.waitForCondition( + () => threadTree.view.rowCount == 6, + "waiting for row to be removed from tree view" + ); + + // TODO: Test dragging a message to another folder. + + // Test the "Save as Search Folder" button. + + const virtualFolderDialogPromise = BrowserTestUtils.promiseAlertDialogOpen( + undefined, + "chrome://messenger/content/virtualFolderProperties.xhtml", + { + async callback(vfWin) { + await SimpleTest.promiseFocus(vfWin); + await BrowserTestUtils.closeWindow(vfWin); + }, + } + ); + EventUtils.synthesizeMouseAtCenter(saveAsVFButton, {}, win); + await virtualFolderDialogPromise; + + await SimpleTest.promiseFocus(win); + + // Test clearing the search. + + EventUtils.synthesizeMouseAtCenter(clearButton, {}, win); + await TestUtils.waitForCondition( + () => searchTermList.itemCount == 1, + "waiting for search term list to be cleared" + ); + await TestUtils.waitForCondition( + () => threadTree.view.rowCount == 0, + "waiting for tree view to be cleared" + ); + + const newSearchTerm0 = searchTermList.getItemAtIndex(0); + Assert.notEqual(newSearchTerm0, searchTerm0); + const newInput0 = newSearchTerm0.querySelector("search-value input"); + Assert.equal(newInput0.value, ""); + + await BrowserTestUtils.closeWindow(win); + + // Open the window again, and check the tree columns are as we left them. + + const window2OpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + null, + w => + w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml" + ); + goDoCommand("cmd_searchMessages"); + const win2 = await window2OpenedPromise; + const doc2 = win.document; + await SimpleTest.promiseFocus(win2); + + const threadTree2 = doc2.getElementById("threadTree"); + + checkTreeColumnsInOrder(threadTree2, [ + "selectCol", + "deleteCol", + "flaggedCol", + "attachmentCol", + "subjectCol", + "unreadButtonColHeader", + "correspondentCol", + "junkStatusCol", + "dateCol", + "locationCol", + ]); + + await BrowserTestUtils.closeWindow(win2); +}); + +function checkTreeColumnsInOrder(tree, expectedOrder) { + Assert.deepEqual( + Array.from(tree.querySelectorAll("treecol:not([hidden])")) + .sort((a, b) => a.ordinal - b.ordinal) + .map(c => c.id), + expectedOrder + ); +} diff --git a/comm/mail/base/test/browser/browser_selectionWidgetController.js b/comm/mail/base/test/browser/browser_selectionWidgetController.js new file mode 100644 index 0000000000..8e57c64bdc --- /dev/null +++ b/comm/mail/base/test/browser/browser_selectionWidgetController.js @@ -0,0 +1,6196 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var tabInfo; +var win; + +add_setup(async () => { + tabInfo = window.openContentTab( + "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/selectionWidget.xhtml" + ); + await BrowserTestUtils.browserLoaded(tabInfo.browser); + + tabInfo.browser.focus(); + win = tabInfo.browser.contentWindow; +}); + +registerCleanupFunction(() => { + window.tabmail.closeTab(tabInfo); +}); + +var selectionModels = ["focus", "browse", "browse-multi"]; + +/** + * The selection widget. + * + * @type {HTMLElement} + */ +var widget; +/** + * A focusable item before the widget. + * + * @type {HTMLElement} + */ +var before; +/** + * A focusable item after the widget. + * + * @type {HTMLElement} + */ +var after; + +/** + * Reset the page and create a new widget. + * + * The "widget", "before" and "after" variables will be reset to the new + * elements. + * + * @param {object} options - Options to set. + * @param {string} options.model - The selection model to use. + * @param {string} [options.direction="right-to-left"] - The direction of the + * widget. Choosing "top-to-bottom" will layout items from top to bottom. + * Choosing "right-to-left" or "left-to-right" will set the page's direction + * to "rtl" or "ltr", respectively, and will layout items in the writing + * direction. + * @param {boolean} [options.draggable=false] - Whether to make the items + * draggable. + */ +function reset(options) { + function createTabStop(text) { + let el = win.document.createElement("span"); + el.tabIndex = 0; + el.id = text; + el.textContent = text; + return el; + } + before = createTabStop("before"); + after = createTabStop("after"); + + let { model, direction } = options; + if (!direction) { + // Default to a less-common format. + direction = "right-to-left"; + } + info(`Creating ${direction} widget with "${model}" model`); + + widget = win.document.createElement("test-selection-widget"); + widget.id = "widget"; + widget.setAttribute("selection-model", model); + widget.setAttribute( + "layout-direction", + direction == "top-to-bottom" ? "vertical" : "horizontal" + ); + widget.toggleAttribute("items-draggable", options.draggable); + + win.document.body.replaceChildren(before, widget, after); + + win.document.dir = direction == "left-to-right" ? "ltr" : "rtl"; + + before.focus(); +} + +/** + * Create an array of sequential integers. + * + * @param {number} start - The starting integer. + * @param {number} num - The number of integers. + * + * @returns {number[]} - Array of integers between start and (start + num - 1). + */ +function range(start, num) { + return Array.from({ length: num }, (_, i) => start + i); +} + +/** + * Assert that the specified items are selected in the widget, and nothing else. + * + * @param {number[]} indices - The indices of the selected items. + * @param {string} msg - A message to use for the assertion. + */ +function assertSelection(indices, msg) { + let selected = widget.selectedIndices(); + Assert.deepEqual(selected, indices, `Selected indices should match: ${msg}`); + // Test that the return of getSelectionRanges is as expected. + let expectRanges = []; + let lastIndex = -2; + let rangeIndex = -1; + for (let index of indices) { + if (index == lastIndex + 1) { + expectRanges[rangeIndex].end++; + } else { + rangeIndex++; + expectRanges.push({ start: index, end: index + 1 }); + } + lastIndex = index; + } + Assert.deepEqual( + widget.getSelectionRanges(), + expectRanges, + `Selection ranges should match expected: ${msg}` + ); +} + +/** + * Assert that the given element is focused. + * + * @param {object} expect - The expected focused element. + * @param {HTMLElement} [expect.element] - The expected element that will + * have focus. + * @param {number} [expect.index] - If the `element` property is not given, this + * specifies the index of the item widget we expect to have focus. + * @param {string} [expect.text] - Optionally test that the element also has the + * given text content. + * @param {string} msg - A message to use for the assertion. + */ +function assertFocus(expect, msg) { + let expectElement; + let name; + if (expect.element != undefined) { + expectElement = expect.element; + name = `Element #${expectElement.id}`; + } else { + expectElement = widget.items[expect.index].element; + name = `Item ${expect.index}`; + } + let active = win.document.activeElement; + let activeIndex = widget.items.findIndex(i => i.element == active); + if (activeIndex >= 0) { + active = `"${active.textContent}", index: ${activeIndex}`; + } else if (active.id) { + active = `#${active.id}`; + } else { + active = `<${active.localName}>`; + } + Assert.ok( + expectElement.matches(":focus"), + `${name} should have focus (active: ${active}): ${msg}` + ); +} + +/** + * Shift the focus by one step by pressing Tab and assert the new focused + * element. + * + * @param {boolean} forward - Whether to move the focus forward. + * @param {object} expect - The expected focused element after pressing tab. + * Same as passed to {@link assertFocus}. + * @param {string} msg - A message to use for the assertion. + */ +function stepFocus(forward, expect, msg) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: !forward }, win); + assertFocus( + expect, + `After moving ${forward ? "forward" : "backward"}: ${msg}` + ); +} + +/** + * @typedef {object} ItemState + * @property {string} text - The text content of the item. + * @property {boolean} [selected=false] - Whether the item is selected. + * @property {boolean} [focused=false] - Whether the item is focused. + */ + +/** + * Assert the text order, selection state and focus of the widget items. + * + * @param {ItemState[]} expected - The expected state of the widget items, in + * the expected order of the items. + * @param {string} msg - A message to use for the assertion. + */ +function assertState(expected, msg) { + let textOrder = []; + let focusIndex; + let selectedIndices = []; + for (let [index, state] of expected.entries()) { + textOrder.push(state.text); + if (state.selected) { + selectedIndices.push(index); + } + if (state.focused) { + if (focusIndex != undefined) { + throw new Error("More than one item specified as having focus"); + } + focusIndex = index; + } + } + Assert.deepEqual( + Array.from(widget.items, i => i.element.textContent), + textOrder, + `Text order should match: ${msg}` + ); + assertSelection(selectedIndices, msg); + if (focusIndex != undefined) { + assertFocus({ index: focusIndex }, msg); + } else { + Assert.ok( + !widget.querySelector(":focus"), + `Widget should not contain any focus: ${msg}` + ); + } +} + +/** + * Click the empty space of the widget. + * + * @param {object} mouseEvent - Properties for the click event. + */ +function clickWidgetEmptySpace(mouseEvent) { + let widgetRect = widget.getBoundingClientRect(); + if (widget.getAttribute("layout-direction") == "vertical") { + // Try click end, which we assume is empty. + EventUtils.synthesizeMouse( + widget, + widgetRect.width / 2, + widgetRect.height - 5, + mouseEvent, + win + ); + } else if (widget.matches(":dir(rtl)")) { + // Try click the left, which we assume is empty. + EventUtils.synthesizeMouse( + widget, + 5, + widgetRect.height / 2, + mouseEvent, + win + ); + } else { + // Try click the right, which we assume is empty. + EventUtils.synthesizeMouse( + widget, + widgetRect.width - 5, + widgetRect.height / 2, + mouseEvent, + win + ); + } +} + +/** + * Click the specified widget item. + * + * @param {number} index - The index of the item to click. + * @param {object} mouseEvent - Properties for the click event. + */ +function clickWidgetItem(index, mouseEvent) { + EventUtils.synthesizeMouseAtCenter( + widget.items[index].element, + mouseEvent, + win + ); +} + +/** + * Trigger the select-all shortcut. + */ +function selectAllShortcut() { + EventUtils.synthesizeKey( + "a", + AppConstants.platform == "macosx" ? { metaKey: true } : { ctrlKey: true }, + win + ); +} + +// If the widget is empty, it receives focus on itself. +add_task(function test_empty_widget_focus() { + for (let model of selectionModels) { + reset({ model }); + + assertFocus({ element: before }, "Initial"); + + // Move focus forward. + stepFocus(true, { element: widget }, "Move into widget"); + stepFocus(true, { element: after }, "Move out of widget"); + + // Move focus backward. + stepFocus(false, { element: widget }, "Move back to widget"); + stepFocus(false, { element: before }, "Move back out of widget"); + + // Clicking also gives focus. + for (let shiftKey of [false, true]) { + for (let ctrlKey of [false, true]) { + info( + `Clicking empty widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}` + ); + clickWidgetEmptySpace({ shiftKey, ctrlKey }); + assertFocus({ element: widget }, "Widget receives focus after click"); + // Move focus for the next loop. + stepFocus(true, { element: after }, "Move back out"); + } + } + } +}); + +/** + * Test that the initial focus is as expected. + * + * @param {string} model - The selection model to use. + * @param {Function} setup - A callback to set up the widget. + * @param {number} clickIndex - The index of an item to click. + * @param {object} expect - The expected states. + * @param {number} expect.focusIndex - The expected focus index. + * @param {number[]} expect.selection - The expected initial selection. + * @param {boolean} expect.selectFocus - Whether we expect the focused item to + * become selected. + */ +function subtest_initial_focus(model, setup, expect) { + let { focusIndex: index, selection, selectFocus } = expect; + + reset({ model }); + setup(); + + assertFocus({ element: before }, "Forward start"); + assertSelection(selection, "Initial selection"); + + stepFocus(true, { index }, "Move onto selected item"); + if (selectFocus) { + assertSelection([index], "Focus becomes selected"); + } else { + assertSelection(selection, "Selection remains when focussing"); + } + stepFocus(true, { element: after }, "Move out of widget"); + + // Reverse. + reset({ model }); + after.focus(); + setup(); + + assertFocus({ element: after }, "Reverse start"); + assertSelection(selection, "Reverse start"); + + stepFocus(false, { index }, "Move backward to selected item"); + if (selectFocus) { + assertSelection([index], "Focus becomes selected"); + } else { + assertSelection(selection, "Selection remains when focussing"); + } + stepFocus(false, { element: before }, "Move out of widget"); + + // With mouse click. + for (let shiftKey of [false, true]) { + for (let ctrlKey of [false, true]) { + info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`); + + reset({ model }); + setup(); + + assertFocus({ element: before }, "Click empty start"); + assertSelection(selection, "Click empty start"); + clickWidgetEmptySpace({ ctrlKey, shiftKey }); + assertFocus( + { index }, + "Selected item becomes focused with click on empty" + ); + if (selectFocus) { + assertSelection([index], "Focus becomes selected on click on empty"); + } else { + assertSelection(selection, "Selection remains when click on empty"); + } + + // With mouse click on item focus moves to the clicked item instead. + for (let clickIndex of [ + (index || widget.items.length) - 1, + index, + index + 1, + ]) { + reset({ model }); + setup(); + + assertFocus({ element: before }, "Click first item start"); + assertSelection(selection, "Click first item start"); + + clickWidgetItem(clickIndex, { shiftKey, ctrlKey }); + + if ( + (shiftKey && ctrlKey) || + ((shiftKey || ctrlKey) && (model == "focus" || model == "browse")) + ) { + // Both modifiers, or multi-selection not supported, so acts the + // same as clicking empty. + assertFocus( + { index }, + "Selected item becomes focused with click on item" + ); + if (selectFocus) { + assertSelection([index], "Focus becomes selected on click on item"); + } else { + assertSelection(selection, "Selection remains when click on item"); + } + } else { + assertFocus( + { index: clickIndex }, + "Clicked item becomes focused with click on item" + ); + let clickSelection; + if (ctrlKey) { + if (selection.includes(clickIndex)) { + // Toggle off clicked item. + clickSelection = selection.filter(index => index != clickIndex); + } else { + clickSelection = selection.concat([clickIndex]).sort(); + } + } else if (shiftKey) { + // Range selection is always from 0, regardless of the selection + // before the click. + clickSelection = range(0, clickIndex + 1); + } else { + clickSelection = [clickIndex]; + } + assertSelection(clickSelection, "Selection after click on item"); + } + } + } + } +} + +// If the widget has a selection when we move into it, the selected item is +// focused. +add_task(function test_initial_focus() { + for (let model of selectionModels) { + // With no initial selection. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + }, + { focusIndex: 0, selection: [], selectFocus: true } + ); + // With call to selectSingleItem + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.selectSingleItem(2); + }, + { focusIndex: 2, selection: [2], selectFocus: false } + ); + + // Using the setItemSelected API + if (model == "focus" || model == "browse") { + continue; + } + + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.setItemSelected(2, true); + }, + { focusIndex: 2, selection: [2], selectFocus: false } + ); + + // With multiple selected, we move focus to the first selected. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.setItemSelected(2, true); + widget.setItemSelected(1, true); + }, + { focusIndex: 1, selection: [1, 2], selectFocus: false } + ); + + // If we use both methods. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.selectSingleItem(2, true); + widget.setItemSelected(1, true); + }, + { focusIndex: 1, selection: [1, 2], selectFocus: false } + ); + + // If we call selectSingleItem and then unselect it, we act same as the + // default case. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.selectSingleItem(2, true); + widget.setItemSelected(2, false); + }, + { focusIndex: 0, selection: [], selectFocus: true } + ); + } +}); + +// If selectSingleItem API method is called, we select an item and make it the +// focus. +add_task(function test_select_single_item_method() { + function subTestSelectSingleItem(outside, index) { + if (outside) { + stepFocus(true, { element: after }, "Moving focus to outside widget"); + } + + widget.selectSingleItem(index); + assertSelection([index], "Item becomes selected after call"); + + if (outside) { + assertFocus({ element: after }, "Focus remains outside the widget"); + // Return. + stepFocus(false, { index }, "Focus moves to selected item on return"); + assertSelection([index], "Item remains selected on return"); + } else { + assertFocus({ index }, "Focus force moved to selected item"); + } + } + + for (let model of selectionModels) { + reset({ model }); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + + stepFocus(true, { index: 0 }, "Move onto first item"); + + for (let outside of [false, true]) { + info(`Testing selecting item${outside ? " with focus outside" : ""}`); + + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "Focus initially on first item"); + assertSelection([0], "Initial selection on first item"); + + subTestSelectSingleItem(outside, 1); + // Selecting again. + subTestSelectSingleItem(outside, 1); + + if (model == "focus") { + continue; + } + + // Split focus from selection + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1], "Second item remains selected"); + + // Select focused item. + subTestSelectSingleItem(outside, 2); + + // Split again. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([2], "Third item remains selected"); + + // Selecting selected item will still move focus. + subTestSelectSingleItem(outside, 2); + + // Split again. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "First item has focus"); + assertSelection([2], "Third item remains selected"); + + // Select neither focused nor selected. + subTestSelectSingleItem(outside, 1); + } + + // With mouse click to focus. + for (let shiftKey of [false, true]) { + for (let ctrlKey of [false, true]) { + info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`); + + reset({ model }); + widget.addItems(0, ["First", "Second", "Third"]); + stepFocus(true, { index: 0 }, "Move onto first item"); + assertSelection([0], "First item becomes selected"); + + // Move focus outside widget. + stepFocus(true, { element: after }, "Move focus outside"); + + // Select an item. + widget.selectSingleItem(1); + + // Click empty space will focus the selected item. + clickWidgetEmptySpace({}); + assertFocus( + { index: 1 }, + "Selected item becomes focused with click on empty" + ); + assertSelection( + [1], + "Second item remains selected with click on empty" + ); + + // With mouse click on selected item. + stepFocus(false, { element: before }, "Move focus outside"); + widget.selectSingleItem(2); + + clickWidgetItem(2, { shiftKey, ctrlKey }); + assertFocus( + { index: 2 }, + "Selected item becomes focused with click on selected" + ); + if (ctrlKey && !shiftKey && model == "browse-multi") { + assertSelection( + [], + "Item becomes unselected with Ctrl+click on selected" + ); + } else { + // NOTE: Shift+Click will select from the item to itself. + assertSelection( + [2], + "Selected item remains selected with click on selected" + ); + } + + // With mouse click on non-selected item. + stepFocus(false, { element: before }, "Move focus outside"); + widget.selectSingleItem(1); + + clickWidgetItem(2, { shiftKey, ctrlKey }); + if ( + (shiftKey && ctrlKey) || + ((shiftKey || ctrlKey) && (model == "focus" || model == "browse")) + ) { + // Both modifiers, or multi-selection not supported, so acts the + // same as clicking empty. + assertFocus( + { index: 1 }, + "Selected item becomes focused with click on item" + ); + assertSelection( + [1], + "Selected item remains selected with click on item" + ); + } else { + assertFocus( + { index: 2 }, + "Third item becomes focused with click on item" + ); + if (ctrlKey) { + assertSelection( + [1, 2], + "Third item becomes selected with Ctrl+click" + ); + } else if (shiftKey) { + assertSelection( + [1, 2], + "Second to third item become selected with Shift+click" + ); + } else { + assertSelection( + [2], + "Third item becomes selected with click on item" + ); + } + } + } + } + } +}); + +// If setItemSelected API method is called, we set the selection state of an +// item but do not change anything else. +add_task(function test_set_item_selected_method() { + for (let model of selectionModels) { + reset({ model }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + stepFocus(true, { index: 0 }, "Initial focus on first item"); + assertSelection([0], "Initial selection on first item"); + + if (model == "focus" || model == "browse") { + // This method always throws. + Assert.throws( + () => widget.setItemSelected(2, true), + /Widget does not support multi-selection/ + ); + // Even if it would not change the single selection state. + Assert.throws( + () => widget.setItemSelected(2, false), + /Widget does not support multi-selection/ + ); + Assert.throws( + () => widget.setItemSelected(0, true), + /Widget does not support multi-selection/ + ); + continue; + } + + // Can select. + widget.setItemSelected(2, true); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([0, 2], "Item 2 becomes selected"); + + // And unselect. + widget.setItemSelected(0, false); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([2], "Item 0 is unselected"); + + // Does nothing extra if already selected/unselected. + widget.setItemSelected(2, true); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([2], "Same selected"); + + widget.setItemSelected(0, false); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([2], "Same selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + + // Select the focused item. + assertFocus({ index: 3 }, "Focus on item 3"); + assertSelection([2], "Same selected"); + + widget.setItemSelected(3, true); + assertFocus({ index: 3 }, "Same focus"); + assertSelection([2, 3], "Item 3 selected"); + + widget.setItemSelected(2, false); + assertFocus({ index: 3 }, "Same focus"); + assertSelection([3], "Item 2 unselected"); + + // Can select none this way. + widget.setItemSelected(3, false); + assertFocus({ index: 3 }, "Same focus"); + assertSelection([], "None selected"); + } +}); + +/** + * Test navigation for the given direction. + * + * @param {string} model - The selection model to use. + * @param {string} direction - The layout direction of the widget. + * @param {object} keys - Navigation keys. + * @param {string} keys.forward - The key to move forward. + * @param {string} keys.backward - The key to move backward. + */ +function subtest_keyboard_navigation(model, direction, keys) { + let { forward: forwardKey, backward: backwardKey } = keys; + reset({ model, direction }); + widget.addItems(0, ["First", "Second", "Third"]); + + stepFocus(true, { index: 0 }, "Initially on first item"); + + // Without Ctrl, selection follows focus. + + // Forward. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 1 }, "Forward to second item"); + assertSelection([1], "Second item becomes selected on focus"); + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 2 }, "Forward to third item"); + assertSelection([2], "Third item becomes selected on focus"); + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 2 }, "Forward at end remains on third item"); + assertSelection([2], "Third item remains selected"); + + // Backward. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 1 }, "Backward to second item"); + assertSelection([1], "Second item becomes selected on focus"); + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 0 }, "Backward to first item"); + assertSelection([0], "First item becomes selected on focus"); + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 0 }, "Backward at end remains on first item"); + assertSelection([0], "First item remains selected"); + + // End. + EventUtils.synthesizeKey("KEY_End", {}, win); + assertFocus({ index: 2 }, "Third becomes focused on End"); + assertSelection([2], "Third becomes selected on End"); + // Move to middle. + EventUtils.synthesizeKey(backwardKey, {}, win); + EventUtils.synthesizeKey("KEY_End", {}, win); + assertFocus({ index: 2 }, "Third becomes focused on End from second"); + assertSelection([2], "Third becomes selected on End from second"); + EventUtils.synthesizeKey("KEY_End", {}, win); + assertFocus({ index: 2 }, "Third remains focused on End from third"); + assertSelection([2], "Third becomes selected on End from third"); + + // Home. + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "First becomes focused on Home"); + assertSelection([0], "First becomes selected on Home"); + // Move to middle. + EventUtils.synthesizeKey(forwardKey, {}, win); + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "First becomes focused on Home from second"); + assertSelection([0], "First becomes selected on Home from second"); + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "First remains focused on Home from first"); + assertSelection([0], "First becomes selected on Home from first"); + + // With Ctrl key, selection does not follow focus. + if (model == "focus") { + // Disabled in "focus" model. + // Move to middle item. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 1 }, "Second item is focused"); + assertFocus({ index: 1 }, "Second item is selected"); + + for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) { + for (let shiftKey of [false, true]) { + info( + `Pressing Ctrl+${ + shiftKey ? "Shift+" : "" + }${key} on "focus" model widget` + ); + EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey }, win); + assertFocus({ index: 1 }, "Second item is still focused"); + assertSelection([1], "Second item is still selected"); + } + } + } else { + EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Ctrl+Forward to second item"); + assertSelection([0], "First item remains selected on Ctrl+Forward"); + + EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Ctrl+Forward to third item"); + assertSelection([0], "First item remains selected on Ctrl+Forward"); + + EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Ctrl+Backward to second item"); + assertSelection([0], "First item remains selected on Ctrl+Backward"); + + EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Ctrl+Backward to first item"); + assertSelection([0], "First item remains selected on Ctrl+Backward"); + + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Ctrl+End to third item"); + assertSelection([0], "First item remains selected on Ctrl+End"); + + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 1 }, "Backward to second item"); + assertSelection([1], "Selection moves with focus when not pressing Ctrl"); + + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Ctrl+Home to first item"); + assertSelection([1], "Second item remains selected on Ctrl+Home"); + + // Does nothing if combined with Shift. + for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) { + info(`Pressing Ctrl+Shift+${key} on "${model}" model widget`); + EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey: true }, win); + assertFocus({ index: 0 }, "First item is still focused"); + assertSelection([1], "Second item is still selected"); + } + + // Even if focus remains the same, the selection is still updated if we + // don't press Ctrl. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 0 }, "Focus remains on first item"); + assertSelection( + [0], + "Selection moves to the first item since Ctrl was not pressed" + ); + } +} + +// Navigating with keyboard will move focus, and possibly selection. +add_task(function test_keyboard_navigation() { + for (let model of selectionModels) { + subtest_keyboard_navigation(model, "top-to-bottom", { + forward: "KEY_ArrowDown", + backward: "KEY_ArrowUp", + }); + subtest_keyboard_navigation(model, "right-to-left", { + forward: "KEY_ArrowLeft", + backward: "KEY_ArrowRight", + }); + subtest_keyboard_navigation(model, "left-to-right", { + forward: "KEY_ArrowRight", + backward: "KEY_ArrowLeft", + }); + } +}); + +/** + * A method to scroll the widget. + * + * @callback ScrollMethod + * @param {number} pos - The position/offset to scroll to. + */ +/** + * The position of an element, relative to the layout of the widget. + * + * @typedef {object} StartEndPositions + * @property {number} start - The starting position of the element in the + * direction of the widget's layout. The value should be a pixel offset + * from some fixed point, such that a higher value indicates an element + * further from the start of the widget. + * @property {number} end - The ending position of the element in the + * direction of the widget's layout. This should use the same fixed point + * as the start. + * @property {number} xStart - An X position in the client coordinates that + * points to the inside of the element, close to the starting corner. I.e. + * the block-start and inline-start. + * @property {number} yStart - A Y position in the client coordinates that + * points to the inside of the element, close to the starting corner. + * @property {number} xEnd - An X position in the client coordinates that + * points to the inside of the element, close to the ending corner. I.e. + * the block-end and inline-end. + * @property {number} yEnd - A Y position in the client coordinates that + * points to the inside of the element, close to the ending corner. + */ +/** + * A method to return the starting and ending positions of the bounding + * client rectangle of an element. + * + * @callback GetStartEndMethod + * @param {DOMRect} rect - The rectangle to get the positions of. + * @returns {object} positions +/** + * Test page navigation for the given direction. + * + * @param {string} model - The selection model to use on the widget. + * @param {string} direction - The direction of the widget layout. + * @param {object} details - Details about the direction. + * @param {string} details.sizeName - The CSS style name that controls the + * widget size in the direction of widget layout. + * @param {string} details.forwardKey - The key to press to move forward one + * item. + * @param {string} details.backwardKey - The key to press to move backward + * one item. + * @param {ScrollMethod} details.scrollTo - A method to call to scroll the + * widget. + * @param {GetStartEndMethod} details.getStartEnd - A method to get the + * positioning of an element. + */ +function subtest_page_navigation(model, direction, details) { + let { sizeName, forwardKey, backwardKey, scrollTo, getStartEnd } = details; + function getStartEndBoundary(element) { + return getStartEnd(element.getBoundingClientRect()); + } + function assertInView(expect, msg) { + let { first, firstClipped, last, lastClipped } = expect; + if (!firstClipped) { + firstClipped = 0; + } + if (!lastClipped) { + lastClipped = 0; + } + let { start: viewStart, end: viewEnd } = getStartEndBoundary(widget); + // The widget has a 1px border that should not contribute to the view + // size. + viewStart += 1; + viewEnd -= 1; + let firstStart = getStartEndBoundary( + widget.items[expect.first].element + ).start; + Assert.equal( + firstStart, + viewStart - firstClipped, + `Item ${first} should be at the start of the view (${viewStart}) clipped by ${firstClipped}: ${msg}` + ); + if (expect.first > 0) { + Assert.lessOrEqual( + getStartEndBoundary(widget.items[expect.first - 1].element).end, + viewStart, + `Item ${expect.first - 1} should be out of view: ${msg}` + ); + } + let lastEnd = getStartEndBoundary(widget.items[expect.last].element).end; + Assert.equal( + lastEnd, + viewEnd + lastClipped, + `Item ${last} should be at the end of the view (${viewEnd}) clipped by ${lastClipped}: ${msg}` + ); + if (expect.last < widget.items.length - 1) { + Assert.greaterOrEqual( + getStartEndBoundary(widget.items[expect.last + 1].element).start, + viewEnd, + `Item ${expect.last + 1} should be out of view: ${msg}` + ); + } + } + reset({ model, direction }); + widget.addItems( + 0, + range(0, 70).map(i => `add-${i}`) + ); + let { start: itemStart, end: itemEnd } = getStartEndBoundary( + widget.items[0].element + ); + Assert.equal(itemEnd - itemStart, 30, "Expected item size"); + + assertInView({ first: 0, last: 19 }, "First 20 items in view"); + stepFocus(true, { index: 0 }, "Move into widget"); + assertSelection([0], "Fist item selected"); + assertInView({ first: 0, last: 19 }, "First 20 items still in view"); + + // PageDown goes to the end of the current page. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 0, last: 19 }, "First 20 item still in view"); + assertFocus({ index: 19 }, "Focus moves to end of the page"); + assertSelection([19], "Selection at end of the page"); + + // Pressing forward key will scroll the next item into view. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertInView({ first: 1, last: 20 }, "Items 1 to 20 in view"); + assertFocus({ index: 20 }, "Focus at end of the page"); + assertSelection([20], "Selection at end of the page"); + + // Pressing backward will not change the view. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view"); + assertFocus({ index: 19 }, "Focus moves up to 19"); + assertSelection([19], "Selection moves up to 19"); + + // PageDown goes to the end of the current page. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view"); + assertFocus({ index: 20 }, "Focus moves to end of page"); + assertSelection([20], "Selection moves to end of page"); + + // PageDown when already at the end of the page will move to the next + // page. + // The last index from the previous page (20) should still be visible at + // the top. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view"); + assertFocus({ index: 39 }, "Focus moves to end of new page"); + assertSelection([39], "Selection moves to end of new page"); + + // Another PageDown will do the same. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 39, last: 58 }, "Items 39 to 58 in view"); + assertFocus({ index: 58 }, "Focus moves to end of new page"); + assertSelection([58], "Selection moves to end of new page"); + + // Last PageDown will take us to the end. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 50, last: 69 }, "Last 20 items in view"); + assertFocus({ index: 69 }, "Focus moves to end"); + assertSelection([69], "Selection moves to end"); + + // Same thing in reverse with PageUp. + // PageUp goes to the start of the current page. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 50, last: 69 }, "Last 20 item still in view"); + assertFocus({ index: 50 }, "Focus moves to start of the page"); + assertSelection([50], "Selection at end of the page"); + + // Pressing backward will scroll the previous item into view. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertInView({ first: 49, last: 68 }, "Items 49 to 68 in view"); + assertFocus({ index: 49 }, "Focus at start of the page"); + assertSelection([49], "Selection at start of the page"); + + // Pressing forward will not change the view. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view"); + assertFocus({ index: 50 }, "Focus moves up to 50"); + assertSelection([50], "Selection moves up to 50"); + + // PageUp goes to the start of the current page. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view"); + assertFocus({ index: 49 }, "Focus moves to start of page"); + assertSelection([49], "Selection moves to start of page"); + + // PageUp when already at the start of the page will move one page up. + // The first index from the previously shown page (49) should still be + // visible at the bottom. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 30, last: 49 }, "Items 30 to 49 in view"); + assertFocus({ index: 30 }, "Focus moves to start of new page"); + assertSelection([30], "Selection moves to start of new page"); + + // Another PageUp will do the same. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 11, last: 30 }, "Items 11 to 30 in view"); + assertFocus({ index: 11 }, "Focus moves to start of new page"); + assertSelection([11], "Selection moves to start of new page"); + + // Last PageUp will take us to the start. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 0, last: 19 }, "Items 0 to 19 in view"); + assertFocus({ index: 0 }, "Focus moves to start"); + assertSelection([0], "Selection moves to start"); + + // PageDown with focus above the view. Focus should move to the end of the + // visible page. + scrollTo(120); + assertInView({ first: 4, last: 23 }, "Items 4 to 23 in view"); + assertFocus({ index: 0 }, "Focus remains above the view"); + assertSelection([0], "Selection remains above the view"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 4, last: 23 }, "Same items in view"); + assertFocus({ index: 23 }, "Focus moves to the end of the visible page"); + assertSelection([23], "Selection moves to the end of the visible page"); + + // PageDown with focus below the view. Focus should shift by one page, + // with the previous focus at the top of the page. + scrollTo(60); + assertInView({ first: 2, last: 21 }, "Items 2 to 21 in view"); + assertFocus({ index: 23 }, "Focus remains below the view"); + assertSelection([23], "Selection remains below the view"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView( + { first: 23, last: 42 }, + "View shifts by a page relative to focus" + ); + assertFocus({ index: 42 }, "Focus moves to end of new page"); + assertSelection([42], "Selection moves to end of new page"); + + // PageUp with focus below the view. Focus should move to the start of the + // visible page. + scrollTo(630); + assertInView({ first: 21, last: 40 }, "Items 21 to 40 in view"); + assertFocus({ index: 42 }, "Focus remains below the view"); + assertSelection([42], "Selection remains below the view"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 21, last: 40 }, "Same items in view"); + assertFocus({ index: 21 }, "Focus moves to the start of the visible page"); + assertSelection([21], "Selection moves to the start of the visible page"); + + // PageUp with focus above the view. Focus should shift by one page, with + // the previous focus at the bottom of the page. + scrollTo(750); + assertInView({ first: 25, last: 44 }, "Items 25 to 44 in view"); + assertFocus({ index: 21 }, "Focus remains above the view"); + assertSelection([21], "Selection remains above the view"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView( + { first: 2, last: 21 }, + "View shifts by a page relative to focus" + ); + assertFocus({ index: 2 }, "Focus moves to start of new page"); + assertSelection([2], "Selection moves to start of new page"); + + // Test when view does not exactly fit items. + for (let sizeDiff of [0, 10, 15, 20]) { + info(`Reducing widget size by ${sizeDiff}px`); + widget.style[sizeName] = `${600 - sizeDiff}px`; + + // When we reduce the size of the view by half an item or more, we + // reduce the page size from 20 to 19. + // NOTE: At each sizeDiff still fits strictly more than 19 items in its + // view. + let pageSize = sizeDiff < 15 ? 20 : 19; + + // Make sure that Home and End keys scroll the view and clip the items + // as expected. + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertInView( + { first: 0, last: 19, lastClipped: sizeDiff }, + `Start of view with last item clipped by ${sizeDiff}px` + ); + assertFocus({ index: 0 }, "First item has focus"); + assertSelection([0], "First item is selected"); + + EventUtils.synthesizeKey("KEY_End", {}, win); + assertInView( + { first: 50, firstClipped: sizeDiff, last: 69 }, + `End of view with first item clipped by ${sizeDiff}px` + ); + assertFocus({ index: 69 }, "Last item has focus"); + assertSelection([69], "Last item is selected"); + + for (let lastClipped of [0, 10, 15, 20]) { + info(`Testing PageDown with last item clipped by ${lastClipped}px`); + // Across all sizeDiff and lastClipped values we still want the last + // item to be index 21 clipped by lastClipped. + // E.g. when sizeDiff is 10 and lastClipped is 10, then the scroll + // will be 60px and the first item will be index 2 with no clipping. + // But when the sizeDiff is 10 and the lastClipped is 20, then the + // scroll will be 50px and the first item will be index 1 with 20px + // clipping. + let scroll = 60 + sizeDiff - lastClipped; + scrollTo(scroll); + let first = Math.floor(scroll / 30); + let firstClipped = scroll % 30; + clickWidgetItem(3, {}); + assertInView( + { first, firstClipped, last: 21, lastClipped }, + `Last item 21 in view clipped by ${lastClipped}px` + ); + assertFocus({ index: 3 }, "Focus on item 3"); + assertSelection([3], "Selection on item 3"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + let pageEnd; + if (lastClipped < 15) { + // The last item is more than half in view, so counts as part of the + // page. + // NOTE: Index of the first item is always "2", even if it was "1" + // before the scroll, because the view fits (19, 20] items. + assertInView( + { first: 2, firstClipped: sizeDiff, last: 21 }, + "Scrolls down to fully include the last item 21" + ); + pageEnd = 21; + } else { + // The last item is half or less in view, so only the one before it + // counts as being part of the page. + assertInView( + { first, firstClipped, last: 21, lastClipped }, + "Same view" + ); + pageEnd = 20; + } + assertFocus({ index: pageEnd }, "Focus moves to pageEnd"); + assertSelection([pageEnd], "Selection moves to pageEnd"); + + // Reset scroll to test scrolling when the focus is already at the + // pageEnd. + scrollTo(scroll); + assertInView( + { first, firstClipped, last: 21, lastClipped }, + `Last item 21 in view clipped by ${lastClipped}px` + ); + + // PageDown again will move by a page. The new end of the page will be + // scrolled just into view at the bottom. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + let newPageEnd = pageEnd + pageSize - 1; + // NOTE: If the previous pageEnd would fit mostly in view, then we + // expect the first item in the view to be this item. Otherwise, we + // expect it to be the one before, which will ensure the previous + // pageEnd is fully visible. + firstClipped = sizeDiff; + first = sizeDiff < 15 ? pageEnd : pageEnd - 1; + assertInView( + { first, firstClipped, last: newPageEnd }, + "New page end scrolled into view, previous page end mostly visible" + ); + assertFocus({ index: newPageEnd }, "Focus moves to end of new page"); + assertSelection([newPageEnd], "Selection moves to end of new page"); + + // PageUp reverses the focus. + // We don't test the the view since that is handled lower down. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertFocus({ index: pageEnd }, "Focus returns to pageEnd"); + assertSelection([pageEnd], "Selection returns to pageEnd"); + } + + for (let firstClipped of [0, 10, 15, 20]) { + // Across all sizeDiff and firstClipped values we still want the first + // item to be index 24 clipped by firstClipped. + // E.g. when sizeDiff is 10 and firstClipped is 10, then the scroll + // will be 730px and the last item will be index 44 with no clipping. + // But when the sizeDiff is 10 and the firstClipped is 0, then the + // scroll will be 720px and the last item will be index 43 with 10px + // clipping. + info(`Testing PageUp with first item clipped by ${firstClipped}px`); + scrollTo(720 + firstClipped); + let viewEnd = 720 + firstClipped + 600 - sizeDiff; + let last = Math.floor(viewEnd / 30); + let lastClipped = 30 - (viewEnd % 30); + clickWidgetItem(42, {}); + assertInView( + { first: 24, firstClipped, last, lastClipped }, + `First item 24 in view clipped by ${firstClipped}px` + ); + assertFocus({ index: 42 }, "Focus on item 42"); + assertSelection([42], "Selection on item 42"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + let pageStart; + if (firstClipped < 15) { + // The first item is more than half in view, so counts as part of + // the page. + // NOTE: Index of the last item is always "43", even if it was "44" + // before the scroll, because the view fits (19, 20] items. + assertInView( + { first: 24, last: 43, lastClipped: sizeDiff }, + "Scrolls up to fully include the first item 24" + ); + pageStart = 24; + } else { + // The first item is half or less in view, so only the one after it + // counts as being part of the page. + assertInView( + { first: 24, firstClipped, last, lastClipped }, + "Same view" + ); + pageStart = 25; + } + assertFocus({ index: pageStart }, "Focus moves to pageStart"); + assertSelection([pageStart], "Selection moves to pageStart"); + + // Reset scroll. + scrollTo(720 + firstClipped); + assertInView( + { first: 24, firstClipped, last, lastClipped }, + `First item 24 in view clipped by ${firstClipped}px` + ); + + // PageUp again will move by a page. The new start of the page will be + // scrolled just into view at the top. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + let newPageStart = pageStart - pageSize + 1; + // NOTE: If the previous pageStart would fit mostly in view, then we + // expect the last item in the view to be this item. Otherwise, we + // expect it to be the one after, which will ensure the previous + // pageStart is fully visible. + lastClipped = sizeDiff; + last = sizeDiff < 15 ? pageStart : pageStart + 1; + assertInView( + { first: newPageStart, last, lastClipped }, + "New page end scrolled into view, previous page end mostly visible" + ); + assertFocus({ index: newPageStart }, "Focus moves to start of new page"); + assertSelection([newPageStart], "Selection moves to start of new page"); + + // PageDown reverses the focus. + // We don't test the the view since that is handled further up. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertFocus({ index: pageStart }, "Focus returns to pageStart"); + assertSelection([pageStart], "Selection returns to pageStart"); + } + } + + // When widget only fits 1 visible item or less. + for (let size of [10, 20, 30, 45, 50]) { + info(`Resizing widget to ${size}px`); + widget.style[sizeName] = `${size}px`; + + scrollTo(600); + // When the view size is less than the size of an item, we cannot always + // click the center of the item, so we need to click the start instead. + let { xStart, yStart } = getStartEndBoundary(widget.items[20].element); + EventUtils.synthesizeMouseAtPoint(xStart, yStart, {}, win); + let last = size > 30 ? 21 : 20; + let lastClipped = size > 30 ? 60 - size : 30 - size; + assertInView({ first: 20, last, lastClipped }, "Small number of items"); + assertFocus({ index: 20 }, "Focus on item 20"); + assertSelection([20], "Item 20 selected"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + if (size <= 45) { + // Only 1 or 0 items fit on the page, so does nothing. + assertInView({ first: 20, last, lastClipped }, "Same view"); + assertFocus({ index: 20 }, "Same focus"); + assertSelection([20], "Same selected"); + } else { + // 2 items fit visibly on the page, so acts as normal. + assertInView( + { first: 20, firstClipped: lastClipped, last: 21 }, + "Last item scrolled into view" + ); + assertFocus({ index: 21 }, "Focus increases by one"); + assertSelection([21], "Selected moves to focus"); + } + + scrollTo(660 - size); + let { xEnd, yEnd } = getStartEndBoundary(widget.items[21].element); + EventUtils.synthesizeMouseAtPoint(xEnd, yEnd, {}, win); + let first = size > 30 ? 20 : 21; + let firstClipped = size > 30 ? 60 - size : 30 - size; + assertInView({ first, firstClipped, last: 21 }, "Small number of items"); + assertFocus({ index: 21 }, "Focus on item 21"); + assertSelection([21], "Item 21 selected"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + if (size <= 45) { + // Only 1 or 0 items fit on the page, so does nothing. + assertInView({ first, firstClipped, last: 21 }, "Same view"); + assertFocus({ index: 21 }, "Same focus"); + assertSelection([21], "Same selected"); + } else { + // 2 items fit visibly on the page, so acts as normal. + assertInView( + { first: 20, last: 21, lastClipped: firstClipped }, + "First item scrolled into view" + ); + assertFocus({ index: 20 }, "Focus decreases by one"); + assertSelection([20], "Selected moves to focus"); + } + } + widget.style[sizeName] = null; + + // Disable page navigation. + // This would be used when the item sizes or the page layout do not allow + // for page navigation, or if PageUp and PageDown should be used for something + // else. + widget.toggleAttribute("no-pages", true); + + let gotKeys = []; + let keydownListener = event => { + gotKeys.push(event.key); + }; + win.document.body.addEventListener("keydown", keydownListener); + scrollTo(600); + clickWidgetItem(20, {}); + assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view"); + assertFocus({ index: 20 }, "First item focused"); + assertSelection([20], "First item selected"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 20 }, "Same focus"); + assertSelection([20], "Same selected"); + Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body"); + gotKeys = []; + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 20 }, "Same focus"); + assertSelection([20], "Same selected"); + Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body"); + gotKeys = []; + + clickWidgetItem(39, {}); + assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view"); + assertFocus({ index: 39 }, "Last item focused"); + assertSelection([39], "Last item selected"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 39 }, "Same focus"); + assertSelection([39], "Same selected"); + Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body"); + gotKeys = []; + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 39 }, "Same focus"); + assertSelection([39], "Same selected"); + Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body"); + gotKeys = []; + + widget.removeAttribute("no-pages"); + + // With page navigation enabled key-presses do not reach the document body. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + Assert.deepEqual(gotKeys, [], "No key reaches document body"); + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + Assert.deepEqual(gotKeys, [], "No key reaches document body"); + + win.document.body.removeEventListener("keydown", keydownListener); + + // Test with modifiers. + for (let { shiftKey, ctrlKey } of [ + { shiftKey: true, ctrlKey: true }, + { shiftKey: false, ctrlKey: true }, + { shiftKey: true, ctrlKey: false }, + ]) { + info( + `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}PageUp/Down` + ); + EventUtils.synthesizeKey("KEY_Home", {}, win); + EventUtils.synthesizeKey(forwardKey, {}, win); + assertInView({ first: 0, last: 19 }, "First 20 items in view"); + assertFocus({ index: 1 }, "Item 1 has focus"); + assertSelection([1], "Item 1 is selected"); + + EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win); + assertInView({ first: 0, last: 19 }, "Same view"); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertFocus({ index: 1 }, "Same focus"); + assertSelection([1], "Same selected"); + // Move focus to the end of the view. + clickWidgetItem(19, {}); + assertFocus({ index: 19 }, "Focus at end of page"); + assertSelection([19], "Selected at end of page"); + } else { + assertFocus({ index: 19 }, "Focus moves to end of page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([1], "Same selected"); + } else { + assertSelection(range(1, 19), "Range selection from 1 to 19"); + } + } + // And again, with focus at the end of the page. + EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertInView({ first: 0, last: 19 }, "Same view"); + assertFocus({ index: 19 }, "Same focus"); + assertSelection([19], "Same selected"); + } else { + assertInView({ first: 19, last: 38 }, "View scrolls to focus"); + assertFocus({ index: 38 }, "Focus moves to end of new page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([1], "Same selected"); + } else { + assertSelection(range(1, 38), "Range selection from 1 to 38"); + } + } + + EventUtils.synthesizeKey("KEY_End", {}, win); + EventUtils.synthesizeKey(backwardKey, {}, win); + assertInView({ first: 50, last: 69 }, "Last 20 items in view"); + assertFocus({ index: 68 }, "Item 68 has focus"); + assertSelection([68], "Item 68 is selected"); + + EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win); + assertInView({ first: 50, last: 69 }, "Same view"); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertFocus({ index: 68 }, "Same focus"); + assertSelection([68], "Same selected"); + // Move focus to the end of the view. + clickWidgetItem(50, {}); + assertFocus({ index: 50 }, "Focus at start of page"); + assertSelection([50], "Selected at start of page"); + } else { + assertFocus({ index: 50 }, "Focus moves to start of page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([68], "Same selected"); + } else { + assertSelection(range(50, 19), "Range selection from 50 to 68"); + } + } + // And again, with focus at the start of the page. + EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertInView({ first: 50, last: 69 }, "Same view"); + assertFocus({ index: 50 }, "Same focus"); + assertSelection([50], "Same selected"); + } else { + assertInView({ first: 31, last: 50 }, "View scrolls to focus"); + assertFocus({ index: 31 }, "Focus moves to start of new page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([68], "Same selected"); + } else { + assertSelection(range(31, 38), "Range selection from 31 to 68"); + } + } + } + + // Does nothing with an empty widget. + reset({ model, direction }); + stepFocus(true, { element: widget }, "Focus on empty widget"); + assertState([], "Empty"); + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertFocus({ element: widget }, "No change in focus"); + assertState([], "Empty"); + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertFocus({ element: widget }, "No change in focus"); + assertState([], "Empty"); +} + +// Test that pressing PageUp or PageDown shifts the view according to the +// visible items. +add_task(function test_page_navigation() { + for (let model of selectionModels) { + subtest_page_navigation(model, "top-to-bottom", { + sizeName: "height", + forwardKey: "KEY_ArrowDown", + backwardKey: "KEY_ArrowUp", + scrollTo: pos => { + widget.scrollTop = pos; + }, + getStartEnd: rect => { + return { + start: rect.top, + end: rect.bottom, + xStart: rect.right - 1, + xEnd: rect.left + 1, + yStart: rect.top + 1, + yEnd: rect.bottom - 1, + }; + }, + }); + subtest_page_navigation(model, "right-to-left", { + sizeName: "width", + forwardKey: "KEY_ArrowLeft", + backwardKey: "KEY_ArrowRight", + scrollTo: pos => { + widget.scrollLeft = -pos; + }, + getStartEnd: rect => { + return { + start: -rect.right, + end: -rect.left, + xStart: rect.right - 1, + xEnd: rect.left + 1, + yStart: rect.top + 1, + yEnd: rect.bottom - 1, + }; + }, + }); + subtest_page_navigation(model, "left-to-right", { + sizeName: "width", + forwardKey: "KEY_ArrowRight", + backwardKey: "KEY_ArrowLeft", + scrollTo: pos => { + widget.scrollLeft = pos; + }, + getStartEnd: rect => { + return { + start: rect.left, + end: rect.right, + xStart: rect.left + 1, + xEnd: rect.right - 1, + yStart: rect.top + 1, + yEnd: rect.bottom - 1, + }; + }, + }); + } +}); + +// Using Space to select items. +add_task(function test_space_selection() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + + stepFocus(true, { index: 0 }, "Move focus to first item"); + assertSelection([0], "First item is selected"); + + // Selecting an already selected item does nothing. + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 0 }, "First item still has focus"); + assertSelection([0], "First item is still selected"); + + if (model == "focus") { + // Just move to second item as set up for the loop. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + } else { + // Selecting a non-selected item will move selection to it. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([0], "First item is still selected"); + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item becomes selected"); + } + + // Ctrl + Space will toggle the selection if multi-selection is supported. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + if (model == "focus") { + // Did nothing. + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item is still selected"); + } else if (model == "browse") { + // Did nothing. + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item is still selected"); + // Make sure nothing happens when on a non-selected item as well. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1], "Second item is still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([1], "Second item is still selected"); + // Restore the previous state. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + } else { + // Unselected the item. + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([], "Second item was un-selected"); + // Toggle again. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item was re-selected"); + + // Do on another index. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item has focus"); + assertSelection([1], "Second item is still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item still has focus"); + assertSelection([1, 3], "Fourth item becomes selected as well"); + + // Move to third without clearing. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1, 3], "Fourth and second item remain selected"); + + // Merge the two ranges together. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([1, 2, 3], "Third item becomes selected"); + + // Shrink the range at the end. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item has focus"); + assertSelection([1, 2, 3], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item still has focus"); + assertSelection([1, 2], "Fourth item unselected"); + + // Shrink the range at the start. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1, 2], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([2], "Second item unselected"); + + // No selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([2], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([], "Third item unselected"); + + // Using arrow keys without modifier will re-introduce a single selection. + EventUtils.synthesizeKey("KEY_ArrowRight"); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1], "Second item becomes selected"); + + // Grow range at the start. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "First item has focus"); + assertSelection([1], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "First item still has focus"); + assertSelection([0, 1], "First item becomes selected"); + + // Grow range at the end. + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([0, 1], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([0, 1, 2], "Third item becomes selected"); + + // Split the range in half. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([0, 1, 2], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([0, 2], "Second item unselected"); + + // Pressing Space without a modifier clears the multi-selection. + EventUtils.synthesizeKey(" ", {}, win); + } + + // Make sure we are in the expected shared state between models. + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1], "Second item is selected"); + + // Shift + Space will do nothing. + for (let ctrlKey of [false, true]) { + info(`Pressing ${ctrlKey ? "Ctrl+" : ""}Shift+space on item`); + // On selected item. + EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item is still selected"); + + if (model == "focus") { + continue; + } + + // On non-selected item. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1], "Second item is still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([1], "Second item is still selected"); + + // Restore for next loop. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1], "Second item is selected"); + } + } +}); + +// Clicking an item will focus and select it. +add_task(function test_clicking_items() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, [ + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Sixth", + "Seventh", + "Eighth", + ]); + + assertFocus({ element: before }, "Focus initially outside widget"); + assertSelection([], "No initial selection"); + + // Focus moves into widget, onto the clicked item. + clickWidgetItem(1, {}); + assertFocus({ index: 1 }, "Focus clicked second item"); + assertSelection([1], "Selected clicked second item"); + + // Focus moves to different item. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus clicked third item"); + assertSelection([2], "Selected clicked third item"); + + // Click same item. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Selected remains on third item"); + + // Focus outside widget, focus moves but selection remains. + before.focus(); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([2], "Selected remains on third item"); + + // Clicking same item will return focus to it. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus returns to third item"); + assertSelection([2], "Selected remains on third item"); + + // Do the same, but return to a different item. + before.focus(); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([2], "Selected remains on third item"); + + // Clicking same item will return focus to it. + clickWidgetItem(1, {}); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Selected moves to second item"); + + // Switching to keyboard works. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0], "Selected moves to first item"); + + // Returning to mouse works. + clickWidgetItem(1, {}); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Selected moves to second item"); + + // Toggle selection with Ctrl+Click. + clickWidgetItem(3, { ctrlKey: true }); + if (model == "browse-multi") { + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 3], "Fourth item is selected"); + + clickWidgetItem(7, { ctrlKey: true }); + assertFocus({ index: 7 }, "Focus moves to eighth item"); + assertSelection([1, 3, 7], "Eighth item selected"); + + // Extend selection range by one after. + clickWidgetItem(4, { ctrlKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([1, 3, 4, 7], "Fifth item is selected"); + + // Extend selection range by one before. + clickWidgetItem(6, { ctrlKey: true }); + assertFocus({ index: 6 }, "Focus moves to seventh item"); + assertSelection([1, 3, 4, 6, 7], "Seventh item is selected"); + + // Merge the two ranges together. + clickWidgetItem(5, { ctrlKey: true }); + assertFocus({ index: 5 }, "Focus moves to sixth item"); + assertSelection([1, 3, 4, 5, 6, 7], "Sixth item is selected"); + + // Reverse by unselecting. + clickWidgetItem(7, { ctrlKey: true }); + assertFocus({ index: 7 }, "Focus moves to eight item"); + assertSelection([1, 3, 4, 5, 6], "Eight item is unselected"); + + clickWidgetItem(3, { ctrlKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 4, 5, 6], "Fourth item is unselected"); + + // Split a range. + clickWidgetItem(5, { ctrlKey: true }); + assertFocus({ index: 5 }, "Focus moves to sixth item"); + assertSelection([1, 4, 6], "Sixth item is unselected"); + + clickWidgetItem(1, { ctrlKey: true }); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([4, 6], "Second item is unselected"); + + clickWidgetItem(6, { ctrlKey: true }); + assertFocus({ index: 6 }, "Focus moves to seventh item"); + assertSelection([4], "Seventh item is unselected"); + + // Can get zero-selection. + clickWidgetItem(4, { ctrlKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([], "None selected"); + + // Get into the same state as the other case. + clickWidgetItem(1, { ctrlKey: true }); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Second item is selected"); + } else { + // No multi-selection, so does nothing. + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1], "Second item remains selected"); + } + + // Ctrl+Shift+Click does nothing in all models. + clickWidgetItem(2, { ctrlKey: true, shiftKey: true }); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1], "Second item remains selected"); + } +}); + +add_task(function test_select_all() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + + stepFocus(true, { index: 0 }, "Move focus to first item"); + assertSelection([0], "First item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertFocus({ index: 1 }, "Focus on second item"); + assertSelection([1], "Second item is selected"); + + selectAllShortcut(); + + assertFocus({ index: 1 }, "Focus remains on second item"); + if (model == "browse-multi") { + assertSelection([0, 1, 2, 3, 4], "All items are selected"); + // Can insert a hole. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([0, 1, 2, 3, 4], "All items are still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([0, 1, 3, 4], "Third item was unselected"); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to the second item"); + assertSelection([0, 1, 3, 4], "Selection remains the same"); + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 1 }, "Focus remains on the second item"); + assertSelection([1], "Only the second item is selected"); + } else { + // Did nothing. + assertSelection([1], "Second item is still selected"); + } + + // Wrong platform modifier does nothing. + EventUtils.synthesizeKey( + "a", + AppConstants.platform == "macosx" ? { ctrlKey: true } : { metaKey: true }, + win + ); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1], "Second item still selected"); + } +}); + +// Holding the shift key should perform a range selection if multi-selection is +// supported by the model. +add_task(function test_range_selection() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + + stepFocus(true, { index: 0 }, "Move focus to first item"); + assertSelection([0], "First item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + + assertFocus({ index: 2 }, "Focus on third item"); + assertSelection([2], "Third item is selected"); + + // Nothing happens with Ctrl+Shift in any model. + EventUtils.synthesizeKey( + "KEY_ArrowLeft", + { shiftKey: true, ctrlKey: true }, + win + ); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + EventUtils.synthesizeKey( + "KEY_ArrowRight", + { shiftKey: true, ctrlKey: true }, + win + ); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + // With just Shift modifier. + if (model == "focus" || model == "browse") { + // No range selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + clickWidgetItem(1, { shiftKey: true }); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + continue; + } + + // Range selection with shift key. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus on fourth item"); + assertSelection([2, 3], "Select from third to fourth item"); + + // Reverse + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus on third item"); + assertSelection([2], "Select from third to same item"); + + // Go back another step. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus on second item"); + assertSelection([1, 2], "Third to second items are selected"); + + // Split focus from selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus on third item"); + assertSelection([1, 2], "Third to second items are still selected"); + + // Back to range selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus on fourth item"); + assertSelection([2, 3], "Third to fourth items are selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus on fifth item"); + assertSelection([2, 3, 4], "Third to fifth items are selected"); + + // Moving without a modifier breaks the range. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertFocus({ index: 4 }, "Focus remains on final fifth item"); + assertSelection([4], "Fifth item is selected"); + + // Again at the middle. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth items are selected"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([2], "Only third item is selected"); + + // Home and End also work. + EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0, 1, 2], "Up to third item is selected"); + + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus moves to last item"); + assertSelection([2, 3, 4], "Third item and above is selected"); + + // Ctrl+A breaks range selection sequence, so we no longer select around the + // third item when we go back to using Shift+Arrow. + selectAllShortcut(); + assertFocus({ index: 4 }, "Focus remains on last item"); + assertSelection([0, 1, 2, 3, 4], "All items are selected"); + // The new shift+range will be from the focus index (the fifth item) rather + // than the third item used for the previous range. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth item are selected"); + + // Ctrl+Space also breaks range selection sequence. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([3, 4], "Range selection remains"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Focus still on first item"); + assertSelection([0, 3, 4], "First item added to selection"); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([0, 1], "First to second item are selected"); + + // Same when unselecting. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([0], "Second item is no longer selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([1, 2], "Second to third item are selected"); + + // Same when using setItemSelected API + widget.setItemSelected(4, true); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([1, 2, 4], "Fifth item becomes selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([2, 3], "Third to fourth item are selected"); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([2, 3, 4], "Third to fifth item are selected"); + + widget.setItemSelected(3, false); + assertFocus({ index: 4 }, "Focus remains on fifth item"); + assertSelection([2, 4], "Fourth item becomes unselected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth item are selected"); + + // Even when the selection state does not change. + widget.setItemSelected(3, true); + assertFocus({ index: 3 }, "Focus remains on fourth item"); + assertSelection([3, 4], "Same selection"); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([2, 3], "Fourth to third item are selected"); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1, 2, 3], "Fourth to second item are selected"); + + widget.setItemSelected(4, false); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1, 2, 3], "Same selection"); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([1, 2], "Second to third item are selected"); + + // Same when selecting with space (no modifier). + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Third item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([2, 3], "Third to fourth item are selected"); + + // Same when using the selectSingleItem API. + widget.selectSingleItem(1); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Second item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0, 1], "Second to first item are selected"); + + // If focus goes out and we return, the range origin is remembered. + stepFocus(true, { element: after }, "Move focus outside the widget"); + assertSelection([0, 1], "Second to first item are still selected"); + stepFocus(false, { index: 0 }, "Focus returns to the widget"); + assertSelection([0, 1], "Second to first item are still selected"); + + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus moves to last item"); + assertSelection([1, 2, 3, 4], "Second to fifth item are selected"); + + // Clicking empty space does not clear it. + clickWidgetEmptySpace({}); + assertFocus({ index: 4 }, "Focus remains on last item"); + assertSelection([1, 2, 3, 4], "Second to fifth item are still selected"); + + // Shift+Click an item will use the same range origin established by the + // current selection. + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 2, 3], "Second to fourth item are selected"); + + // Clicking without the modifier breaks the range selection sequence. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([2], "Only the third item is selected"); + + // Shift click will select between the third item and the the clicked item. + clickWidgetItem(4, { shiftKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([2, 3, 4], "Third to fifth item are selected"); + + // Reverse direction about the same point. + clickWidgetItem(0, { shiftKey: true }); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0, 1, 2], "Third to first item are selected"); + + // Ctrl+Click breaks the range selection sequence. + clickWidgetItem(1, { ctrlKey: true }); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([0, 2], "Second item is unselected"); + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 2, 3], "Second to fourth item are selected"); + + // Same when Ctrl+Click on non-selected. + clickWidgetItem(4, { ctrlKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([1, 2, 3, 4], "Fifth item is selected"); + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth item are selected"); + + // Selecting-all also breaks range selection sequence. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([3, 4], "Same selection"); + + selectAllShortcut(); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([0, 1, 2, 3, 4], "All items selected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1, 2], "Third to second item selected"); + } +}); + +// Adding items to widget with existing items, should not change the selected +// item. +add_task(function test_add_items_to_nonempty() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add"]); + stepFocus(true, { index: 0, text: "0-add" }, "Move focus to 0-add"); + assertState([{ text: "0-add", selected: true, focused: true }], "One item"); + + // Add item after. + widget.addItems(1, ["1-add"]); + assertState( + [{ text: "0-add", selected: true, focused: true }, { text: "1-add" }], + "0-add still focused and selected" + ); + + // Add item before. 0-add moves to index 1. + widget.addItems(0, ["2-add"]); + assertState( + [ + { text: "2-add" }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + ], + "0-add still focused and selected" + ); + + // Add several before. + widget.addItems(1, ["3-add", "4-add", "5-add"]); + assertState( + [ + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + ], + "0-add still focused and selected" + ); + + // Key navigation works. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertState( + [ + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + ], + "5-add becomes focused and selected" + ); + + // With focus outside the widget. + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add"]); + stepFocus(true, { index: 0 }, "Move focus to 0-add"); + assertState([{ text: "0-add", selected: true, focused: true }], "One item"); + + stepFocus(true, { element: after }, "Move focus to after widget"); + // Add after. + widget.addItems(1, ["1-add", "2-add"]); + assertState( + [{ text: "0-add", selected: true }, { text: "1-add" }, { text: "2-add" }], + "0-add still selected but not focused" + ); + stepFocus(false, { index: 0 }, "Move focus back to 0-add"); + assertState( + [ + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + ], + "0-add selected and focused" + ); + + stepFocus(false, { element: before }, "Move focus to before widget"); + // Add before. + widget.addItems(0, ["3-add", "4-add"]); + assertState( + [ + { text: "3-add" }, + { text: "4-add" }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + ], + "0-add selected but not focused" + ); + stepFocus(true, { index: 2 }, "Move focus back to 0-add"); + assertState( + [ + { text: "3-add" }, + { text: "4-add" }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + ], + "0-add selected and focused" + ); + + // With focus separate from selection. + if (model == "focus") { + continue; + } + + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add", "1-add", "2-add"]); + assertState( + [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }], + "None selected or focused" + ); + stepFocus(true, { index: 0 }, "Move focus to 0-add"); + + // With selection after focus. + EventUtils.synthesizeKey("KEY_End", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "0-add" }, + { text: "1-add", focused: true }, + { text: "2-add", selected: true }, + ], + "Selection after focus" + ); + + // Add after both selection and focus. + widget.addItems(3, ["3-add"]); + assertState( + [ + { text: "0-add" }, + { text: "1-add", focused: true }, + { text: "2-add", selected: true }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // Add before both selection and focus. + widget.addItems(1, ["4-add"]); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "1-add", focused: true }, + { text: "2-add", selected: true }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // Before selection, after focus. + widget.addItems(3, ["5-add"]); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "1-add", focused: true }, + { text: "5-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // Swap selection to be before focus. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "0-add" }, + { text: "4-add", selected: true }, + { text: "1-add" }, + { text: "5-add", focused: true }, + { text: "2-add" }, + { text: "3-add" }, + ], + "Selection before focus" + ); + + // After selection, before focus. + widget.addItems(2, ["6-add", "7-add", "8-add"]); + assertState( + [ + { text: "0-add" }, + { text: "4-add", selected: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "1-add" }, + { text: "5-add", focused: true }, + { text: "2-add" }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // With multi-selection. + if (model == "browse") { + continue; + } + + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add", "1-add", "2-add"]); + assertState( + [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }], + "None selected" + ); + stepFocus(true, { index: 0 }, "Move focus to 0-add"); + + // Select all. + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertState( + [ + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + ], + "All selected" + ); + + // Add after all. + widget.addItems(3, ["3-add", "4-add", "5-add"]); + assertState( + [ + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same range selected" + ); + + // Can continue shift selection to newly added item + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Range extended to new item" + ); + + // Add before all. + widget.addItems(0, ["6-add", "7-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same range selected" + ); + + // Can continue shift selection about the "0-add" item. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Range extended backward" + ); + + // And change direction of shift selection range. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertState( + [ + { text: "6-add", focused: true }, + { text: "7-add" }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Focus moves to first item" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Selection pivoted about 0-add" + ); + + // Add items in the middle of the range. Selection in the range is not added + // initially. + widget.addItems(2, ["8-add", "9-add", "10-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add", selected: true, focused: true }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Backward range selection with single gap" + ); + + // But continuing the shift selection will fill in the holes again. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "6-add", selected: true, focused: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "9-add", selected: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Backward range selection with no gap" + ); + + // Do the same but with a selection range moving forward and two holes. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "9-add", selected: true }, + { text: "10-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Forward range selection" + ); + + widget.addItems(3, ["11-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add" }, + { text: "9-add", selected: true }, + { text: "10-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Forward range selection with one gap" + ); + + widget.addItems(5, ["12-add", "13-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add" }, + { text: "9-add", selected: true }, + { text: "12-add" }, + { text: "13-add" }, + { text: "10-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Forward range selection with two gaps" + ); + + // Continuing the shift selection will fill in the holes. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "13-add", selected: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Extended range forward with no gaps" + ); + + // With multi-selection via toggling. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "13-add", selected: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Selected 2-add" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "13-add", focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "De-selected 13-add" + ); + + widget.addItems(6, ["14-add", "15-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add" }, + { text: "15-add" }, + { text: "13-add", focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same selected items" + ); + + widget.addItems(3, ["16-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "16-add" }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add" }, + { text: "15-add" }, + { text: "13-add", focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same selected items" + ); + + // With select-all + selectAllShortcut(); + assertState( + [ + { text: "6-add", selected: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "16-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add", selected: true }, + { text: "15-add", selected: true }, + { text: "13-add", selected: true, focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true }, + { text: "4-add", selected: true }, + { text: "5-add", selected: true }, + ], + "All items selected" + ); + + // Added items do not become selected. + widget.addItems(4, ["17-add", "18-add"]); + assertState( + [ + { text: "6-add", selected: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "16-add", selected: true }, + { text: "17-add" }, + { text: "18-add" }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add", selected: true }, + { text: "15-add", selected: true }, + { text: "13-add", selected: true, focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true }, + { text: "4-add", selected: true }, + { text: "5-add", selected: true }, + ], + "Added items not selected" + ); + + // Added items will be selected if we select-all again. + selectAllShortcut(); + assertState( + [ + { text: "6-add", selected: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "16-add", selected: true }, + { text: "17-add", selected: true }, + { text: "18-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add", selected: true }, + { text: "15-add", selected: true }, + { text: "13-add", selected: true, focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true }, + { text: "4-add", selected: true }, + { text: "5-add", selected: true }, + ], + "All items selected" + ); + } +}); + +/** + * Test that pressing a key on a non-empty widget that has focus on itself will + * move to the expected index. + * + * @param {object} initialState - The initial state of the widget to set up. + * @param {string} initialState.model - The selection model to use. + * @param {string} initialState.direction - The layout direction of the widget. + * @param {number} initialState.numItems - The number of items in the widget. + * @param {Function} [initialState.scroll] - A method to call to scroll the + * widget. + * @param {string} key - The key to press once the widget is set up. + * @param {number} index - The expected index for the item that will receive + * focus after the key press. + */ +function subtest_keypress_on_focused_widget(initialState, key, index) { + let { model, direction, numItems, scroll } = initialState; + for (let ctrlKey of [false, true]) { + for (let shiftKey of [false, true]) { + info( + `Adding items to empty ${direction} widget and then pressing ${ + ctrlKey ? "Ctrl+" : "" + }${shiftKey ? "Shift+" : ""}${key}` + ); + reset({ model, direction }); + + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems( + 0, + range(0, numItems).map(i => `add-${i}`) + ); + scroll?.(); + + assertFocus( + { element: widget }, + "Focus remains on the widget after adding items" + ); + assertSelection([], "No items are selected yet"); + + EventUtils.synthesizeKey(key, { ctrlKey, shiftKey }, win); + if ( + (ctrlKey && shiftKey) || + (model == "browse" && shiftKey) || + (model == "focus" && (ctrlKey || shiftKey)) + ) { + // Does nothing. + assertFocus({ element: widget }, "Focus remains on widget"); + assertSelection([], "No change in selection"); + continue; + } + + assertFocus({ index }, `Focus moves to ${index} after ${key}`); + if (ctrlKey) { + assertSelection([], `No selection if pressing Ctrl+${key}`); + } else if (shiftKey) { + assertSelection( + range(0, index + 1), + `Range selection from 0 to ${index} if pressing Shift+${key}` + ); + } else { + assertSelection([index], `Item selected after ${key}`); + } + } + } +} + +// If items are added to an empty widget that has focus, nothing happens +// initially. Arrow keys will focus the first item. +add_task(function test_add_items_to_empty_with_focus() { + for (let model of selectionModels) { + // Step navigation always takes us to the first item. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_ArrowUp", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_ArrowDown", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_ArrowRight", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_ArrowLeft", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_ArrowLeft", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_ArrowRight", + 0 + ); + // Home also takes us to the first item. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_Home", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_Home", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_Home", + 0 + ); + // End takes us to the last item. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_End", + 2 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_End", + 2 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_End", + 2 + ); + // PageUp and PageDown take us to the start or end of the visible page. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_PageUp", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_PageDown", + 2 + ); + subtest_keypress_on_focused_widget( + { + model, + direction: "top-to-bottom", + numItems: 30, + scroll: () => { + widget.scrollTop = 270; + }, + }, + "KEY_PageUp", + 9 + ); + subtest_keypress_on_focused_widget( + { + model, + direction: "top-to-bottom", + numItems: 30, + scroll: () => { + widget.scrollTop = 60; + }, + }, + "KEY_PageDown", + 21 + ); + + // Arrow keys in other directions do nothing. + reset({ model, direction: "top-to-bottom" }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second"]); + for (let key of ["KEY_ArrowRight", "KEY_ArrowLeft"]) { + EventUtils.synthesizeKey(key, {}, win); + assertFocus({ element: widget }, `Focus remains on widget after ${key}`); + assertSelection([], `No items become selected after ${key}`); + } + + reset({ model, direction: "right-to-left" }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second"]); + for (let key of ["KEY_ArrowUp", "KEY_ArrowDown"]) { + EventUtils.synthesizeKey(key, {}, win); + assertFocus({ element: widget }, `Focus remains on widget after ${key}`); + assertSelection([], `No items become selected after ${key}`); + } + + // Pressing Space does nothing. + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second"]); + for (let ctrlKey of [false, true]) { + for (let shiftKey of [false, true]) { + info( + `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}Space` + ); + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ element: widget }, "Focus remains on widget after Space"); + assertSelection([], "No items become selected after Space"); + } + } + + // Selecting all + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third"]); + + selectAllShortcut(); + assertFocus({ element: widget }, "Focus remains on the widget"); + if (model == "browse-multi") { + assertSelection([0, 1, 2], "All items selected"); + } else { + assertSelection([], "still no selection"); + } + + // Adding and then removing items does not set focus. + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.removeItems(2, 2); + assertState( + [{ text: "First" }, { text: "Second" }], + "No item focused or selected" + ); + assertFocus({ element: widget }, "Focus remains on the widget"); + widget.removeItems(0, 1); + assertState([{ text: "Second" }], "No item focused or selected"); + assertFocus({ element: widget }, "Focus remains on the widget"); + + // Moving items does not set focus. + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.moveItems(1, 0, 2, false); + assertState( + [ + { text: "Second" }, + { text: "Third" }, + { text: "First" }, + { text: "Fourth" }, + ], + "No item focused or selected" + ); + assertFocus({ element: widget }, "Focus remains on the widget"); + widget.moveItems(0, 1, 3, true); + assertState( + [ + { text: "Fourth" }, + { text: "Second" }, + { text: "Third" }, + { text: "First" }, + ], + "No item focused or selected" + ); + assertFocus({ element: widget }, "Focus remains on the widget"); + + // This does not effect clicking. + // NOTE: case where widget does not initially have focus on clicking is + // handled by test_initial_no_select_focus + for (let ctrlKey of [false, true]) { + for (let shiftKey of [false, true]) { + info( + `Adding items to empty focused widget and then ${ + ctrlKey ? "Ctrl+" : "" + }${shiftKey ? "Shift+" : ""}Click` + ); + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third"]); + + // Clicking empty space does nothing. + clickWidgetEmptySpace({ ctrlKey, shiftKey }); + assertFocus({ element: widget }, "Focus remains on widget"); + assertSelection([], "No item selected"); + + // Clicking an item can change focus and selection. + clickWidgetItem(1, { ctrlKey, shiftKey }); + if ( + (ctrlKey && shiftKey) || + ((model == "focus" || model == "browse") && (ctrlKey || shiftKey)) + ) { + assertFocus({ element: widget }, "Focus remains on widget"); + assertSelection([], "No selection"); + continue; + } + assertFocus({ index: 1 }, "Focus moves to second item"); + if (shiftKey) { + assertSelection([0, 1], "First and second item selected"); + } else { + assertSelection([1], "Second item selected"); + } + } + } + } +}); + +// Removing items from the widget with existing items, may change focus or +// selection if the corresponding item was removed. +add_task(function test_remove_items_nonempty() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + + widget.addItems(0, ["0-add", "1-add", "2-add", "3-add", "4-add", "5-add"]); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "No initial focus or selection" + ); + + clickWidgetItem(2, {}); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add focused and selected" + ); + + // Remove one after. + widget.removeItems(3, 1); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + // Remove one before. + widget.removeItems(0, 1); + assertState( + [ + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + widget.addItems(0, ["6-add", "7-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + // Remove several before. + widget.removeItems(1, 2); + assertState( + [ + { text: "6-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + // Remove selected and focused. Focus should move to the next item. + widget.removeItems(1, 1); + assertState( + [ + { text: "6-add" }, + { text: "4-add", selected: true, focused: true }, + { text: "5-add" }, + ], + "Selection and focus move to 4-add" + ); + + widget.addItems(0, ["8-add"]); + widget.addItems(3, ["9-add", "10-add"]); + assertState( + [ + { text: "8-add" }, + { text: "6-add" }, + { text: "4-add", selected: true, focused: true }, + { text: "9-add" }, + { text: "10-add" }, + { text: "5-add" }, + ], + "Selection and focus still on 4-add" + ); + + // Remove selected and focused, not at boundary. + widget.removeItems(1, 3); + assertState( + [ + { text: "8-add" }, + { text: "10-add", selected: true, focused: true }, + { text: "5-add" }, + ], + "Selection and focus move to 10-add" + ); + + // Remove last item whilst it has focus. Focus should move to the new last + // item. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertState( + [ + { text: "8-add" }, + { text: "10-add" }, + { text: "5-add", selected: true, focused: true }, + ], + "Last item is focused and selected" + ); + + widget.removeItems(2, 1); + assertState( + [{ text: "8-add" }, { text: "10-add", selected: true, focused: true }], + "New last item is focused and selected" + ); + + // Delete focused whilst outside widget. + widget.addItems(2, ["11-add"]); + assertState( + [ + { text: "8-add" }, + { text: "10-add", selected: true, focused: true }, + { text: "11-add" }, + ], + "10-add is focused and selected" + ); + stepFocus(false, { element: before }); + + widget.removeItems(1, 1); + assertFocus({ element: before }, "Focus remains outside widget"); + assertState( + [{ text: "8-add" }, { text: "11-add", selected: true }], + "11-add becomes selected" + ); + + stepFocus(true, { index: 1 }, "11-add becomes focused"); + assertState( + [{ text: "8-add" }, { text: "11-add", selected: true, focused: true }], + "11-add is selected" + ); + + // With focus separate from selected. + if (model == "focus") { + continue; + } + + // Move selection to be before focus. + widget.addItems(2, ["12-add", "13-add", "14-add"]); + assertState( + [ + { text: "8-add" }, + { text: "11-add", selected: true, focused: true }, + { text: "12-add" }, + { text: "13-add" }, + { text: "14-add" }, + ], + "11-add is selected and focused" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "8-add" }, + { text: "11-add", selected: true }, + { text: "12-add", focused: true }, + { text: "13-add" }, + { text: "14-add" }, + ], + "Selection before focus" + ); + + // Remove focused, but not selected. + widget.removeItems(2, 1); + assertState( + [ + { text: "8-add" }, + { text: "11-add", selected: true }, + { text: "13-add", focused: true }, + { text: "14-add" }, + ], + "Focus moves to 13-add, but selection is the same" + ); + + // Remove focused and selected. + widget.removeItems(1, 2); + assertState( + [{ text: "8-add" }, { text: "14-add", selected: true, focused: true }], + "Focus moves to 14-add and becomes selected" + ); + + // Restore selection before focus. + widget.addItems(0, ["15-add"]); + assertState( + [ + { text: "15-add" }, + { text: "8-add" }, + { text: "14-add", selected: true, focused: true }, + ], + "14-add has focus and selection" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertState( + [ + { text: "15-add" }, + { text: "8-add", selected: true, focused: true }, + { text: "14-add" }, + ], + "8-add is focused and selected" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "15-add" }, + { text: "8-add", selected: true }, + { text: "14-add", focused: true }, + ], + "Selection before focus again" + ); + + // Remove selected, but not focused. + widget.removeItems(1, 1); + assertState( + [{ text: "15-add" }, { text: "14-add", focused: true }], + "14-add still has focus, but selection is lost" + ); + + // Move selection to be after focus. + widget.addItems(1, ["16-add", "17-add"]); + widget.addItems(4, ["18-add"]); + assertState( + [ + { text: "15-add" }, + { text: "16-add" }, + { text: "17-add" }, + { text: "14-add", focused: true }, + { text: "18-add" }, + ], + "Still no selection" + ); + // Select focused. + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 3 }, "14-add has focus"); + assertSelection([3], "14-add is selected"); + assertState( + [ + { text: "15-add" }, + { text: "16-add" }, + { text: "17-add" }, + { text: "14-add", selected: true, focused: true }, + { text: "18-add" }, + ], + "14-add is selected and focused" + ); + // Move focus. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "15-add" }, + { text: "16-add", focused: true }, + { text: "17-add" }, + { text: "14-add", selected: true }, + { text: "18-add" }, + ], + "Selection after focus" + ); + + // Remove focused, but not selected. + widget.removeItems(1, 1); + assertState( + [ + { text: "15-add" }, + { text: "17-add", focused: true }, + { text: "14-add", selected: true }, + { text: "18-add" }, + ], + "Focus moves to 17-add, selection stays on 14-add" + ); + + // Remove focused and selected. + widget.removeItems(1, 2); + assertState( + [{ text: "15-add" }, { text: "18-add", selected: true, focused: true }], + "Focus and selection moves to 18-add" + ); + + // Restore selection after focus. + widget.addItems(2, ["19-add", "20-add"]); + assertState( + [ + { text: "15-add" }, + { text: "18-add", selected: true, focused: true }, + { text: "19-add" }, + { text: "20-add" }, + ], + "Still no selection" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertState( + [ + { text: "15-add" }, + { text: "18-add" }, + { text: "19-add", selected: true, focused: true }, + { text: "20-add" }, + ], + "19-add focused and selected" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "15-add" }, + { text: "18-add", focused: true }, + { text: "19-add", selected: true }, + { text: "20-add" }, + ], + "Selection after focus again" + ); + + // Remove selected, but not focused. + widget.removeItems(2, 2); + assertState( + [{ text: "15-add" }, { text: "18-add", focused: true }], + "18-add still has focus, but selection is lost" + ); + + // With multi-selection + if (model == "browse") { + continue; + } + + widget.addItems(0, ["21-add", "22-add", "23-add"]); + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", focused: true }, + ], + "18-add focused, no selection yet" + ); + widget.addItems(5, [ + "24-add", + "25-add", + "26-add", + "27-add", + "28-add", + "29-add", + "30-add", + "31-add", + ]); + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", focused: true }, + { text: "24-add" }, + { text: "25-add" }, + { text: "26-add" }, + { text: "27-add" }, + { text: "28-add" }, + { text: "29-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "18-add focused, no selection yet" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", selected: true }, + { text: "24-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "29-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Forward range selection from 18-add to 26-add" + ); + + // Delete after the selection range + widget.removeItems(10, 1); + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", selected: true }, + { text: "24-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Same range selection" + ); + + // Delete before the selection range. + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", selected: true }, + { text: "24-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Same range selection" + ); + + // Delete the start of the selection range. + widget.removeItems(2, 3); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 26-add" + ); + + // Selection pivot is now around 25-add. + EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win); + assertState( + [ + { text: "21-add", selected: true, focused: true }, + { text: "23-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add" }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 21-add" + ); + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true }, + { text: "27-add", selected: true }, + { text: "28-add", selected: true }, + { text: "30-add", selected: true }, + { text: "31-add", selected: true, focused: true }, + ], + "Selection range from 25-add to 31-add" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true }, + { text: "27-add", selected: true }, + { text: "28-add", selected: true, focused: true }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 28-add" + ); + + // Delete the end of the selection. + // As a special case, the focus moves to the end of the selection, rather + // than to the next item. + widget.removeItems(4, 2); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 26-add" + ); + + // Do same with a gap. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true }, + { text: "30-add", selected: true, focused: true }, + { text: "31-add" }, + ], + "Continue selection range from 25-add to 30-add" + ); + + widget.addItems(3, ["32-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add" }, + { text: "26-add", selected: true }, + { text: "30-add", selected: true, focused: true }, + { text: "31-add" }, + ], + "Selection range from 25-add to 30-add with gap" + ); + + widget.removeItems(5, 1); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add" }, + { text: "26-add", selected: true, focused: true }, + { text: "31-add" }, + ], + "Focus moves to the end of the range, after the gap" + ); + + // Do the same with a gap and all items after the gap are removed. + widget.addItems(6, ["33-add", "34-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add" }, + { text: "26-add", selected: true, focused: true }, + { text: "31-add" }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Added 33-add and 34-add" + ); + + clickWidgetItem(5, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true }, + { text: "26-add", selected: true }, + { text: "31-add", selected: true, focused: true }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Selection extended to 31-add and gap filled" + ); + + widget.addItems(4, ["35-add", "36-add", "37-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true }, + { text: "35-add" }, + { text: "36-add" }, + { text: "37-add" }, + { text: "26-add", selected: true }, + { text: "31-add", selected: true, focused: true }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Selection from 25-add to 31-add with gap" + ); + + widget.removeItems(6, 3); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true, focused: true }, + { text: "35-add" }, + { text: "36-add" }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Focus jumps gap to what is left of the selection range" + ); + + // Same, with entire gap also removed. + clickWidgetItem(6, { shiftKey: true }); + widget.addItems(5, ["38-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true }, + { text: "35-add", selected: true }, + { text: "38-add" }, + { text: "36-add", selected: true }, + { text: "33-add", selected: true, focused: true }, + { text: "34-add" }, + ], + "Selection from 25-add to 33-add with gap" + ); + + widget.removeItems(4, 4); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true, focused: true }, + { text: "34-add" }, + ], + "Focus moves to end of what is left of the selection range" + ); + + // Test deleting the end of the selection with focus in a gap. + // We don't expect to follow the special treatment because the user has + // explicitly moved the focus "outside" of the selected range. + widget.addItems(3, ["39-add"]); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "39-add", focused: true }, + { text: "32-add", selected: true }, + { text: "34-add" }, + ], + "Focus in range gap" + ); + + widget.removeItems(3, 2); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "34-add", focused: true }, + ], + "Focus moves from gap to 34-add, outside the selection range" + ); + + // Same, but deleting the start of the range. + widget.addItems(4, ["40-add", "41-add", "42-add"]); + clickWidgetItem(5, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true, focused: true }, + { text: "42-add" }, + ], + "Selection from 25-add to 41-add" + ); + + widget.addItems(3, ["43-add", "44-add", "45-add"]); + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "43-add", focused: true }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "42-add" }, + ], + "Focus in gap" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "44-add", focused: true }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "42-add" }, + ], + "Focus moves to next item, rather than the selection start" + ); + + // Test deleting the end of the selection with the focus towards the end of + // the range. + widget.addItems(7, ["46-add", "47-add", "48-add", "49-add", "50-add"]); + clickWidgetItem(7, { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "42-add", selected: true, focused: true }, + { text: "46-add", selected: true }, + { text: "47-add" }, + { text: "48-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Range selection from 34-add to 46-add, with focus on 42-add" + ); + + widget.removeItems(6, 2); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true, focused: true }, + { text: "47-add" }, + { text: "48-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Focus still moves to the end of the selection" + ); + + // Test deleting with focus after the end of the range. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "47-add", focused: true }, + { text: "48-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Focus still moves to the end of the selection" + ); + + widget.removeItems(5, 2); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "48-add", focused: true }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Focus remains outside the range" + ); + + // Test deleting with focus in the middle of the range, and end of the range + // is not deleted. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true, focused: true }, + { text: "48-add", selected: true }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus in the middle of the range" + ); + + widget.removeItems(4, 1); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "48-add", selected: true, focused: true }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus moves to next item, rather than the end of the range" + ); + + // With focus just before a gap. + widget.addItems(5, ["51-add", "52-add"]); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "48-add", selected: true, focused: true }, + { text: "51-add" }, + { text: "52-add" }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus just before a gap" + ); + + widget.removeItems(4, 1); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "51-add", focused: true }, + { text: "52-add" }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus moves forward into the gap" + ); + + // Selection pivot is about 34-add + clickWidgetItem(1, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add", selected: true, focused: true }, + { text: "45-add", selected: true }, + { text: "34-add", selected: true }, + { text: "51-add" }, + { text: "52-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Selection from 34-add backward to 44-add" + ); + clickWidgetItem(5, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "51-add", selected: true }, + { text: "52-add", selected: true, focused: true }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Selection from 34-add forward to 52-add" + ); + + // Delete the whole range. + widget.removeItems(3, 3); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "49-add", selected: true, focused: true }, + { text: "50-add" }, + ], + "Focus and selection moves to after the selection range" + ); + + // Do the same with focus outside the range. + widget.addItems(5, ["53-add", "54-add", "55-add"]); + EventUtils.synthesizeKey("KEY_Home", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + clickWidgetItem(2, { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "44-add", selected: true }, + { text: "45-add", selected: true }, + { text: "49-add", focused: true }, + { text: "50-add" }, + { text: "53-add" }, + { text: "54-add" }, + { text: "55-add" }, + ], + "Focus outside selection" + ); + + widget.removeItems(1, 2); + assertState( + [ + { text: "21-add" }, + { text: "49-add", focused: true }, + { text: "50-add" }, + { text: "53-add" }, + { text: "54-add" }, + { text: "55-add" }, + ], + "Focus remains on same item and unselected" + ); + + // Do the same, but the focus is also removed. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "49-add", selected: true }, + { text: "50-add", selected: true }, + { text: "53-add", focused: true }, + { text: "54-add" }, + { text: "55-add" }, + ], + "Focus outside selection" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "54-add", selected: true, focused: true }, + { text: "55-add" }, + ], + "Focus and selection moves to 49-add" + ); + + // * Do the same tests but with selection travelling backwards. * + widget.addItems(3, [ + "56-add", + "57-add", + "58-add", + "59-add", + "60-add", + "61-add", + "62-add", + "63-add", + "64-add", + "65-add", + "66-add", + ]); + assertState( + [ + { text: "21-add" }, + { text: "54-add", selected: true, focused: true }, + { text: "55-add" }, + { text: "56-add" }, + { text: "57-add" }, + { text: "58-add" }, + { text: "59-add" }, + { text: "60-add" }, + { text: "61-add" }, + { text: "62-add" }, + { text: "63-add" }, + { text: "64-add" }, + { text: "65-add" }, + { text: "66-add" }, + ], + "Same selection and focus" + ); + EventUtils.synthesizeKey("KEY_End", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "55-add" }, + { text: "56-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "62-add", selected: true }, + { text: "63-add" }, + { text: "64-add" }, + { text: "65-add" }, + { text: "66-add" }, + ], + "Backward range selection from 62-add to 57-add" + ); + + // Delete after the selection range + widget.removeItems(11, 2); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "55-add" }, + { text: "56-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "62-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Same range selection" + ); + + // Delete before the selection range. + widget.removeItems(2, 2); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "62-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Same range selection" + ); + + // Delete the end of the selection range. + widget.removeItems(7, 1); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 57-add" + ); + + // Selection pivot is now around 61-add. + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add" }, + { text: "58-add" }, + { text: "59-add" }, + { text: "60-add" }, + { text: "61-add", selected: true }, + { text: "63-add", selected: true }, + { text: "66-add", selected: true, focused: true }, + ], + "Selection range forwards from 61-add to 66-add" + ); + EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win); + assertState( + [ + { text: "21-add", selected: true, focused: true }, + { text: "54-add", selected: true }, + { text: "57-add", selected: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 21-add" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 57-add" + ); + + // Delete the start of the selection. + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "59-add", selected: true, focused: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range shrinks to 59-add" + ); + + // Do the same with a gap after the focus and its next item. + widget.addItems(3, ["67-add", "68-add", "69-add"]); + assertState( + [ + { text: "21-add" }, + { text: "59-add", selected: true, focused: true }, + { text: "60-add", selected: true }, + { text: "67-add" }, + { text: "68-add" }, + { text: "69-add" }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 59-add with gap" + ); + + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add" }, + { text: "60-add", selected: true, focused: true }, + { text: "67-add" }, + { text: "68-add" }, + { text: "69-add" }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, before gap" + ); + + // Do the same with a gap and all items before the gap are removed. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "60-add" }, + { text: "67-add", selected: true, focused: true }, + { text: "68-add", selected: true }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward reduced to 67-add with gap filled" + ); + widget.addItems(4, ["70-add", "71-add"]); + assertState( + [ + { text: "21-add" }, + { text: "60-add" }, + { text: "67-add", selected: true, focused: true }, + { text: "68-add", selected: true }, + { text: "70-add" }, + { text: "71-add" }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward from 61-add to 67-add with gap" + ); + + widget.removeItems(2, 2); + assertState( + [ + { text: "21-add" }, + { text: "60-add" }, + { text: "70-add" }, + { text: "71-add" }, + { text: "69-add", selected: true, focused: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus jumps gap to selection range" + ); + + // Same, with entire gap also removed. + clickWidgetItem(1, { shiftKey: true }); + widget.addItems(2, ["72-add"]); + assertState( + [ + { text: "21-add" }, + { text: "60-add", selected: true, focused: true }, + { text: "72-add" }, + { text: "70-add", selected: true }, + { text: "71-add", selected: true }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 60-add to 70-add" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "71-add", selected: true, focused: true }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the start of what is left of the selection range" + ); + + // Test deleting the start of the selection with focus in a gap. + // We don't expect to follow the special treatment because the user has + // explicitly moved the focus "outside" of the selected range. + widget.addItems(2, ["73-add", "74-add", "75-add"]); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "71-add", selected: true }, + { text: "73-add", focused: true }, + { text: "74-add" }, + { text: "75-add" }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus in range gap" + ); + + widget.removeItems(1, 2); + assertState( + [ + { text: "21-add" }, + { text: "74-add", focused: true }, + { text: "75-add" }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, rather than the selection range" + ); + + // Same, but deleting the end of the range. + clickWidgetItem(1, { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "69-add", selected: true, focused: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward from 61-add to 74-add, with focus shifted" + ); + widget.addItems(3, ["76-add", "77-add"]); + assertState( + [ + { text: "21-add" }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "69-add", selected: true, focused: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus in gap" + ); + + widget.removeItems(5, 2); + assertState( + [ + { text: "21-add" }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "63-add", focused: true }, + { text: "66-add" }, + ], + "Focus moves to next item, rather than selection range end" + ); + + // Selection pivot now about 75-add. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "74-add" }, + { text: "75-add", selected: true }, + { text: "76-add", selected: true }, + { text: "77-add", selected: true, focused: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range forward from 75-add to 77-add" + ); + + widget.addItems(1, ["78-add", "79-add", "80-add"]); + clickWidgetItem(2, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "78-add" }, + { text: "79-add", selected: true, focused: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward from 75-add to 79-add" + ); + + // Move focus to the end of the range and delete again, but with no gap this + // time. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "78-add" }, + { text: "79-add", selected: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true, focused: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moved to the end of the selection" + ); + + widget.removeItems(5, 2); + assertState( + [ + { text: "21-add" }, + { text: "78-add" }, + { text: "79-add", selected: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add", focused: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moved to the next item rather than the selection start" + ); + + // Deleting with focus before the selection start. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "78-add", focused: true }, + { text: "79-add", selected: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus before the selection start" + ); + + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add" }, + { text: "79-add", selected: true, focused: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, which happens to be in the selection range" + ); + + // Test deleting with focus in the middle of the range. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + assertState( + [ + { text: "21-add", selected: true }, + { text: "79-add", selected: true, focused: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 74-add to 21-add, with focus in middle" + ); + + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add", selected: true }, + { text: "80-add", selected: true, focused: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, rather than the selection start or end" + ); + + // Delete the whole range. + widget.removeItems(0, 4); + assertState( + [{ text: "63-add", selected: true, focused: true }, { text: "66-add" }], + "Focus and selection move to the next remaining item" + ); + + // Do the same with focus outside the range. + widget.addItems(0, ["81-add", "82-add", "83-add", "84-add", "85-add"]); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "81-add" }, + { text: "82-add" }, + { text: "83-add" }, + { text: "84-add", focused: true }, + { text: "85-add", selected: true }, + { text: "63-add", selected: true }, + { text: "66-add" }, + ], + "Focus outside backward selection range" + ); + + widget.removeItems(4, 2); + assertState( + [ + { text: "81-add" }, + { text: "82-add" }, + { text: "83-add" }, + { text: "84-add", focused: true }, + { text: "66-add" }, + ], + "Focus remains the same and is not selected" + ); + + // Same, but with focus also removed. + widget.addItems(5, ["86-add"]); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "81-add" }, + { text: "82-add", focused: true }, + { text: "83-add", selected: true }, + { text: "84-add", selected: true }, + { text: "66-add" }, + { text: "86-add" }, + ], + "Focus outside backwards selection range" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "81-add" }, + { text: "66-add", selected: true, focused: true }, + { text: "86-add" }, + ], + "Focus moves to next item and selected" + ); + + // With multi-selection via toggling. + + widget.addItems(3, [ + "87-add", + "88-add", + "89-add", + "90-add", + "91-add", + "92-add", + "93-add", + "94-add", + ]); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "81-add" }, + { text: "66-add" }, + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add", selected: true }, + { text: "89-add", selected: true }, + { text: "90-add", selected: true, focused: true }, + { text: "91-add" }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Start with range selection forward from 87-add to 90-add" + ); + + clickWidgetItem(4, { ctrlKey: true }); + clickWidgetItem(5, { ctrlKey: true }); + assertState( + [ + { text: "81-add" }, + { text: "66-add" }, + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add", focused: true }, + { text: "90-add", selected: true }, + { text: "91-add" }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Range selection from 87-add to 90-add, with 88-add and 89-add unselected" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + EventUtils.synthesizeKey(" ", { ctrlKey: true }); + assertState( + [ + { text: "81-add" }, + { text: "66-add" }, + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "91-add", selected: true, focused: true }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Mixed selection" + ); + + // Remove before the selected items + widget.removeItems(0, 2); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "91-add", selected: true, focused: true }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Same selection and focus" + ); + + // Remove after + widget.removeItems(6, 1); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "91-add", selected: true, focused: true }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Same selection and focus" + ); + + // Removed the focused item, unlike a simple range selection, the focused + // item is not bound to stay within the selected items. + widget.removeItems(5, 1); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "93-add", focused: true }, + { text: "94-add" }, + ], + "Focus moves to next item and not selected" + ); + + // Remove the unselected items, merging the two ranges together. + widget.removeItems(2, 2); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "90-add", selected: true }, + { text: "93-add", focused: true }, + { text: "94-add" }, + ], + "Focus remains the same" + ); + + // Remove the selected items. + widget.removeItems(1, 2); + assertState( + [ + { text: "86-add" }, + { text: "93-add", focused: true }, + { text: "94-add" }, + ], + "Focus remains the same, and not selected" + ); + + // Remove all selected items, including the focused item. + widget.addItems(0, [ + "95-add", + "96-add", + "97-add", + "98-add", + "99-add", + "100-add", + "101-add", + ]); + EventUtils.synthesizeKey(" ", {}, win); + assertState( + [ + { text: "95-add" }, + { text: "96-add" }, + { text: "97-add" }, + { text: "98-add" }, + { text: "99-add" }, + { text: "100-add" }, + { text: "101-add" }, + { text: "86-add" }, + { text: "93-add", selected: true, focused: true }, + { text: "94-add" }, + ], + "Single selection" + ); + + clickWidgetItem(6, { ctrlKey: true }); + clickWidgetItem(5, { ctrlKey: true }); + assertState( + [ + { text: "95-add" }, + { text: "96-add" }, + { text: "97-add" }, + { text: "98-add" }, + { text: "99-add" }, + { text: "100-add", selected: true, focused: true }, + { text: "101-add", selected: true }, + { text: "86-add" }, + { text: "93-add", selected: true }, + { text: "94-add" }, + ], + "Mixed selection with focus selected" + ); + + widget.removeItems(5, 4); + assertState( + [ + { text: "95-add" }, + { text: "96-add" }, + { text: "97-add" }, + { text: "98-add" }, + { text: "99-add" }, + { text: "94-add", selected: true, focused: true }, + ], + "Focus moves to next item and selected" + ); + + // Remove all selected, with focus outside the selection + clickWidgetItem(1, {}); + clickWidgetItem(3, { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }); + assertState( + [ + { text: "95-add" }, + { text: "96-add", selected: true }, + { text: "97-add", focused: true }, + { text: "98-add", selected: true }, + { text: "99-add" }, + { text: "94-add" }, + ], + "Mixed selection with focus not selected" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "95-add" }, + { text: "99-add", selected: true, focused: true }, + { text: "94-add" }, + ], + "Focus moves to next item and selected" + ); + + // With select all. + widget.addItems(0, ["102-add", "103-add", "104-add"]); + widget.addItems(6, ["105-add", "106-add"]); + selectAllShortcut(); + assertState( + [ + { text: "102-add", selected: true }, + { text: "103-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "99-add", selected: true, focused: true }, + { text: "94-add", selected: true }, + { text: "105-add", selected: true }, + { text: "106-add", selected: true }, + ], + "All selected" + ); + + // Remove middle and focused. + widget.removeItems(4, 1); + assertState( + [ + { text: "102-add", selected: true }, + { text: "103-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "94-add", selected: true, focused: true }, + { text: "105-add", selected: true }, + { text: "106-add", selected: true }, + ], + "Focus moves to the next item, selections remain" + ); + + // Remove before focused. + widget.removeItems(1, 1); + assertState( + [ + { text: "102-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "94-add", selected: true, focused: true }, + { text: "105-add", selected: true }, + { text: "106-add", selected: true }, + ], + "Focus and selection remain" + ); + + // Remove after the focus. + widget.removeItems(4, 2); + assertState( + [ + { text: "102-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "94-add", selected: true, focused: true }, + ], + "Focus and selection remain" + ); + + // Remove end and focused. + widget.removeItems(3, 1); + assertState( + [ + { text: "102-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true, focused: true }, + ], + "Focus moves to the last item, selection remains" + ); + + // Remove start and focused. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertState( + [ + { text: "102-add", selected: true, focused: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + ], + "Focus on first item" + ); + + widget.removeItems(0, 1); + assertState( + [ + { text: "104-add", selected: true, focused: true }, + { text: "95-add", selected: true }, + ], + "Focus moves to next item" + ); + + // Remove items to cause two distinct ranges to merge together, with + // in-between ranges removed. + widget.addItems(2, [ + "107-add", + "108-add", + "109-add", + "110-add", + "111-add", + "112-add", + "113-add", + ]); + clickWidgetItem(0, { ctrlKey: true }); + assertState( + [ + { text: "104-add", focused: true }, + { text: "95-add", selected: true }, + { text: "107-add" }, + { text: "108-add" }, + { text: "109-add" }, + { text: "110-add" }, + { text: "111-add" }, + { text: "112-add" }, + { text: "113-add" }, + ], + "Added items and de-selected 104-add" + ); + + clickWidgetItem(3, { ctrlKey: true }); + clickWidgetItem(4, { ctrlKey: true }); + clickWidgetItem(6, { ctrlKey: true }); + clickWidgetItem(8, { ctrlKey: true }); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "107-add" }, + { text: "108-add", selected: true }, + { text: "109-add", selected: true }, + { text: "110-add" }, + { text: "111-add", selected: true }, + { text: "112-add" }, + { text: "113-add", selected: true, focused: true }, + ], + "Several selection ranges" + ); + + widget.removeItems(2, 6); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "113-add", selected: true, focused: true }, + ], + "End ranges merged together" + ); + + // Do the same, but where parts of the end ranges are also removed. + widget.addItems(3, [ + "114-add", + "115-add", + "116-add", + "117-add", + "118-add", + "119-add", + "120-add", + "121-add", + "122-add", + "123-add", + "124-add", + "125-add", + "126-add", + "127-add", + ]); + clickWidgetItem(4, { ctrlKey: true }); + clickWidgetItem(5, { ctrlKey: true }); + clickWidgetItem(7, { ctrlKey: true }); + clickWidgetItem(10, { ctrlKey: true }); + clickWidgetItem(12, { ctrlKey: true }); + clickWidgetItem(13, { ctrlKey: true }); + clickWidgetItem(14, { ctrlKey: true }); + clickWidgetItem(16, { ctrlKey: true }); + + clickWidgetItem(9, { ctrlKey: true }); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "113-add", selected: true }, + { text: "114-add" }, + { text: "115-add", selected: true }, + { text: "116-add", selected: true }, + { text: "117-add" }, + { text: "118-add", selected: true }, + { text: "119-add" }, + { text: "120-add", selected: true, focused: true }, + { text: "121-add", selected: true }, + { text: "122-add" }, + { text: "123-add", selected: true }, + { text: "124-add", selected: true }, + { text: "125-add", selected: true }, + { text: "126-add" }, + { text: "127-add", selected: true }, + ], + "Several ranges" + ); + + widget.removeItems(5, 8); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "113-add", selected: true }, + { text: "114-add" }, + { text: "115-add", selected: true }, + { text: "124-add", selected: true, focused: true }, + { text: "125-add", selected: true }, + { text: "126-add" }, + { text: "127-add", selected: true }, + ], + "Two ranges merged and rest removed, focus moves to next item" + ); + } +}); + +// If widget is emptied whilst focused, focus moves to widget. +add_task(function test_emptying_widget() { + for (let model of selectionModels) { + // Empty with focused widget. + reset({ model }); + stepFocus(true, { element: widget }, "Initial"); + widget.addItems(0, ["First", "Second"]); + assertFocus({ element: widget }, "Focus still on widget after adding"); + widget.removeItems(0, 2); + assertFocus({ element: widget }, "Focus still on widget after removing"); + + // Empty with focused item. + widget.addItems(0, ["First", "Second"]); + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "Focus on first item"); + widget.removeItems(0, 2); + assertFocus({ element: widget }, "Focus moves to widget after removing"); + + // Empty with focus elsewhere. + widget.addItems(0, ["First", "Second"]); + stepFocus(false, { element: before }, "Focus elsewhere"); + widget.removeItems(0, 2); + assertFocus({ element: before }, "Focus still elsewhere after removing"); + stepFocus(true, { element: widget }, "Widget becomes focused"); + + // Empty with focus elsewhere, but active item. + widget.addItems(0, ["First", "Second"]); + // Move away from and back to widget to focus second item. + stepFocus(true, { element: after }, "Focus elsewhere"); + widget.selectSingleItem(1); + stepFocus(false, { index: 1 }, "Focus on second item"); + stepFocus(false, { element: before }, "Return focus to elsewhere"); + widget.removeItems(0, 2); + assertFocus({ element: before }, "Focus still elsewhere after removing"); + stepFocus(true, { element: widget }, "Widget becomes focused"); + } +}); + +/** + * Test moving items in the widget. + * + * @param {string} model - The selection model to use. + * @param {boolean} reCreate - Whether the widget should reCreate the items when + * moving them. + */ +function subtest_move_items(model, reCreate) { + reset({ model, direction: "right-to-left" }); + + widget.addItems(0, [ + "0-add", + "1-add", + "2-add", + "3-add", + "4-add", + "5-add", + "6-add", + "7-add", + "8-add", + "9-add", + "10-add", + "11-add", + "12-add", + "13-add", + ]); + clickWidgetItem(5, {}); + + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Item 5 selected and focused" + ); + + // Move items before focus. + widget.moveItems(4, 3, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + widget.moveItems(1, 3, 2, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move items after focus. + widget.moveItems(6, 8, 2, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "7-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + widget.moveItems(9, 6, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move from before focus to after focus. + widget.moveItems(2, 3, 3, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection, but moved" + ); + + // Move from after focus to before focus. + widget.moveItems(3, 2, 5, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection, but moved" + ); + + // Move selected and focused up. + widget.moveItems(7, 3, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved to index 3" + ); + + // Move down. + widget.moveItems(3, 5, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved to index 5" + ); + + // Move in a group. + widget.moveItems(4, 5, 3, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "8-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved to index 6" + ); + widget.moveItems(5, 4, 3, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved back to index 5" + ); + + // With focus split from selection. + if (model == "focus") { + return; + } + + // Focus before selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus before selection" + ); + + // Move before both. + widget.moveItems(0, 1, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "3-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move after both. + widget.moveItems(8, 7, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "3-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "9-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move focus to after selected. + widget.moveItems(3, 6, 2, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "3-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus moved to after selection" + ); + + // Move focus before selected. + widget.moveItems(5, 2, 3, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "3-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus moved to before selection" + ); + + // Move selection before focus. + widget.moveItems(5, 1, 5, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selected moved to before focus" + ); + + // Move selection after focus. + widget.moveItems(2, 8, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "5-add", selected: true }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selected moved to after focus" + ); + + // Navigation still works. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "0-add" }, + { text: "9-add", focused: true }, + { text: "1-add" }, + { text: "5-add", selected: true }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selected moved to after focus" + ); + + // Test with multi-selection. + if (model == "browse") { + return; + } + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add" }, + { text: "5-add" }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 9-add to 6-add" + ); + + // Move non-selected into the middle of the selected. + widget.moveItems(8, 5, 2, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true, focused: true }, + { text: "5-add" }, + { text: "2-add" }, + { text: "0-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Non-selected gap" + ); + + // Moving an item always ends a Shift range selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "5-add" }, + { text: "2-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 6-add to 8-add" + ); + + clickWidgetItem(9, { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "2-add", selected: true }, + { text: "0-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true, focused: true }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 6-add to 1-add" + ); + + // Move selected to middle of selected. + widget.moveItems(8, 6, 2, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true, focused: true }, + { text: "2-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selection block" + ); + + // Also ends a Shift range selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 1-add to 5-add" + ); + + // Move from start of selection to end. + widget.moveItems(5, 7, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true }, + { text: "5-add", selected: true, focused: true }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Moved to end" + ); + + // And reverse. + widget.moveItems(7, 5, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Moved back to start" + ); + + // Also broke Shift range selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add", selected: true, focused: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 5-add to 3-add" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + + assertState( + [ + { text: "4-add" }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + ], + "Multi-selection" + ); + + // Move selected with gap into middle of a selection block. + widget.moveItems(8, 4, 6, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together on both sides" + ); + + // Move selected with gap to start of a selection block. + widget.moveItems(5, 1, 5, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at start" + ); + + // Move selected with gap to end of a selection block. + widget.moveItems(1, 4, 8, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at end" + ); + + // Move block with non-selected boundaries into middle of selected. + widget.moveItems(5, 3, 6, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "8-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Split range block" + ); + + // Move block with selected at start into middle of selected. + widget.moveItems(1, 6, 5, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at start" + ); + + // Move block with selected at end into middle of selected. + widget.moveItems(8, 6, 4, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at end" + ); + + // Move selected into non-selected region and move to start. + widget.moveItems(4, 0, 6, reCreate); + assertState( + [ + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "4-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at end" + ); + + // Remove gap between two selections and move to end. + widget.moveItems(2, 9, 5, reCreate); + assertState( + [ + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "4-add" }, + ], + "Merged ranges together" + ); + + // Navigation still works. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertState( + [ + { text: "5-add" }, + { text: "0-add" }, + { text: "13-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "2-add" }, + { text: "6-add", selected: true, focused: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "8-add" }, + { text: "4-add" }, + ], + "Move by one index and single select" + ); +} + +// Moving items in the widget will move focus and selection with the moved +// items. +add_task(function test_move_items() { + for (let model of selectionModels) { + // We want to be sure the methods work with or without re-creating the + // item elements. + subtest_move_items(model, false); + subtest_move_items(model, true); + } +}); + +// Test that dragging is possible. +add_task(function test_can_drag_items() { + /** + * Assert that dragging can occur and takes place with the expected selection. + * + * @param {number} index - The index of the item to start dragging on. We also + * expect this item to have focus during and after dragging. + * @param {number[]} selection - The expected selection during and after + * dragging. + * @param {string} msg - A message to use in assertions. + */ + function assertDragstart(index, selection, msg) { + let element = widget.items[index].element; + let eventFired = false; + + let dragstartListener = event => { + eventFired = true; + Assert.ok( + element.contains(event.target), + `Item ${index} contains the dragstart target` + ); + assertFocus({ index }, `Item ${index} has focus in dragstart: ${msg}`); + assertSelection(selection, `Selection in dragstart: ${msg}`); + }; + widget.addEventListener("dragstart", dragstartListener, true); + + // Synthesize the start of a drag. + let rect = element.getBoundingClientRect(); + let x = rect.left + rect.width / 2; + let y = rect.top + rect.height / 2; + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win); + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousemove" }, win); + EventUtils.synthesizeMouseAtPoint(x, y + 60, { type: "mousemove" }, win); + // Don't care about ending the drag. + + Assert.ok(eventFired, `dragstart event fired: ${msg}`); + widget.removeEventListener("dragstart", dragstartListener, true); + assertSelection(selection, `Same selection after dragging: ${msg}`); + assertFocus( + { index }, + `Item ${index} still has focus after dragging: ${msg}` + ); + } + + for (let model of selectionModels) { + reset({ model, draggable: true }); + widget.addItems(0, ["First", "Second", "Third"]); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([], "No initial selection"); + assertDragstart(1, [1], "First drag with no focus or selection"); + + assertDragstart(1, [1], "Already selected item"); + assertDragstart(2, [2], "Non-selected item"); + + reset({ model, draggable: true }); + widget.addItems(0, ["First", "Second", "Third"]); + widget.selectSingleItem(1); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([1], "Initial selection on item 1"); + assertDragstart(1, [1], "First drag on selected item"); + + reset({ model, draggable: true }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + widget.selectSingleItem(3); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([3], "Initial selection on item 3"); + assertDragstart(2, [2], "First drag on non-selected item"); + + // With focus split from selected. + if (model == "focus") { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Focus on item 3"); + assertSelection([2], "Item 2 is selected"); + assertDragstart(3, [3], "Non-selected but focused item"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus on item 2"); + assertSelection([3], "Item 3 is selected"); + assertDragstart(3, [3], "Selected but non-focused item"); + + // With mutli-selection. + if (model == "browse") { + continue; + } + + // Clicking a non-selected item will change to selection to the single item + // before dragging. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Focus on item 1"); + assertSelection([1, 3], "Multi selection"); + assertDragstart(2, [2], "Selection moves to item 2 before drag"); + + // Clicking a selected item will keep the same selection for dragging. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 4 }, "Focus on item 4"); + assertSelection([2, 4], "Multi selection"); + assertDragstart( + 4, + [2, 4], + "Selection same when dragging selected and focused" + ); + assertDragstart( + 2, + [2, 4], + "Selection same when dragging selected and non-focussed" + ); + } +}); diff --git a/comm/mail/base/test/browser/browser_smartFolderDelete.js b/comm/mail/base/test/browser/browser_smartFolderDelete.js new file mode 100644 index 0000000000..1c3f1fb59c --- /dev/null +++ b/comm/mail/base/test/browser/browser_smartFolderDelete.js @@ -0,0 +1,75 @@ +/* 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/. */ + +const { VirtualFolderHelper } = ChromeUtils.import( + "resource:///modules/VirtualFolderWrapper.jsm" +); + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const tabmail = document.getElementById("tabmail"); +const about3Pane = tabmail.currentAbout3Pane; + +let rootFolder; +let inboxFolder; + +add_setup(async function () { + MailServices.accounts.createLocalMailAccount(); + const account = MailServices.accounts.accounts[0]; + rootFolder = account.incomingServer.rootFolder; + rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder); + + // Set the active modes of the folder pane. In theory we only need the "smart" + // mode to test with, but in practice we also need the "all" mode to generate + // messages in folders. + about3Pane.folderPane.activeModes = ["all", "smart"]; + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + about3Pane.folderPane.activeModes = ["all"]; + }); +}); + +/** + * Test deleting a message from a smart folder using + * gDBView.applyCommandToIndices. + */ +add_task(async function testDeleteViaDBViewCommand() { + // Create an inbox folder. + const inboxFolder = rootFolder + .createLocalSubfolder("testDeleteInbox") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox); + + // Add a message to the folder. + const generator = new MessageGenerator(); + inboxFolder.addMessage(generator.makeMessage().toMboxString()); + + // Create a smart folder from the inbox. + const smartInboxFolder = getSmartServer().rootFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Inbox + ); + + // Switch the view to the smart folder. + about3Pane.displayFolder(smartInboxFolder.URI); + + // Get the DB view and tree view to use to send the command and observe its + // effect. + const dbView = about3Pane.gDBView; + const treeView = dbView.QueryInterface(Ci.nsITreeView); + + // Ensure we currently have one message. + Assert.equal(treeView.rowCount, 1, "should have one message before deleting"); + + // Delete the message using applyCommandToIndices. + dbView.applyCommandToIndices(Ci.nsMsgViewCommandType.deleteMsg, [0]); + + // Test that the message has been deleted. + await TestUtils.waitForCondition( + () => treeView.rowCount === 0, + "there should be no remaining message in the tree" + ); +}); diff --git a/comm/mail/base/test/browser/browser_spacesToolbar.js b/comm/mail/base/test/browser/browser_spacesToolbar.js new file mode 100644 index 0000000000..c2432a2131 --- /dev/null +++ b/comm/mail/base/test/browser/browser_spacesToolbar.js @@ -0,0 +1,1173 @@ +/* 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/. */ + +/** + * Test the spaces toolbar features. + */ + +/* globals gSpacesToolbar */ + +var folderA; +var folderB; +var testAccount; + +add_setup(function () { + // Set up two folders. + window.MailServices.accounts.createLocalMailAccount(); + testAccount = window.MailServices.accounts.accounts[0]; + let rootFolder = testAccount.incomingServer.rootFolder; + rootFolder.createSubfolder("spacesToolbarA", null); + folderA = rootFolder.findSubFolder("spacesToolbarA"); + rootFolder.createSubfolder("spacesToolbarB", null); + folderB = rootFolder.findSubFolder("spacesToolbarB"); +}); + +registerCleanupFunction(async () => { + window.MailServices.accounts.removeAccount(testAccount, true); + // Close all opened tabs. + let tabmail = document.getElementById("tabmail"); + tabmail.closeOtherTabs(tabmail.tabInfo[0]); + // Reset the spaces toolbar to its default visible state. + window.gSpacesToolbar.toggleToolbar(false); + // Reset the menubar visibility. + let menubar = document.getElementById("toolbar-menubar"); + menubar.removeAttribute("autohide"); + menubar.removeAttribute("inactive"); + await new Promise(resolve => requestAnimationFrame(resolve)); +}); + +async function assertMailShown(win = window) { + await TestUtils.waitForCondition( + () => + win.document.getElementById("tabmail").currentTabInfo.mode.name == + "mail3PaneTab", + "The mail tab should be visible" + ); +} + +async function assertAddressBookShown(win = window) { + await TestUtils.waitForCondition(() => { + let panel = win.document.querySelector( + // addressBookTabWrapper0, addressBookTabWrapper1, etc + "#tabpanelcontainer > [id^=addressBookTabWrapper][selected]" + ); + if (!panel) { + return false; + } + let browser = panel.querySelector("[id^=addressBookTabBrowser]"); + return browser.contentDocument.readyState == "complete"; + }, "The address book tab should be visible and loaded"); +} + +async function assertChatShown(win = window) { + await TestUtils.waitForCondition( + () => win.document.getElementById("chatTabPanel").hasAttribute("selected"), + "The chat tab should be visible" + ); +} + +async function assertCalendarShown(win = window) { + await TestUtils.waitForCondition(() => { + return ( + win.document + .getElementById("calendarTabPanel") + .hasAttribute("selected") && + !win.document.getElementById("calendar-view-box").collapsed + ); + }, "The calendar view should be visible"); +} + +async function assertTasksShown(win = window) { + await TestUtils.waitForCondition(() => { + return ( + win.document + .getElementById("calendarTabPanel") + .hasAttribute("selected") && + !win.document.getElementById("calendar-task-box").collapsed + ); + }, "The task view should be visible"); +} + +async function assertSettingsShown(win = window) { + await TestUtils.waitForCondition(() => { + let panel = win.document.querySelector( + // preferencesTabWrapper0, preferencesTabWrapper1, etc + "#tabpanelcontainer > [id^=preferencesTabWrapper][selected]" + ); + if (!panel) { + return false; + } + let browser = panel.querySelector("[id^=preferencesTabBrowser]"); + return browser.contentDocument.readyState == "complete"; + }, "The settings tab should be visible and loaded"); +} + +async function assertContentShown(url, win = window) { + await TestUtils.waitForCondition(() => { + let panel = win.document.querySelector( + // contentTabWrapper0, contentTabWrapper1, etc + "#tabpanelcontainer > [id^=contentTabWrapper][selected]" + ); + if (!panel) { + return false; + } + let doc = panel.querySelector("[id^=contentTabBrowser]").contentDocument; + return doc.URL == url && doc.readyState == "complete"; + }, `The selected content tab should show ${url}`); +} + +async function sub_test_cycle_through_primary_tabs() { + // We can't really cycle through all buttons and tabs with a simple for loop + // since some tabs are actual collapsing views and other tabs are separate + // pages. We can improve this once the new 3pane tab is actually a standalone + // tab. + + // Switch to address book. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("addressBookButton"), + {}, + window + ); + await assertAddressBookShown(); + + // Switch to calendar. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("calendarButton"), + {}, + window + ); + await assertCalendarShown(); + + // Switch to Mail. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("mailButton"), + {}, + window + ); + await assertMailShown(); + + // Switch to Tasks. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("tasksButton"), + {}, + window + ); + await assertTasksShown(); + + // Switch to chat. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("chatButton"), + {}, + window + ); + await assertChatShown(); + + // Switch to Settings. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("settingsButton"), + {}, + window + ); + await assertSettingsShown(); + + // Switch to Mail. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("mailButton"), + {}, + window + ); + await assertMailShown(); + + window.tabmail.closeOtherTabs(window.tabmail.tabInfo[0]); +} + +add_task(async function testSpacesToolbarVisibility() { + let spacesToolbar = document.getElementById("spacesToolbar"); + let toggleButton = document.getElementById("spacesToolbarReveal"); + let pinnedButton = document.getElementById("spacesPinnedButton"); + Assert.ok(spacesToolbar, "The spaces toolbar exists"); + + let assertVisibility = async function (isHidden, msg) { + await TestUtils.waitForCondition( + () => spacesToolbar.hidden == !isHidden, + `The spaces toolbar should be ${!isHidden ? "visible" : "hidden"}: ${msg}` + ); + + await TestUtils.waitForCondition( + () => toggleButton.hidden == isHidden, + `The toggle button should be ${isHidden ? "hidden" : "visible"}: ${msg}` + ); + + await TestUtils.waitForCondition( + () => pinnedButton.hidden == isHidden, + `The pinned button should be ${isHidden ? "hidden" : "visible"}: ${msg}` + ); + }; + + async function toggleVisibilityWithAppMenu(expectChecked) { + let appMenu = document.getElementById("appMenu-popup"); + let menuShownPromise = BrowserTestUtils.waitForEvent(appMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("button-appmenu"), + {}, + window + ); + await menuShownPromise; + + let viewShownPromise = BrowserTestUtils.waitForEvent( + appMenu.querySelector("#appMenu-viewView"), + "ViewShown" + ); + EventUtils.synthesizeMouseAtCenter( + appMenu.querySelector("#appmenu_View"), + {}, + window + ); + await viewShownPromise; + + let toolbarShownPromise = BrowserTestUtils.waitForEvent( + appMenu.querySelector("#appMenu-toolbarsView"), + "ViewShown" + ); + EventUtils.synthesizeMouseAtCenter( + appMenu.querySelector("#appmenu_Toolbars"), + {}, + window + ); + await toolbarShownPromise; + + let appMenuButton = document.getElementById("appmenu_spacesToolbar"); + Assert.equal( + appMenuButton.checked, + expectChecked, + `The app menu item should ${expectChecked ? "not " : ""}be checked` + ); + + EventUtils.synthesizeMouseAtCenter(appMenuButton, {}, window); + + // Close the appmenu. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("button-appmenu"), + {}, + window + ); + } + await assertVisibility(true, "on initial load"); + + // Collapse with a mouse click. + let activeElement = document.activeElement; + let collapseButton = document.getElementById("collapseButton"); + EventUtils.synthesizeMouseAtCenter(collapseButton, {}, window); + await assertVisibility(false, "after clicking collapse button"); + + await toggleVisibilityWithAppMenu(false); + await assertVisibility(true, "after revealing with the app menu"); + + // We already clicked the collapse button, so it should already be the + // focusButton for the gSpacesToolbar, and thus focusable. + collapseButton.focus(); + Assert.ok( + collapseButton.matches(":focus"), + "Collapse button should be focusable" + ); + + // Hide the spaces toolbar using the collapse button, which already has focus. + EventUtils.synthesizeKey(" ", {}, window); + await assertVisibility(false, "after closing with space key press"); + Assert.ok( + pinnedButton.matches(":focus"), + "Pinned button should be focused after closing with a key press" + ); + + // Show using the pinned button menu. + let pinnedMenu = document.getElementById("spacesButtonMenuPopup"); + let pinnedMenuShown = BrowserTestUtils.waitForEvent(pinnedMenu, "popupshown"); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await pinnedMenuShown; + pinnedMenu.activateItem(document.getElementById("spacesPopupButtonReveal")); + + await assertVisibility(true, "after opening with pinned menu"); + Assert.ok( + collapseButton.matches(":focus"), + "Collapse button should be focused again after showing with the pinned menu" + ); + + // Move focus to the mail button. + let mailButton = document.getElementById("mailButton"); + // Loop around from the collapse button to the mailButton. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + + Assert.ok( + mailButton.matches(":focus"), + "Mail button should become focused after pressing key down" + ); + Assert.ok( + spacesToolbar.matches(":focus-within"), + "Spaces toolbar should contain the focus" + ); + + // Now move focus elsewhere. + EventUtils.synthesizeKey("KEY_Tab", {}, window); + activeElement = document.activeElement; + Assert.ok( + !mailButton.matches(":focus"), + "Mail button should no longer be focused" + ); + Assert.ok( + !spacesToolbar.matches(":focus-within"), + "Spaces toolbar should no longer contain the focus" + ); + + // Hide the spaces toolbar using the app menu. + await toggleVisibilityWithAppMenu(true); + await assertVisibility(false, "after hiding with the app menu"); + + // macOS by default doesn't move the focus when clicking on toolbar buttons. + if (AppConstants.platform != "macosx") { + Assert.notEqual( + document.activeElement, + activeElement, + "The focus moved from the previous element" + ); + // Focus should be on the main app menu since we used the mouse to toggle the + // spaces toolbar. + Assert.equal( + document.activeElement, + document.getElementById("button-appmenu"), + "Active element is on the app menu" + ); + } else { + Assert.equal( + document.activeElement, + activeElement, + "The focus didn't move from the previous element" + ); + } + + // Now click the status bar toggle button to reveal the toolbar again. + toggleButton.focus(); + Assert.ok( + toggleButton.matches(":focus"), + "Toggle button should be focusable" + ); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await assertVisibility(true, "after showing with the toggle button"); + // Focus is restored to the mailButton. + Assert.ok( + mailButton.matches(":focus"), + "Mail button should become focused again" + ); + + // Clicked buttons open or move to the correct tab, starting with just one tab + // open. + await sub_test_cycle_through_primary_tabs(); +}); + +add_task(async function testSpacesToolbarContextMenu() { + let tabmail = document.getElementById("tabmail"); + let firstMailTabInfo = tabmail.currentTabInfo; + firstMailTabInfo.folder = folderB; + + // Fetch context menu elements. + let contextMenu = document.getElementById("spacesContextMenu"); + let newTabItem = document.getElementById("spacesContextNewTabItem"); + let newWindowItem = document.getElementById("spacesContextNewWindowItem"); + + let settingsMenu = document.getElementById("settingsContextMenu"); + let settingsItem = document.getElementById("settingsContextOpenSettingsItem"); + let accountItem = document.getElementById( + "settingsContextOpenAccountSettingsItem" + ); + let addonsItem = document.getElementById("settingsContextOpenAddonsItem"); + + /** + * Open the context menu, test its state, select an action and wait for it to + * close. + * + * @param {object} input - Input data. + * @param {Element} input.button - The button whose context menu should be + * opened. + * @param {Element} [input.item] - The context menu item to select. Either + * this or switchItem must be given. + * @param {number} [input.switchItem] - The nth switch-to-tab item to select. + * @param {object} expect - The expected state of the context menu when + * opened. + * @param {boolean} [expect.settings=false] - Whether we expect the settings + * context menu. If this is true, the other values are ignored. + * @param {boolean} [expect.newTab=false] - Whether we expect the "Open in new + * tab" item to be visible. + * @param {boolean} [expect.newWindow=false] - Whether we expect the "Open in + * new window" item to be visible. + * @param {number} [expect.numSwitch=0] - The expected number of switch-to-tab + * items. + * @param {string} msg - A message to use in tests. + */ + async function useContextMenu(input, expect, msg) { + let menu = expect.settings ? settingsMenu : contextMenu; + let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + input.button, + { type: "contextmenu" }, + window + ); + await shownPromise; + let item = input.item; + if (!expect.settings) { + Assert.equal( + BrowserTestUtils.is_visible(newTabItem), + expect.newTab || false, + `Open in new tab item visibility: ${msg}` + ); + Assert.equal( + BrowserTestUtils.is_visible(newWindowItem), + expect.newWindow || false, + `Open in new window item visibility: ${msg}` + ); + let switchItems = menu.querySelectorAll(".switch-to-tab"); + Assert.equal( + switchItems.length, + expect.numSwitch || 0, + `Should have the expected number of switch items: ${msg}` + ); + if (!item) { + item = switchItems[input.switchItem]; + } + } + let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + menu.activateItem(item); + await hiddenPromise; + } + + let tabScroll = document.getElementById("tabmail-arrowscrollbox").scrollbox; + /** + * Ensure the tab is scrolled into view. + * + * @param {MozTabmailTab} - The tab to scroll into view. + */ + async function scrollToTab(tab) { + function tabInView() { + let tabRect = tab.getBoundingClientRect(); + let scrollRect = tabScroll.getBoundingClientRect(); + return ( + tabRect.left >= scrollRect.left && tabRect.right <= scrollRect.right + ); + } + if (tabInView()) { + info(`Tab ${tab.label} already in view`); + return; + } + tab.scrollIntoView(); + await TestUtils.waitForCondition( + tabInView, + "Tab should be scrolled into view: " + tab.label + ); + info(`Tab ${tab.label} was scrolled into view`); + } + + let numTabs = 0; + /** + * Wait for and return the latest tab. + * + * This should be called every time a tab is created so the test can keep + * track of the expected number of tabs. + * + * @returns {MozTabmailTab} - The last tab. + */ + async function waitForNewTab() { + numTabs++; + let tabs; + await TestUtils.waitForCondition(() => { + tabs = document.querySelectorAll("tab.tabmail-tab"); + return tabs.length == numTabs; + }, `Waiting for ${numTabs} tabs`); + return tabs[numTabs - 1]; + } + + /** + * Close a tab and wait for it to close. + * + * This should be used alongside waitForNewTab so the test can keep track of + * the expected number of tabs. + * + * @param {MozTabmailTab} - The tab to close. + */ + async function closeTab(tab) { + numTabs--; + await scrollToTab(tab); + EventUtils.synthesizeMouseAtCenter( + tab.querySelector(".tab-close-button"), + {}, + window + ); + await TestUtils.waitForCondition( + () => document.querySelectorAll("tab.tabmail-tab").length == numTabs, + "Waiting for tab to close" + ); + } + + let toolbar = document.getElementById("spacesToolbar"); + /** + * Verify the current tab and space match. + * + * @param {MozTabmailTab} tab - The expected tab. + * @param {Element} spaceButton - The expected button to be shown as the + * current space in the spaces toolbar. + * @param {string} msg - A message to use in tests. + */ + async function assertTab(tab, spaceButton, msg) { + await TestUtils.waitForCondition( + () => tab.selected, + `Tab should be selected: ${msg}` + ); + let current = toolbar.querySelectorAll("button.current"); + Assert.equal(current.length, 1, `Should have one current space: ${msg}`); + Assert.equal( + current[0], + spaceButton, + `Current button ${current[0].id} should match: ${msg}` + ); + } + + /** + * Click on a tab and verify we have switched tabs and spaces. + * + * @param {MozTabmailTab} tab - The tab to click. + * @param {Element} spaceButton - The expected button to be shown as the + * current space after clicking the tab. + * @param {string} msg - A message to use in tests. + */ + async function switchTab(tab, spaceButton, msg) { + await scrollToTab(tab); + EventUtils.synthesizeMouseAtCenter(tab, {}, window); + await assertTab(tab, spaceButton, msg); + } + + // -- Test initial tab -- + + let mailButton = document.getElementById("mailButton"); + let firstTab = await waitForNewTab(); + await assertTab(firstTab, mailButton, "First tab is mail tab"); + await assertMailShown(); + + // -- Test spaces that only open one tab -- + + let calendarTab; + let calendarButton = document.getElementById("calendarButton"); + for (let { name, button, assertShown } of [ + { + name: "address book", + button: document.getElementById("addressBookButton"), + assertShown: assertAddressBookShown, + }, + { + name: "calendar", + button: calendarButton, + assertShown: assertCalendarShown, + }, + { + name: "tasks", + button: document.getElementById("tasksButton"), + assertShown: assertTasksShown, + }, + { + name: "chat", + button: document.getElementById("chatButton"), + assertShown: assertChatShown, + }, + ]) { + info(`Testing ${name} space`); + // Only have option to open in new tab. + await useContextMenu( + { button, item: newTabItem }, + { newTab: true }, + `Opening ${name} tab` + ); + let newTab = await waitForNewTab(); + if (name == "calendar") { + calendarTab = newTab; + } + await assertTab(newTab, button, `Opened ${name} tab`); + await assertShown(); + // Only have option to switch tabs. + // Doing this from the same tab does nothing. + await useContextMenu( + { button, switchItem: 0 }, + { numSwitch: 1 }, + `When ${name} tab is open` + ); + // Wait one tick to allow tabs to potentially change. + await TestUtils.waitForTick(); + // However, the same tab should remain shown. + await assertShown(); + + // Switch to first tab and back. + await switchTab(firstTab, mailButton, `${name} to first tab`); + await assertMailShown(); + await useContextMenu( + { button, switchItem: 0 }, + { numSwitch: 1 }, + `Switching from first tab to ${name}` + ); + await assertTab(newTab, button, `Switched from first tab to ${name}`); + await assertShown(); + } + + // -- Test opening mail space in a new tab -- + + // Open new mail tabs whilst we are still in a non-mail tab. + await useContextMenu( + { button: mailButton, item: newTabItem }, + { newWindow: true, newTab: true, numSwitch: 1 }, + "Opening the second mail tab" + ); + let secondMailTab = await waitForNewTab(); + await assertTab(secondMailTab, mailButton, "Opened second mail tab"); + await assertMailShown(); + // Displayed folder should be the same as in the first mail tab. + let [, secondMailTabInfo] = + tabmail._getTabContextForTabbyThing(secondMailTab); + await TestUtils.waitForCondition( + () => secondMailTabInfo.folder?.URI == folderB.URI, + "Should display folder B in the second mail tab" + ); + + secondMailTabInfo.folder = folderA; + + // Open a new mail tab whilst in a mail tab. + await useContextMenu( + { button: mailButton, item: newTabItem }, + { newWindow: true, newTab: true, numSwitch: 2 }, + "Opening the third mail tab" + ); + let thirdMailTab = await waitForNewTab(); + await assertTab(thirdMailTab, mailButton, "Opened third mail tab"); + await assertMailShown(); + // Displayed folder should be the same as in the mail tab that was in view + // when the context menu was opened, rather than the folder in the first tab. + let [, thirdMailTabInfo] = tabmail._getTabContextForTabbyThing(thirdMailTab); + await TestUtils.waitForCondition( + () => thirdMailTabInfo.folder?.URI == folderA.URI, + "Should display folder A in the third mail tab" + ); + + // -- Test switching between the multiple mail tabs -- + + await useContextMenu( + { button: mailButton, switchItem: 1 }, + { newWindow: true, newTab: true, numSwitch: 3 }, + "Switching to second mail tab" + ); + await assertTab(secondMailTab, mailButton, "Switch to second mail tab"); + await assertMailShown(); + await useContextMenu( + { button: mailButton, switchItem: 0 }, + { newWindow: true, newTab: true, numSwitch: 3 }, + "Switching to first mail tab" + ); + await assertTab(firstTab, mailButton, "Switch to first mail tab"); + await assertMailShown(); + + await switchTab(calendarTab, calendarButton, "First mail to calendar tab"); + await useContextMenu( + { button: mailButton, switchItem: 2 }, + { newWindow: true, newTab: true, numSwitch: 3 }, + "Switching to third mail tab" + ); + await assertTab(thirdMailTab, mailButton, "Switch to third mail tab"); + await assertMailShown(); + + // -- Test the mail button with multiple mail tabs -- + + // Clicking the mail button whilst in the mail space does nothing. + // Specifically, we do not want it to take us to the first tab. + EventUtils.synthesizeMouseAtCenter(mailButton, {}, window); + // Wait one cycle to see if the tab would change. + await TestUtils.waitForTick(); + await assertTab(thirdMailTab, mailButton, "Remain in third tab"); + await assertMailShown(); + Assert.equal( + thirdMailTabInfo.folder.URI, + folderA.URI, + "Still display folder A in the third mail tab" + ); + + // Clicking the mail button whilst in a different space takes us to the first + // mail tab. + await switchTab(calendarTab, calendarButton, "Third mail to calendar tab"); + EventUtils.synthesizeMouseAtCenter(mailButton, {}, window); + await assertTab(firstTab, mailButton, "Switch to the first mail tab"); + await assertMailShown(); + Assert.equal( + firstMailTabInfo.folder.URI, + folderB.URI, + "Still display folder B in the first mail tab" + ); + + // -- Test opening the mail space in a new window -- + + let windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + await useContextMenu( + { button: mailButton, item: newWindowItem }, + { newWindow: true, newTab: true, numSwitch: 3 }, + "Opening mail tab in new window" + ); + let newMailWindow = await windowPromise; + let newTabmail = newMailWindow.document.getElementById("tabmail"); + // Expect the same folder as the previously focused tab. + await TestUtils.waitForCondition( + () => newTabmail.currentTabInfo.folder?.URI == folderB.URI, + "Waiting for folder B to be displayed in the new window" + ); + Assert.equal( + newMailWindow.document.querySelectorAll("tab.tabmail-tab").length, + 1, + "Should only have one tab in the new window" + ); + await assertMailShown(newMailWindow); + + // -- Test opening different tabs that belong to the settings space -- + + let settingsButton = document.getElementById("settingsButton"); + await useContextMenu( + { button: settingsButton, item: accountItem }, + { settings: true }, + "Opening account settings" + ); + let accountTab = await waitForNewTab(); + // Shown as part of the settings space. + await assertTab(accountTab, settingsButton, "Opened account settings tab"); + await assertContentShown("about:accountsettings"); + + await useContextMenu( + { button: settingsButton, item: settingsItem }, + { settings: true }, + "Opening settings" + ); + let settingsTab = await waitForNewTab(); + // Shown as part of the settings space. + await assertTab(settingsTab, settingsButton, "Opened settings tab"); + await assertSettingsShown(); + + await useContextMenu( + { button: settingsButton, item: addonsItem }, + { settings: true }, + "Opening add-ons" + ); + let addonsTab = await waitForNewTab(); + // Shown as part of the settings space. + await assertTab(addonsTab, settingsButton, "Opened add-ons tab"); + await assertContentShown("about:addons"); + + // -- Test the settings button with multiple settings tabs -- + + // Clicking the settings button whilst in the settings space does nothing. + EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window); + // Wait one cycle to see if the tab would change. + await TestUtils.waitForTick(); + await assertTab(addonsTab, settingsButton, "Remain in add-ons tab"); + await assertContentShown("about:addons"); + + // Clicking the settings button whilst in a different space takes us to the + // settings tab, rather than the first tab, since this is the primary tab for + // the space. + await switchTab(calendarTab, calendarButton, "Add-ons to calendar tab"); + EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window); + await assertTab(settingsTab, settingsButton, "Switch to the settings tab"); + await assertSettingsShown(); + + // Clicking the settings button whilst in a different space and no settings + // tab will open a new settings tab, rather than switch to another tab in the + // settings space because they are not the primary tab for the space. + await closeTab(settingsTab); + await switchTab(calendarTab, calendarButton, "Settings to calendar tab"); + EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window); + settingsTab = await waitForNewTab(); + await assertTab(settingsTab, settingsButton, "Re-opened settings tab"); + await assertSettingsShown(); + + // -- Test opening different settings tabs when they already exist -- + + await useContextMenu( + { button: settingsButton, item: addonsItem }, + { settings: true }, + "Switching to add-ons" + ); + await assertTab(addonsTab, settingsButton, "Switched to add-ons"); + await assertContentShown("about:addons"); + + await useContextMenu( + { button: settingsButton, item: accountItem }, + { settings: true }, + "Switching to account settings" + ); + await assertTab(accountTab, settingsButton, "Switched to account settings"); + await assertContentShown("about:accountsettings"); + + await useContextMenu( + { button: settingsButton, item: settingsItem }, + { settings: true }, + "Switching to settings" + ); + await assertTab(settingsTab, settingsButton, "Switched to settings"); + await assertSettingsShown(); + + // -- Test clicking the spaces buttons when all the tabs are already open. + + await sub_test_cycle_through_primary_tabs(); + + // Tidy up the opened window. + // FIXME: Closing the window earlier in the test causes a test failure on the + // osx build on the try server. + await BrowserTestUtils.closeWindow(newMailWindow); +}); + +add_task(async function testSpacesToolbarMenubar() { + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + let spacesToolbar = document.getElementById("spacesToolbar"); + + EventUtils.synthesizeMouseAtCenter( + document.getElementById("collapseButton"), + {}, + window + ); + Assert.ok(spacesToolbar.hidden, "The spaces toolbar is hidden"); + Assert.ok( + !document.getElementById("spacesToolbarReveal").hidden, + "The status bar toggle button is visible" + ); + + // Test the menubar button. + let viewShownPromise = BrowserTestUtils.waitForEvent( + document.getElementById("menu_View_Popup"), + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("menu_View"), + {}, + window + ); + await viewShownPromise; + + let toolbarsShownPromise = BrowserTestUtils.waitForEvent( + document.getElementById("view_toolbars_popup"), + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("menu_Toolbars"), + {}, + window + ); + await toolbarsShownPromise; + + let menuButton = document.getElementById("viewToolbarsPopupSpacesToolbar"); + Assert.ok( + menuButton.getAttribute("checked") != "true", + "The menu item is not checked" + ); + + let viewHiddenPromise = BrowserTestUtils.waitForEvent( + document.getElementById("menu_View_Popup"), + "popuphidden" + ); + EventUtils.synthesizeMouseAtCenter(menuButton, {}, window); + await viewHiddenPromise; + + Assert.ok( + menuButton.getAttribute("checked") == "true", + "The menu item is checked" + ); +}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac. + +add_task(async function testSpacesToolbarOSX() { + let size = document + .getElementById("spacesToolbar") + .getBoundingClientRect().width; + + // By default, macOS shouldn't need any custom styling. + Assert.ok( + !document.getElementById("titlebar").hasAttribute("style"), + "The custom styling was cleared from all toolbars" + ); + + let styleAppliedPromise = BrowserTestUtils.waitForCondition( + () => + document.getElementById("tabmail-tabs").getAttribute("style") == + `margin-inline-start: ${size}px;`, + "The correct style was applied to the tabmail" + ); + + // Force full screen. + window.fullScreen = true; + await new Promise(resolve => requestAnimationFrame(resolve)); + await styleAppliedPromise; + + let styleRemovedPromise = BrowserTestUtils.waitForCondition( + () => !document.getElementById("tabmail-tabs").hasAttribute("style"), + "The custom styling was cleared from all toolbars" + ); + // Restore original window size. + window.fullScreen = false; + await new Promise(resolve => requestAnimationFrame(resolve)); + await styleRemovedPromise; +}).__skipMe = AppConstants.platform != "macosx"; + +add_task(async function testSpacesToolbarClearedAlignment() { + // Hide the spaces toolbar to check if the style it's cleared. + window.gSpacesToolbar.toggleToolbar(true); + Assert.ok( + !document.getElementById("titlebar").hasAttribute("style") && + !document.getElementById("navigation-toolbox").hasAttribute("style"), + "The custom styling was cleared from all toolbars" + ); +}); + +add_task(async function testSpacesToolbarExtension() { + window.gSpacesToolbar.toggleToolbar(false); + + for (let i = 0; i < 6; i++) { + await window.gSpacesToolbar.createToolbarButton(`testButton${i}`, { + title: `Title ${i}`, + url: `https://test.invalid/${i}`, + iconStyles: new Map([ + [ + "--webextension-toolbar-image", + 'url("chrome://messenger/content/extension.svg")', + ], + ]), + }); + let button = document.getElementById(`testButton${i}`); + Assert.ok(button); + Assert.equal(button.title, `Title ${i}`); + + let img = button.querySelector("img"); + Assert.equal( + img.style.getPropertyValue("--webextension-toolbar-image"), + `url("chrome://messenger/content/extension.svg")`, + `Button image should have the correct icon.` + ); + + let menuitem = document.getElementById(`testButton${i}-menuitem`); + Assert.ok(menuitem); + Assert.equal(menuitem.label, `Title ${i}`); + Assert.equal( + menuitem.style.getPropertyValue("--webextension-toolbar-image"), + `url("chrome://messenger/content/extension.svg")`, + `Menuitem should have the correct icon.` + ); + + let space = window.gSpacesToolbar.spaces.find( + space => space.name == `testButton${i}` + ); + Assert.ok(space); + Assert.equal( + space.url, + `https://test.invalid/${i}`, + "Added url should be correct." + ); + } + + for (let i = 0; i < 6; i++) { + await window.gSpacesToolbar.updateToolbarButton(`testButton${i}`, { + title: `Modified Title ${i}`, + url: `https://test.invalid/${i + 1}`, + iconStyles: new Map([ + [ + "--webextension-toolbar-image", + 'url("chrome://messenger/skin/icons/new-addressbook.svg")', + ], + ]), + }); + let button = document.getElementById(`testButton${i}`); + Assert.ok(button); + Assert.equal(button.title, `Modified Title ${i}`); + + let img = button.querySelector("img"); + Assert.equal( + img.style.getPropertyValue("--webextension-toolbar-image"), + `url("chrome://messenger/skin/icons/new-addressbook.svg")`, + `Button image should have the correct icon.` + ); + + let menuitem = document.getElementById(`testButton${i}-menuitem`); + Assert.ok(menuitem); + Assert.equal( + menuitem.label, + `Modified Title ${i}`, + "Updated title should be correct." + ); + Assert.equal( + menuitem.style.getPropertyValue("--webextension-toolbar-image"), + `url("chrome://messenger/skin/icons/new-addressbook.svg")`, + `Menuitem should have the correct icon.` + ); + + let space = window.gSpacesToolbar.spaces.find( + space => space.name == `testButton${i}` + ); + Assert.ok(space); + Assert.equal( + space.url, + `https://test.invalid/${i + 1}`, + "Updated url should be correct." + ); + } + + let overflowButton = document.getElementById( + "spacesToolbarAddonsOverflowButton" + ); + + let originalHeight = window.outerHeight; + // Set a ridiculous tiny height to be sure all add-on buttons are hidden. + window.resizeTo(window.outerWidth, 300); + await new Promise(resolve => requestAnimationFrame(resolve)); + await BrowserTestUtils.waitForCondition( + () => !overflowButton.hidden, + "The overflow button is visible" + ); + + let overflowPopup = document.getElementById("spacesToolbarAddonsPopup"); + let popupshown = BrowserTestUtils.waitForEvent(overflowPopup, "popupshown"); + overflowButton.click(); + await popupshown; + + Assert.ok(overflowPopup.hasChildNodes()); + + let popuphidden = BrowserTestUtils.waitForEvent(overflowPopup, "popuphidden"); + // Restore the original height. + window.resizeTo(window.outerWidth, originalHeight); + await new Promise(resolve => requestAnimationFrame(resolve)); + + await popuphidden; + await BrowserTestUtils.waitForCondition( + () => overflowButton.hidden, + "The overflow button is hidden" + ); + + // Remove all previously added toolbar buttons and make sure all previously + // generate elements are properly cleared. + for (let i = 0; i < 6; i++) { + await window.gSpacesToolbar.removeToolbarButton(`testButton${i}`); + let space = window.gSpacesToolbar.spaces.find( + space => space.name == `testButton${i}` + ); + Assert.ok(!space); + + let button = document.getElementById(`testButton${i}`); + Assert.ok(!button); + + let menuitem = document.getElementById(`testButton${i}-menuitem`); + Assert.ok(!menuitem); + } +}); + +add_task(function testPinnedSpacesBadge() { + window.gSpacesToolbar.toggleToolbar(true); + let spacesPinnedButton = document.getElementById("spacesPinnedButton"); + let spacesPopupButtonChat = document.getElementById("spacesPopupButtonChat"); + + window.gSpacesToolbar.updatePinnedBadgeState(); + + Assert.ok( + !spacesPinnedButton.classList.contains("has-badge"), + "Pinned button does not indicate badged items without any" + ); + + spacesPopupButtonChat.classList.add("has-badge"); + window.gSpacesToolbar.updatePinnedBadgeState(); + + Assert.ok( + spacesPinnedButton.classList.contains("has-badge"), + "Pinned button indicates it has badged items" + ); + + spacesPopupButtonChat.classList.remove("has-badge"); + window.gSpacesToolbar.updatePinnedBadgeState(); + + Assert.ok( + !spacesPinnedButton.classList.contains("has-badge"), + "Badge state is reset from pinned button" + ); +}); + +add_task(async function testSpacesToolbarFocusRing() { + // Make sure the spaces toolbar is visible. + window.gSpacesToolbar.toggleToolbar(false); + // Move the focus ring on the mail toolbar button. + document.getElementById("mailButton").focus(); + + // Collect an array of all currently visible buttons. + let buttons = [ + ...document.querySelectorAll(".spaces-toolbar-button:not([hidden])"), + ]; + + // Simulate the Arrow Down keypress to make sure the correct button gets the + // focus. + for (let i = 1; i < buttons.length; i++) { + let previousElement = document.activeElement; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + Assert.equal( + document.activeElement.id, + buttons[i].id, + "The next button is focused" + ); + Assert.ok( + document.activeElement.tabIndex == 0 && previousElement.tabIndex == -1, + "The roving tab index was updated" + ); + } + + // Do the same with the Arrow Up key press but reversing the array. + buttons.reverse(); + for (let i = 1; i < buttons.length; i++) { + let previousElement = document.activeElement; + EventUtils.synthesizeKey("KEY_ArrowUp", {}, window); + Assert.equal( + document.activeElement.id, + buttons[i].id, + "The previous button is focused" + ); + Assert.ok( + document.activeElement.tabIndex == 0 && previousElement.tabIndex == -1, + "The roving tab index was updated" + ); + } + + // Pressing the END key should move the focus down to the last available + // button. + EventUtils.synthesizeKey("KEY_End", {}, window); + Assert.equal( + document.activeElement.id, + "collapseButton", + "The last button is focused" + ); + + // Pressing the HOME key should move the focus up to the first available + // button. + EventUtils.synthesizeKey("KEY_Home", {}, window); + Assert.equal( + document.activeElement.id, + "mailButton", + "The first button is focused" + ); + + // Focus follows the mouse click. + EventUtils.synthesizeMouseAtCenter( + document.getElementById("calendarButton"), + {}, + window + ); + Assert.equal( + document.activeElement.id, + "calendarButton", + "Focus should move to the clicked calendar button" + ); + + // Now press a key to make sure roving index was updated with the click. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + Assert.equal( + document.activeElement.id, + "tasksButton", + "Focus should move to the tasks button" + ); +}); diff --git a/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js b/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js new file mode 100644 index 0000000000..7fc2cc3ae5 --- /dev/null +++ b/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js @@ -0,0 +1,119 @@ +/* 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/. */ + +/** + * Test the spaces toolbar customization features. + */ + +const BACKGROUND = "#f00000"; +const ICON = "#00ff0b"; +const ACCENT = "#0300ff"; +const ACCENT_ICON = "#fff600"; + +const INPUTS = { + spacesBackgroundColor: BACKGROUND, + spacesIconsColor: ICON, + spacesAccentTextColor: ACCENT, + spacesAccentBgColor: ACCENT_ICON, +}; + +registerCleanupFunction(async () => { + // Reset all colors. + window.gSpacesToolbar.resetColorCustomization(); + window.gSpacesToolbar.closeCustomize(); +}); + +async function sub_test_open_customize_panel() { + // Open the panel. + let menu = document.getElementById("spacesToolbarContextMenu"); + let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("spacesToolbar"), + { type: "contextmenu" }, + window + ); + await shownPromise; + + let panel = document.getElementById("spacesToolbarCustomizationPanel"); + let panelShownPromise = BrowserTestUtils.waitForEvent(panel, "popupshown"); + menu.activateItem(document.getElementById("spacesToolbarContextCustomize")); + await panelShownPromise; +} + +function sub_test_apply_colors_to_inputs() { + for (let key in INPUTS) { + let input = document.getElementById(`${key}`); + input.value = INPUTS[key]; + // We need to force dispatch the onchange event otherwise the listener won't + // fire since we're programmatically changing the color value. + input.dispatchEvent(new Event("change")); + } +} + +/** + * Check the current state of the custom color properties applied to the + * document style. + * + * @param {boolean} empty - If the style properties should be empty or filled. + */ +function sub_test_check_for_style_properties(empty) { + let style = document.documentElement.style; + if (empty) { + Assert.equal(style.getPropertyValue("--spaces-bg-color"), ""); + Assert.equal(style.getPropertyValue("--spaces-button-text-color"), ""); + Assert.equal( + style.getPropertyValue("--spaces-button-active-text-color"), + "" + ); + Assert.equal(style.getPropertyValue("--spaces-button-active-bg-color"), ""); + return; + } + + Assert.equal(style.getPropertyValue("--spaces-bg-color"), BACKGROUND); + Assert.equal(style.getPropertyValue("--spaces-button-text-color"), ICON); + Assert.equal( + style.getPropertyValue("--spaces-button-active-text-color"), + ACCENT + ); + Assert.equal( + style.getPropertyValue("--spaces-button-active-bg-color"), + ACCENT_ICON + ); +} + +add_task(async function testSpacesToolbarCustomizationPanel() { + // Make sure we're starting from a clean state. + window.gSpacesToolbar.resetColorCustomization(); + + await sub_test_open_customize_panel(); + + // Current colors should be clear. + sub_test_check_for_style_properties(true); + + // Test color preview. + sub_test_apply_colors_to_inputs(); + sub_test_check_for_style_properties(); + + // Reset should clear all applied colors. + window.gSpacesToolbar.resetColorCustomization(); + window.gSpacesToolbar.closeCustomize(); + sub_test_check_for_style_properties(true); + + await sub_test_open_customize_panel(); + // Set colors again. + sub_test_apply_colors_to_inputs(); + + // "Done" should close the panel and apply all colors. + window.gSpacesToolbar.closeCustomize(); + sub_test_check_for_style_properties(); + + // Open the panel and click reset. + await sub_test_open_customize_panel(); + window.gSpacesToolbar.resetColorCustomization(); + sub_test_check_for_style_properties(true); + + // "Done" should restore the custom colors. + window.gSpacesToolbar.closeCustomize(); + sub_test_check_for_style_properties(true); +}); diff --git a/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js b/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js new file mode 100644 index 0000000000..f6cf64cf57 --- /dev/null +++ b/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js @@ -0,0 +1,24 @@ +/* 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/. */ + +// Load subtest shared with tabs-in-titlebar tests. +Services.scriptloader.loadSubScript( + new URL("head_spacesToolbar.js", gTestPath).href, + this +); + +registerCleanupFunction(async () => { + // Reset the menubar visibility. + let menubar = document.getElementById("toolbar-menubar"); + menubar.removeAttribute("autohide"); + menubar.removeAttribute("inactive"); + await new Promise(resolve => requestAnimationFrame(resolve)); +}); + +add_task(async function testSpacesToolbarAlignment() { + // Hide titlebar in toolbar, show menu. + await sub_test_toolbar_alignment(false, false); + // Hide titlebar in toolbar, hide menu. + await sub_test_toolbar_alignment(false, true); +}); diff --git a/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js b/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js new file mode 100644 index 0000000000..5633adad6f --- /dev/null +++ b/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js @@ -0,0 +1,24 @@ +/* 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/. */ + +// Load subtest shared with non-tabs-in-titlebar tests. +Services.scriptloader.loadSubScript( + new URL("head_spacesToolbar.js", gTestPath).href, + this +); + +registerCleanupFunction(async () => { + // Reset the menubar visibility. + let menubar = document.getElementById("toolbar-menubar"); + menubar.removeAttribute("autohide"); + menubar.removeAttribute("inactive"); + await new Promise(resolve => requestAnimationFrame(resolve)); +}); + +add_task(async function testSpacesToolbarAlignment() { + // Show titlebar in toolbar, show menu. + await sub_test_toolbar_alignment(true, false); + // Show titlebar in toolbar, hide menu. + await sub_test_toolbar_alignment(true, true); +}); diff --git a/comm/mail/base/test/browser/browser_statusFeedback.js b/comm/mail/base/test/browser/browser_statusFeedback.js new file mode 100644 index 0000000000..66132aa484 --- /dev/null +++ b/comm/mail/base/test/browser/browser_statusFeedback.js @@ -0,0 +1,71 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const statusText = document.getElementById("statusText"); +const tabmail = document.getElementById("tabmail"); +const about3Pane = tabmail.currentAbout3Pane; +const { threadTree } = about3Pane; + +add_setup(async function () { + // Create an account for the test. + MailServices.accounts.createLocalMailAccount(); + const account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + + // Create a folder for the account to store test messages. + const rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("statusFeedback", null); + const testFolder = rootFolder + .getChildNamed("statusFeedback") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + // Generate a test message. + const generator = new MessageGenerator(); + testFolder.addMessage(generator.makeMessage().toMboxString()); + + // Use the test folder. + about3Pane.displayFolder(testFolder.URI); + await ensure_cards_view(); + + // Remove test account on cleanup. + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + }); +}); + +/** + * Tests that the correct status message appears when opening a message. + */ +add_task(async function testMessageOpen() { + const row = threadTree.getRowAtIndex(0); + const subjectLine = row.querySelector( + ".thread-card-subject-container .subject" + ); + + // Click on the email. + const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + EventUtils.synthesizeMouseAtCenter( + subjectLine, + { clickCount: 1 }, + about3Pane + ); + await selectPromise; + + // Check the value of the status message. + Assert.equal( + statusText.value, + "Loading Message…", + "correct status message is shown" + ); + + // Check that the status message eventually reset + await TestUtils.waitForCondition( + () => statusText.value == "", + "status message should eventually reset" + ); +}); diff --git a/comm/mail/base/test/browser/browser_tabIcon.js b/comm/mail/base/test/browser/browser_tabIcon.js new file mode 100644 index 0000000000..ae9d3a057f --- /dev/null +++ b/comm/mail/base/test/browser/browser_tabIcon.js @@ -0,0 +1,99 @@ +/* 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/. */ + +const { GlodaIndexer } = ChromeUtils.import( + "resource:///modules/gloda/GlodaIndexer.jsm" +); +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +const { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/PromiseTestUtils.jsm" +); + +const TEST_DOCUMENT_URL = + "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html"; +const TEST_IMAGE_URL = + "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png"; + +let tabmail = document.getElementById("tabmail"); +let rootFolder, testFolder, testMessages; + +add_setup(async function () { + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("tabIcon", null); + testFolder = rootFolder + .getChildNamed("tabIcon") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + let messageFile = new FileUtils.File( + getTestFilePath("files/sampleContent.eml") + ); + Assert.ok(messageFile.exists(), "test data file should exist"); + let promiseCopyListener = new PromiseTestUtils.PromiseCopyListener(); + // Copy gIncomingMailFile into the Inbox. + MailServices.copy.copyFileMessage( + messageFile, + testFolder, + null, + false, + 0, + "", + promiseCopyListener, + null + ); + await promiseCopyListener.promise; + testMessages = [...testFolder.messages]; + tabmail.currentAbout3Pane.displayFolder(testFolder); + + registerCleanupFunction(() => { + tabmail.closeOtherTabs(0); + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function testMsgInFolder() { + tabmail.currentAbout3Pane.threadTree.selectedIndex = 0; + await BrowserTestUtils.browserLoaded( + tabmail.currentAboutMessage.getMessagePaneBrowser() + ); + let icon = tabmail.tabInfo[0].tabNode.querySelector(".tab-icon-image"); + await TestUtils.waitForCondition(() => icon.complete, "Icon loaded"); + Assert.equal( + icon.src, + "chrome://messenger/skin/icons/new/compact/folder.svg" + ); +}); + +add_task(async function testMsgInTab() { + window.OpenMessageInNewTab(testMessages[0], { background: false }); + await BrowserTestUtils.waitForEvent( + tabmail.tabInfo[1].chromeBrowser, + "MsgLoaded" + ); + let tab = tabmail.tabInfo[1]; + let icon = tab.tabNode.querySelector(".tab-icon-image"); + await TestUtils.waitForCondition(() => icon.complete, "Icon loaded"); + Assert.equal(icon.src, "chrome://messenger/skin/icons/new/compact/draft.svg"); +}); + +add_task(async function testContentTab() { + let tab = window.openTab("contentTab", { + url: TEST_DOCUMENT_URL, + background: false, + }); + await BrowserTestUtils.browserLoaded(tab.browser); + + let icon = tab.tabNode.querySelector(".tab-icon-image"); + + // Start of TEST_IMAGE_URL as data url. + await TestUtils.waitForCondition( + () => icon.src.startsWith("data:image/png;base64,iVBORw0KGgoAAAANSUhEU"), + "Waited for icon to be correct" + ); +}); diff --git a/comm/mail/base/test/browser/browser_tagsMode.js b/comm/mail/base/test/browser/browser_tagsMode.js new file mode 100644 index 0000000000..6077897e48 --- /dev/null +++ b/comm/mail/base/test/browser/browser_tagsMode.js @@ -0,0 +1,214 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm"); +var { VirtualFolderHelper } = ChromeUtils.import( + "resource:///modules/VirtualFolderWrapper.jsm" +); + +let about3Pane = document.getElementById("tabmail").currentAbout3Pane; +let rootFolder; +let folders = {}; + +const FOLDER_PREFIX = "mailbox://nobody@smart%20mailboxes/tags/"; +const DEFAULT_TAGS = new Map([ + ["$label1", { label: "Important", color: "#FF0000" }], + ["$label2", { label: "Work", color: "#FF9900" }], + ["$label3", { label: "Personal", color: "#009900" }], + ["$label4", { label: "To Do", color: "#3333FF" }], + ["$label5", { label: "Later", color: "#993399" }], +]); + +add_setup(async function () { + let allTags = MailServices.tags.getAllTags(); + Assert.deepEqual( + allTags.map(t => t.key), + [...DEFAULT_TAGS.keys()], + "sanity check tag keys" + ); + Assert.deepEqual( + allTags.map(t => ({ label: t.tag, color: t.color })), + [...DEFAULT_TAGS.values()], + "sanity check tag labels" + ); + + about3Pane.folderPane.activeModes = ["all"]; + resetSmartMailboxes(); + + let account = MailServices.accounts.createAccount(); + account.incomingServer = MailServices.accounts.createIncomingServer( + `${account.key}user`, + "localhost", + "pop3" + ); + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + + for (let flag of [ + "Inbox", + "Drafts", + "Templates", + "SentMail", + "Archive", + "Junk", + "Trash", + "Queue", + "Virtual", + ]) { + let folder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags[flag]); + if (!folder) { + folder = rootFolder.createLocalSubfolder(`tagsMode${flag}`); + folder.setFlag(Ci.nsMsgFolderFlags[flag]); + } + folders[flag] = folder.QueryInterface(Ci.nsIMsgLocalMailFolder); + } + folders.Plain = rootFolder.createLocalSubfolder("tagsModePlain"); + + let msgDatabase = folders.Virtual.msgDatabase; + let folderInfo = msgDatabase.dBFolderInfo; + folderInfo.setCharProperty("searchStr", "ALL"); + folderInfo.setCharProperty("searchFolderUri", folders.Inbox.URI); + + registerCleanupFunction(function () { + MailServices.accounts.removeAccount(account, false); + about3Pane.folderPane.activeModes = ["all"]; + }); +}); + +async function checkFolderTree(expectedTags) { + let tagsList = about3Pane.folderTree.querySelector(`li[data-mode="tags"] ul`); + await TestUtils.waitForCondition( + () => tagsList.childElementCount == expectedTags.size, + "waiting for folder tree to update" + ); + let keys = expectedTags.keys(); + let values = expectedTags.values(); + for (let row of tagsList.children) { + let key = keys.next().value; + let { label, color } = values.next().value; + Assert.equal(row.uri, FOLDER_PREFIX + encodeURIComponent(key)); + Assert.equal(row.name, label); + Assert.equal(row.icon.style.getPropertyValue("--icon-color"), color); + } + Assert.ok(keys.next().done, "all tags should have a row in the tree"); +} + +add_task(async function testFolderTree() { + // Check the default tags are shown initially. + let expectedTags = new Map(DEFAULT_TAGS); + about3Pane.folderPane.activeModes = ["all", "tags"]; + await checkFolderTree(DEFAULT_TAGS); + + // Add two custom tags and check they are shown. + MailServices.tags.addTagForKey("testkey", "testLabel", "#000000", ""); + await TestUtils.waitForCondition( + () => MailServices.tags.getAllTags().length == 6, + "waiting for tag to be created" + ); + expectedTags.set("testkey", { label: "testLabel", color: "#000000" }); + await checkFolderTree(expectedTags); + + MailServices.tags.addTagForKey("anotherkey", "anotherLabel", "#333333", ""); + await TestUtils.waitForCondition( + () => MailServices.tags.getAllTags().length == 7, + "waiting for tag to be created" + ); + expectedTags.set("anotherkey", { label: "anotherLabel", color: "#333333" }); + await checkFolderTree(expectedTags); + + // Delete the first custom tag and check it is removed. + MailServices.tags.deleteKey("testkey"); + await TestUtils.waitForCondition( + () => MailServices.tags.getAllTags().length == 6, + "waiting for tag to be removed" + ); + expectedTags.delete("testkey"); + await checkFolderTree(expectedTags); + + // Hide and reinitialise the Tags mode and check the list is the same. + about3Pane.folderPane.activeModes = ["all"]; + about3Pane.folderPane.activeModes = ["all", "tags"]; + await checkFolderTree(expectedTags); + + // Delete the second custom tag. + MailServices.tags.deleteKey("anotherkey"); + await TestUtils.waitForCondition( + () => MailServices.tags.getAllTags().length == 5, + "waiting for tag to be removed" + ); + expectedTags.delete("anotherkey"); + await checkFolderTree(expectedTags); +}); + +function checkVirtualFolder(tagKey, tagLabel, expectedFolderURIs) { + let folder = MailServices.folderLookup.getFolderForURL( + FOLDER_PREFIX + encodeURIComponent(tagKey) + ); + Assert.ok(folder); + let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder); + Assert.equal(folder.prettyName, tagLabel); + Assert.equal(wrappedFolder.searchString, `AND (tag,contains,${tagKey})`); + Assert.equal(wrappedFolder.searchFolderURIs, "*"); + + about3Pane.displayFolder(folder); + Assert.deepEqual( + about3Pane.gViewWrapper._underlyingFolders.map(f => f.URI).sort(), + expectedFolderURIs.sort() + ); +} + +add_task(async function testFolderSelection() { + let expectedFolderURIs = [ + folders.Inbox.URI, + folders.Drafts.URI, + folders.Templates.URI, + folders.SentMail.URI, + folders.Archive.URI, + folders.Plain.URI, + ]; + + for (let [key, { label }] of DEFAULT_TAGS) { + checkVirtualFolder(key, label, expectedFolderURIs); + } + + // Add another plain folder. It should be added to the searched folders. + let newPlainFolder = rootFolder.createLocalSubfolder("tagsModePlain2"); + expectedFolderURIs.push(newPlainFolder.URI); + checkVirtualFolder("$label1", "Important", expectedFolderURIs); + + // Add a subfolder to the inbox. It should be added to the searched folders. + let newInboxFolder = folders.Inbox.createLocalSubfolder("tagsModeInbox2"); + expectedFolderURIs.push(newInboxFolder.URI); + checkVirtualFolder("$label2", "Work", expectedFolderURIs); + + // Add a subfolder to the trash. It should NOT be added to the searched folders. + folders.Trash.createLocalSubfolder("tagsModeTrash2"); + checkVirtualFolder("$label1", "Important", expectedFolderURIs); + + let rssAccount = FeedUtils.createRssAccount("rss"); + let rssRootFolder = rssAccount.incomingServer.rootFolder; + FeedUtils.subscribeToFeed( + "https://example.org/browser/comm/mail/base/test/browser/files/rss.xml?tagsMode", + rssRootFolder, + null + ); + await TestUtils.waitForCondition(() => rssRootFolder.subFolders.length == 2); + let rssFeedFolder = rssRootFolder.getChildNamed("Test Feed"); + + expectedFolderURIs.push(rssFeedFolder.URI); + checkVirtualFolder("$label2", "Work", expectedFolderURIs); + + // Delete the smart mailboxes server and check it is correctly recreated. + about3Pane.folderPane.activeModes = ["all"]; + resetSmartMailboxes(); + about3Pane.folderPane.activeModes = ["all", "tags"]; + + for (let [key, { label }] of DEFAULT_TAGS) { + checkVirtualFolder(key, label, expectedFolderURIs); + } + + MailServices.accounts.removeAccount(rssAccount, false); +}); diff --git a/comm/mail/base/test/browser/browser_threadTreeDeleting.js b/comm/mail/base/test/browser/browser_threadTreeDeleting.js new file mode 100644 index 0000000000..e034fbbfb3 --- /dev/null +++ b/comm/mail/base/test/browser/browser_threadTreeDeleting.js @@ -0,0 +1,572 @@ +/* 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/. */ + +requestLongerTimeout(2); + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let tabmail = document.getElementById("tabmail"); +let about3Pane = tabmail.currentAbout3Pane; +let threadTree = about3Pane.threadTree; +// Not `currentAboutMessage` as (a) that's null right now, and (b) we'll be +// testing things that happen when about:message is hidden. +let aboutMessage = about3Pane.messageBrowser.contentWindow; +let messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); +let multiMessageView = about3Pane.multiMessageBrowser.contentWindow; +let generator = new MessageGenerator(); +let rootFolder, sourceMessageIDs; + +add_setup(async function () { + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + }); +}); + +/** Test a real folder, unthreaded. */ +add_task(async function testUnthreaded() { + let folderA = rootFolder + .createLocalSubfolder("threadTreeDeletingA") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderA.addMessageBatch( + generator.makeMessages({ count: 15 }).map(message => message.toMboxString()) + ); + + sourceMessageIDs = Array.from(folderA.messages, m => m.messageId); + + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderA.URI, + }); + await ensure_cards_view(); + goDoCommand("cmd_sort", { target: { value: "unthreaded" } }); + + await subtest(); +}); + +/** Test a real folder with threads. */ +add_task(async function testThreaded() { + let folderB = rootFolder + .createLocalSubfolder("threadTreeDeletingB") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderB.addMessageBatch( + [ + // No real reason for the values here other than the total count. + ...generator.makeMessages({ count: 4 }), + ...generator.makeMessages({ count: 6, msgsPerThread: 3 }), + ...generator.makeMessages({ count: 1 }), + ...generator.makeMessages({ count: 2, msgsPerThread: 2 }), + ...generator.makeMessages({ count: 2 }), + ].map(message => message.toMboxString()) + ); + + sourceMessageIDs = Array.from(folderB.messages, m => m.messageId); + + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderB.URI, + }); + goDoCommand("cmd_sort", { target: { value: "threaded" } }); + goDoCommand("cmd_expandAllThreads"); + + await subtest(); +}); + +/** Test a virtual folder with a single backing folder. */ +add_task(async function testSingleVirtual() { + let folderC = rootFolder + .createLocalSubfolder("threadTreeDeletingC") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderC.addMessageBatch( + generator.makeMessages({ count: 15 }).map(message => message.toMboxString()) + ); + + let virtualFolderC = rootFolder.createLocalSubfolder( + "threadTreeDeletingVirtualC" + ); + virtualFolderC.setFlag(Ci.nsMsgFolderFlags.Virtual); + let folderInfoC = virtualFolderC.msgDatabase.dBFolderInfo; + // Search for something instead of all messages, as the "ALL" search could + // detected and the backing folder displayed instead, defeating the point of + // this test. + folderInfoC.setCharProperty("searchStr", "AND (date,is after,31-Dec-1999)"); + folderInfoC.setCharProperty("searchFolderUri", folderC.URI); + + sourceMessageIDs = Array.from(folderC.messages, m => m.messageId); + + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: virtualFolderC.URI, + }); + + await subtest(); +}); + +/** Test a virtual folder with multiple backing folders. */ +add_task(async function testXFVirtual() { + let folderD = rootFolder + .createLocalSubfolder("threadTreeDeletingD") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderD.addMessageBatch( + generator.makeMessages({ count: 4 }).map(message => message.toMboxString()) + ); + + let folderE = rootFolder + .createLocalSubfolder("threadTreeDeletingE") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderE.addMessageBatch( + generator + .makeMessages({ count: 11, msgsPerThread: 3 }) + .map(message => message.toMboxString()) + ); + + let virtualFolderDE = rootFolder.createLocalSubfolder( + "threadTreeDeletingVirtualDE" + ); + virtualFolderDE.setFlag(Ci.nsMsgFolderFlags.Virtual); + let folderInfoY = virtualFolderDE.msgDatabase.dBFolderInfo; + folderInfoY.setCharProperty("searchStr", "AND (date,is after,31-Dec-1999)"); + folderInfoY.setCharProperty( + "searchFolderUri", + `${folderD.URI}|${folderE.URI}` + ); + + sourceMessageIDs = [ + ...Array.from(folderD.messages, m => m.messageId), + ...Array.from(folderE.messages, m => m.messageId), + ]; + + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: virtualFolderDE.URI, + }); + goDoCommand("cmd_sort", { target: { value: "threaded" } }); + goDoCommand("cmd_expandAllThreads"); + + await subtest(); +}); + +/** Test a real folder with a quick filter applied. */ +add_task(async function testQuickFiltered() { + let folderF = rootFolder + .createLocalSubfolder("threadTreeDeletingF") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderF.addMessageBatch( + generator.makeMessages({ count: 30 }).map(message => message.toMboxString()) + ); + let flaggedMessages = []; + let i = 0; + for (let message of folderF.messages) { + if (i++ % 2) { + flaggedMessages.push(message); + } + } + folderF.markMessagesFlagged(flaggedMessages, true); + + sourceMessageIDs = flaggedMessages.map(m => m.messageId); + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderF.URI, + }); + let filterer = about3Pane.quickFilterBar.filterer; + filterer.clear(); + filterer.visible = true; + filterer.setFilterValue("starred", true); + about3Pane.quickFilterBar.updateSearch(); + + await subtest(); +}); + +/** Test a folder sorted by date descending. */ +add_task(async function testSortDescending() { + let folderG = rootFolder + .createLocalSubfolder("threadTreeDeletingG") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderG.addMessageBatch( + generator.makeMessages({ count: 15 }).map(message => message.toMboxString()) + ); + + sourceMessageIDs = Array.from(folderG.messages, m => m.messageId).reverse(); + + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderG.URI, + }); + goDoCommand("cmd_sort", { target: { value: "descending" } }); + + await subtest(); +}); + +/** Test a folder sorted by subject. */ +add_task(async function testSortBySubject() { + let folderH = rootFolder + .createLocalSubfolder("threadTreeDeletingH") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderH.addMessageBatch( + generator.makeMessages({ count: 15 }).map(message => message.toMboxString()) + ); + + sourceMessageIDs = Array.from(folderH.messages) + .sort((m1, m2) => (m1.subject < m2.subject ? -1 : 1)) + .map(m => m.messageId); + + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderH.URI, + }); + goDoCommand("cmd_sort", { target: { value: "bySubject" } }); + + await subtest(); +}); + +/** + * Tests that deleting the selected row while smooth-scrolling does not break + * the scrolling and leave the tree in a bad scroll position. + */ +add_task(async function testDeletionWhileScrolling() { + let folderI = rootFolder + .createLocalSubfolder("threadTreeDeletingI") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderI.addMessageBatch( + generator + .makeMessages({ count: 500 }) + .map(message => message.toMboxString()) + ); + + await ensure_table_view(); + about3Pane.restoreState({ + messagePaneVisible: false, + folderURI: folderI.URI, + }); + + const scrollListener = { + async promiseScrollingStopped() { + this.lastTime = Date.now(); + await TestUtils.waitForCondition( + () => Date.now() - this.lastTime > 1000, + "waiting for scrolling to stop" + ); + delete this.direction; + delete this.lastPosition; + }, + setScrollExpectation(direction) { + this.direction = direction; + this.lastPosition = threadTree.scrollTop; + }, + setNoScrollExpectation() { + this.direction = 0; + }, + handleEvent(event) { + if (this.direction === 0) { + Assert.report(true, undefined, undefined, "unexpected scroll event"); + return; + } + + const position = threadTree.scrollTop; + if (this.direction == -1) { + Assert.lessOrEqual(position, this.lastPosition); + } else if (this.direction == 1) { + Assert.greaterOrEqual(position, this.lastPosition); + } + this.lastPosition = position; + this.lastTime = Date.now(); + }, + }; + + async function delayThenPress(millis, key) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, millis)); + if (key) { + EventUtils.synthesizeKey(key, {}, about3Pane); + await TestUtils.waitForTick(); + } + } + + threadTree.addEventListener("scroll", scrollListener); + threadTree.table.body.focus(); + threadTree.selectedIndex = 299; + await scrollListener.promiseScrollingStopped(); + + let stopPromise = scrollListener.promiseScrollingStopped(); + scrollListener.setScrollExpectation(-1); + + // Page up a few times then delete some messages. + + await delayThenPress(0, "VK_PAGE_UP"); + await delayThenPress(60, "VK_PAGE_UP"); + await delayThenPress(60, "VK_PAGE_UP"); + await delayThenPress(400, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + + await stopPromise; + Assert.equal( + threadTree.getFirstVisibleIndex(), + threadTree.selectedIndex, + "selected row should be the first visible row" + ); + + // Page down a few times then delete some messages. + + stopPromise = scrollListener.promiseScrollingStopped(); + scrollListener.setScrollExpectation(1); + + await delayThenPress(60, "VK_PAGE_DOWN"); + await delayThenPress(60, "VK_PAGE_DOWN"); + await delayThenPress(60, "VK_PAGE_DOWN"); + await delayThenPress(300, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + + await stopPromise; + Assert.equal( + threadTree.getLastVisibleIndex(), + threadTree.selectedIndex, + "selected row should be the last visible row" + ); + + // Select a message somewhere in the middle then delete it. + + scrollListener.setNoScrollExpectation(); + threadTree.selectedIndex -= 10; + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + + await delayThenPress(1000); + Assert.less( + threadTree.getFirstVisibleIndex(), + threadTree.selectedIndex, + "selected row should be below the first visible row" + ); + Assert.greater( + threadTree.getLastVisibleIndex(), + threadTree.selectedIndex, + "selected row should be above the last visible row" + ); + + threadTree.removeEventListener("scroll", scrollListener); +}); + +async function subtest() { + await TestUtils.waitForCondition( + () => threadTree.table.body.rows.length == 15, + "waiting for all of the table rows" + ); + + let dbView = about3Pane.gDBView; + let subjects = []; + for (let i = 0; i < 15; i++) { + subjects.push(dbView.cellTextForColumn(i, "subjectCol")); + } + verifySubjects(subjects); + + threadTree.table.body.focus(); + threadTree.selectedIndex = 3; + await messageLoaded(3); + + // Delete a single message. + await doDeleteCommand(4); + await verifySelection(14, [3], 3); + verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(4)]); + + // Delete a single message. + await doDeleteCommand(5); + await verifySelection(13, [3], 3); + verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(5)]); + + // Delete a single message by clicking the about:message Delete button. + await doDeleteClick(6); + await verifySelection(12, [3], 3); + verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(6)]); + + // Delete adjacent messages. + threadTree.selectedIndices = [3, 4, 5]; + threadTree.currentIndex = 6; + await doDeleteCommand(9); + await verifySelection(9, [3], 3); + verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(9)]); + + // Delete non-adjacent messages. + threadTree.selectedIndices = [2, 4]; + threadTree.currentIndex = 4; + // We should select the message below the current index, but we select the + // message below the first selected one. + await doDeleteCommand(9); + await verifySelection(7, [2], 2); + verifySubjects([ + subjects[0], + subjects[1], + subjects[9], + ...subjects.slice(11), + ]); + + // Delete the last message. + threadTree.selectedIndex = 6; + await messageLoaded(14); + await doDeleteCommand(13); + await verifySelection(6, [5], 5); + verifySubjects([ + subjects[0], + subjects[1], + subjects[9], + ...subjects.slice(11, 14), + ]); + + // Now cause a delete to happen from outside the UI. + await doDeleteExternal(1); + await verifySelection(5, [4], 4); + verifySubjects([subjects[0], subjects[9], ...subjects.slice(11, 14)]); + + // Delete the selected message from outside the UI. + threadTree.selectedIndex = 2; + await messageLoaded(11); + await doDeleteExternal(2, 12); + await verifySelection(4, [2], 2); + verifySubjects([subjects[0], subjects[9], ...subjects.slice(12, 14)]); +} + +async function messageLoaded(index) { + await BrowserTestUtils.browserLoaded(messagePaneBrowser); + Assert.equal( + aboutMessage.gMessage.messageId, + sourceMessageIDs[index], + "correct message loaded" + ); +} + +async function _doDelete(callback, index, expectedLoad) { + let selectCount = 0; + let onSelect = () => selectCount++; + threadTree.addEventListener("select", onSelect); + + let selectPromise; + if (expectedLoad !== undefined) { + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + } + await callback(); + if (selectPromise) { + await selectPromise; + await messageLoaded(expectedLoad); + } + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 25)); + if (selectPromise) { + Assert.equal(selectCount, 1, "'select' event should've happened only once"); + } else { + Assert.equal(selectCount, 0, "'select' event should not have happened"); + } + + threadTree.removeEventListener("select", onSelect); +} + +async function doDeleteCommand(expectedLoad) { + await _doDelete( + function () { + goDoCommand("cmd_delete"); + }, + undefined, + expectedLoad + ); +} + +async function doDeleteClick(expectedLoad) { + await _doDelete( + function () { + let messageView = + threadTree.selectedIndices.length == 1 + ? aboutMessage + : multiMessageView; + EventUtils.synthesizeMouseAtCenter( + messageView.document.getElementById("hdrTrashButton"), + {}, + messageView + ); + }, + undefined, + expectedLoad + ); +} + +async function doDeleteExternal(index, expectedLoad) { + await _doDelete( + function () { + let message = about3Pane.gDBView.getMsgHdrAt(index); + message.folder.deleteMessages( + [message], // messages + null, // msgWindow + true, // deleteStorage + false, // isMove + null, // listener + false // canUndo + ); + }, + index, + expectedLoad + ); +} + +async function verifySelection(rowCount, selectedIndices, currentIndex) { + Assert.equal(threadTree.view.rowCount, rowCount, "row count of view"); + await TestUtils.waitForCondition( + () => threadTree.table.body.rows.length == rowCount, + "waiting table row count to match the view's row count" + ); + + Assert.deepEqual( + threadTree.selectedIndices, + selectedIndices, + "table's selected indices" + ); + let selectedRows = Array.from(threadTree.querySelectorAll(".selected")); + Assert.equal( + selectedRows.length, + selectedIndices.length, + "number of rows with .selected class" + ); + for (let index of selectedIndices) { + let row = threadTree.getRowAtIndex(index); + Assert.ok( + selectedRows.includes(row), + `.selected row at ${index} is expected` + ); + } + + Assert.equal(threadTree.currentIndex, currentIndex, "table's current index"); + let currentRows = threadTree.querySelectorAll(".current"); + Assert.equal(currentRows.length, 1, "one row should have .current"); + Assert.equal( + currentRows[0], + threadTree.getRowAtIndex(currentIndex), + `.current row at ${currentIndex} is expected` + ); + + let contextTargetRows = threadTree.querySelectorAll(".context-menu-target"); + Assert.equal( + contextTargetRows.length, + 0, + "no rows should have .context-menu-target" + ); +} + +function verifySubjects(expectedSubjects) { + let actualSubjects = Array.from( + threadTree.table.body.rows, + row => + row.querySelector(".thread-card-subject-container > .subject").textContent + ); + Assert.equal(actualSubjects.length, expectedSubjects.length, "row count"); + for (let i = 0; i < expectedSubjects.length; i++) { + Assert.equal(actualSubjects[i], expectedSubjects[i], `subject at ${i}`); + } +} diff --git a/comm/mail/base/test/browser/browser_threadTreeQuirks.js b/comm/mail/base/test/browser/browser_threadTreeQuirks.js new file mode 100644 index 0000000000..f0f0012d57 --- /dev/null +++ b/comm/mail/base/test/browser/browser_threadTreeQuirks.js @@ -0,0 +1,669 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); +const { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/PromiseTestUtils.jsm" +); + +let tabmail = document.getElementById("tabmail"); +let about3Pane = tabmail.currentAbout3Pane; +let threadTree = about3Pane.threadTree; +// Not `currentAboutMessage` as (a) that's null right now, and (b) we'll be +// testing things that happen when about:message is hidden. +let aboutMessage = about3Pane.messageBrowser.contentWindow; +let messagePaneBrowser = aboutMessage.getMessagePaneBrowser(); +let rootFolder, folderA, folderB, trashFolder, sourceMessages, sourceMessageIDs; + +add_setup(async function () { + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("threadTreeQuirksA", null); + folderA = rootFolder + .getChildNamed("threadTreeQuirksA") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + rootFolder.createSubfolder("threadTreeQuirksB", null); + folderB = rootFolder.getChildNamed("threadTreeQuirksB"); + trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash); + + // Make some messages, then change their dates to simulate a different order. + let syntheticMessages = generator.makeMessages({ + count: 15, + msgsPerThread: 5, + }); + syntheticMessages[1].date = generator.makeDate(); + syntheticMessages[2].date = generator.makeDate(); + syntheticMessages[3].date = generator.makeDate(); + syntheticMessages[4].date = generator.makeDate(); + + folderA.addMessageBatch( + syntheticMessages.map(message => message.toMboxString()) + ); + sourceMessages = [...folderA.messages]; + sourceMessageIDs = sourceMessages.map(m => m.messageId); + + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function testExpandCollapseUpdates() { + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderA.URI, + }); + + // Clicking the twisty to collapse a row should update the message display. + goDoCommand("cmd_expandAllThreads"); + threadTree.selectedIndex = 5; + await messageLoaded(10); + + let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(5).querySelector(".twisty"), + {}, + about3Pane + ); + await selectPromise; + // Thread root still selected. + await validateTree(11, [5], 5); + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.messageBrowser), + "messageBrowser became hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser), + "multiMessageBrowser became visible" + ); + + // Clicking the twisty to expand a row should update the message display. + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(5).querySelector(".twisty"), + {}, + about3Pane + ); + await selectPromise; + await messageLoaded(10); + await validateTree(15, [5], 5); + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser), + "multiMessageBrowser became hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.messageBrowser), + "messageBrowser became visible" + ); + + // Collapsing all rows while the first message in a thread is selected should + // update the message display. + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_collapseAllThreads"); + await selectPromise; + // Thread root still selected. + await validateTree(3, [1], 1); + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.messageBrowser), + "messageBrowser became hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser), + "multiMessageBrowser became visible" + ); + + // Expanding all rows while the first message in a thread is selected should + // update the message display. + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_expandAllThreads"); + await selectPromise; + await messageLoaded(10); + await validateTree(15, [5], 5); + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser), + "multiMessageBrowser became hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.messageBrowser), + "messageBrowser became visible" + ); + + // Collapsing all rows while a message inside a thread is selected should + // select the first message in the thread and update the message display. + threadTree.selectedIndex = 2; + await messageLoaded(7); + + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_collapseAllThreads"); + await selectPromise; + // Thread root became selected. + await validateTree(3, [0], 0); + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.messageBrowser), + "messageBrowser became hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser), + "multiMessageBrowser became visible" + ); + + // Expanding all rows while the first message in a thread is selected should + // update the message display. (This is effectively the same test as earlier.) + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_expandAllThreads"); + await selectPromise; + await messageLoaded(5); + await validateTree(15, [0], 0); + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser), + "multiMessageBrowser became hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.messageBrowser), + "messageBrowser became visible" + ); + + // Select several things and collapse all. + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + threadTree.selectedIndices = [2, 3, 5]; + await selectPromise; + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.messageBrowser), + "messageBrowser became hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser), + "multiMessageBrowser became visible" + ); + + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_collapseAllThreads"); + await selectPromise; + // Thread roots became selected. + await validateTree(3, [0, 1], 1); + Assert.ok( + BrowserTestUtils.is_hidden(about3Pane.messageBrowser), + "messageBrowser stayed hidden" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser), + "multiMessageBrowser stayed visible" + ); +}); + +add_task(async function testThreadUpdateKeepsSelection() { + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderB.URI, + }); + + // Put some messages from different threads in the folder and select one. + await move([sourceMessages[0]], folderA, folderB); + await move([sourceMessages[5]], folderA, folderB); + threadTree.selectedIndex = 1; + await messageLoaded(5); + + // Move a "newer" message into the folder. This should switch the order of + // the threads, but no selection change should occur. + threadTree.addEventListener("select", reportBadSelectEvent); + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + await move([sourceMessages[1]], folderA, folderB); + // Selection should have moved. + await validateTree(2, [0], 0); + Assert.equal( + aboutMessage.gMessage.messageId, + sourceMessageIDs[5], + "correct message still loaded" + ); + + // Wait to prove unwanted selection or load didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + + threadTree.removeEventListener("select", reportBadSelectEvent); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + await restoreMessages(); +}); + +add_task(async function testArchiveDeleteUpdates() { + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderA.URI, + }); + about3Pane.sortController.sortUnthreaded(); + + threadTree.table.body.focus(); + threadTree.selectedIndex = 3; + await messageLoaded(7); + + let selectCount = 0; + let onSelect = () => selectCount++; + threadTree.addEventListener("select", onSelect); + + let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_delete"); + await selectPromise; + await messageLoaded(8); + await validateTree(14, [3], 3); + Assert.equal(selectCount, 1, "'select' event should've happened only once"); + + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_delete"); + await selectPromise; + await messageLoaded(9); + await validateTree(13, [3], 3); + Assert.equal(selectCount, 2, "'select' event should've happened only once"); + + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_archive"); + await selectPromise; + await messageLoaded(10); + await validateTree(12, [3], 3); + Assert.equal(selectCount, 3, "'select' event should've happened only once"); + + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_archive"); + await selectPromise; + await messageLoaded(11); + await validateTree(11, [3], 3); + Assert.equal(selectCount, 4, "'select' event should've happened only once"); + + selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_delete"); + await selectPromise; + await messageLoaded(12); + await validateTree(10, [3], 3); + Assert.equal(selectCount, 5, "'select' event should've happened only once"); + + threadTree.removeEventListener("select", onSelect); + + await restoreMessages(); +}); + +add_task(async function testMessagePaneSelection() { + await move(sourceMessages.slice(6, 9), folderA, folderB); + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderB.URI, + }); + about3Pane.sortController.sortUnthreaded(); + about3Pane.sortController.sortThreadPane("byDate"); + about3Pane.sortController.sortDescending(); + + threadTree.table.body.focus(); + threadTree.selectedIndex = 1; + await messageLoaded(7); + await validateTree(3, [1], 1); + + // Check the initial selection in about:message. + Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1); + let min = {}, + max = {}; + aboutMessage.gDBView.selection.getRangeAt(0, min, max); + Assert.equal(min.value, 1); + Assert.equal(max.value, 1); + + // Add a new message to the folder, which should appear first. + threadTree.addEventListener("select", reportBadSelectEvent); + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + await move(sourceMessages.slice(9, 10), folderA, folderB); + await validateTree(4, [2], 2); + + Assert.deepEqual( + Array.from(folderB.messages, m => m.messageId), + sourceMessageIDs.slice(6, 10), + "all expected messages are in the folder" + ); + + // Check the selection in about:message. + Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1); + aboutMessage.gDBView.selection.getRangeAt(0, min, max); + Assert.equal(min.value, 2); + Assert.equal(max.value, 2); + + // Wait to prove unwanted selection or load didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + threadTree.removeEventListener("select", reportBadSelectEvent); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + + // Now click the delete button in about:message. + let deletePromise = PromiseTestUtils.promiseFolderEvent( + folderB, + "DeleteOrMoveMsgCompleted" + ); + let loadPromise = messageLoaded(6); + EventUtils.synthesizeMouseAtCenter( + aboutMessage.document.getElementById("hdrTrashButton"), + {}, + aboutMessage + ); + await Promise.all([deletePromise, loadPromise]); + + // Check which message was deleted. + Assert.deepEqual( + Array.from(trashFolder.messages, m => m.messageId), + [sourceMessageIDs[7]], + "the right message was deleted" + ); + Assert.deepEqual( + Array.from(folderB.messages, m => m.messageId), + [sourceMessageIDs[6], sourceMessageIDs[8], sourceMessageIDs[9]], + "the right messages were kept" + ); + + await validateTree(3, [2], 2); + + // Check the selection in about:message again. + Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1); + aboutMessage.gDBView.selection.getRangeAt(0, min, max); + Assert.equal(min.value, 2); + Assert.equal(max.value, 2); + + await restoreMessages(); +}); + +add_task(async function testNonSelectionContextMenu() { + let mailContext = about3Pane.document.getElementById("mailContext"); + let openNewTabItem = about3Pane.document.getElementById( + "mailContext-openNewTab" + ); + let replyItem = about3Pane.document.getElementById("mailContext-replySender"); + + about3Pane.restoreState({ + messagePaneVisible: true, + folderURI: folderA.URI, + }); + about3Pane.sortController.sortUnthreaded(); + threadTree.scrollToIndex(0, true); + + threadTree.selectedIndex = 0; + await messageLoaded(0); + await validateTree(15, [0], 0); + await subtestOpenTab(1, sourceMessageIDs[5]); + await subtestReply(6, sourceMessageIDs[10]); + + threadTree.selectedIndices = [3, 6, 9]; + await BrowserTestUtils.browserLoaded( + messagePaneBrowser, + false, + "about:blank" + ); + await subtestOpenTab(0, sourceMessageIDs[0]); + + async function doContextMenu(testIndex, messageId, itemToActivate) { + let originalSelection = threadTree.selectedIndices; + + threadTree.addEventListener("select", reportBadSelectEvent); + messagePaneBrowser.addEventListener("load", reportBadLoad, true); + + let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + threadTree + .getRowAtIndex(testIndex) + .querySelector(".thread-card-subject-container"), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + + Assert.ok(about3Pane.mailContextMenu.selectionIsOverridden); + Assert.deepEqual( + threadTree.selectedIndices, + [testIndex], + "selection should be only the right-clicked-on row" + ); + let contextTargetRows = threadTree.querySelectorAll(".context-menu-target"); + Assert.equal( + contextTargetRows.length, + 1, + "one row should have .context-menu-target" + ); + Assert.equal( + contextTargetRows[0].index, + testIndex, + "correct row has .context-menu-target" + ); + + let hiddenPromise = BrowserTestUtils.waitForEvent( + mailContext, + "popuphidden" + ); + mailContext.activateItem(itemToActivate); + await hiddenPromise; + + Assert.ok(!about3Pane.mailContextMenu.selectionIsOverridden); + Assert.equal( + document.activeElement, + tabmail.tabInfo[0].chromeBrowser, + "about:3pane should have focus after context menu" + ); + Assert.equal( + about3Pane.document.activeElement, + threadTree.table.body, + "table body should have focus after context menu" + ); + + // Selection should be restored. + await validateTree( + 15, + threadTree.selectedIndices, + originalSelection.at(-1) + ); + + // Wait to prove unwanted selection or load didn't happen. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + + threadTree.removeEventListener("select", reportBadSelectEvent); + messagePaneBrowser.removeEventListener("load", reportBadLoad, true); + } + + // Opening a new tab should open the clicked-on message, not the selected. + async function subtestOpenTab(testIndex, messageId) { + let newAboutMessagePromise = BrowserTestUtils.waitForEvent( + tabmail, + "aboutMessageLoaded" + ).then(async function (event) { + await BrowserTestUtils.browserLoaded( + event.target.getMessagePaneBrowser() + ); + return event.target; + }); + await doContextMenu(testIndex, messageId, openNewTabItem); + + let newAboutMessage = await newAboutMessagePromise; + Assert.equal( + newAboutMessage.gMessage.messageId, + messageId, + "correct message should have opened in a tab" + ); + Assert.equal( + tabmail.tabInfo.length, + 2, + "only one new tab should have opened" + ); + tabmail.closeOtherTabs(0); + } + + // Replying should quote the clicked-on message, not the selected, even when + // some text is selected in the message pane. + async function subtestReply(testIndex, messageId) { + Assert.stringContains( + messagePaneBrowser.contentDocument.body.textContent, + "Hello Bob Bell!" + ); + messagePaneBrowser.contentWindow + .getSelection() + .selectAllChildren(messagePaneBrowser.contentDocument.body); + + let composeWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + await doContextMenu(testIndex, messageId, replyItem); + let composeWindow = await composeWindowPromise; + let composeEditor = composeWindow.GetCurrentEditorElement(); + let composeBody = await TestUtils.waitForCondition( + () => composeEditor.contentDocument.body.textContent + ); + + Assert.stringContains( + composeBody, + "Hello Felix Flowers!", + "new message should quote the right-clicked-on message" + ); + Assert.ok( + !composeBody.includes("Hello Bob Bell!"), + "new message should not quote the selected message" + ); + + await BrowserTestUtils.closeWindow(composeWindow); + } +}); + +async function messageLoaded(index) { + await BrowserTestUtils.browserLoaded(messagePaneBrowser); + Assert.equal( + aboutMessage.gMessage.messageId, + sourceMessageIDs[index], + "correct message loaded" + ); +} + +async function validateTree(rowCount, selectedIndices, currentIndex) { + Assert.equal(threadTree.view.rowCount, rowCount, "row count of view"); + await TestUtils.waitForCondition( + () => threadTree.table.body.rows.length == rowCount, + "waiting table row count to match the view's row count" + ); + + Assert.deepEqual( + threadTree.selectedIndices, + selectedIndices, + "table's selected indices" + ); + let selectedRows = Array.from(threadTree.querySelectorAll(".selected")); + Assert.equal( + selectedRows.length, + selectedIndices.length, + "number of rows with .selected class" + ); + for (let index of selectedIndices) { + let row = threadTree.getRowAtIndex(index); + Assert.ok( + selectedRows.includes(row), + `.selected row at ${index} is expected` + ); + } + + Assert.equal(threadTree.currentIndex, currentIndex, "table's current index"); + let currentRows = threadTree.querySelectorAll(".current"); + Assert.equal(currentRows.length, 1, "one row should have .current"); + Assert.equal( + currentRows[0], + threadTree.getRowAtIndex(currentIndex), + ".current row is expected" + ); + + let contextTargetRows = threadTree.querySelectorAll(".context-menu-target"); + Assert.equal( + contextTargetRows.length, + 0, + "no rows should have .context-menu-target" + ); +} + +async function move(messages, source, dest) { + let copyListener = new PromiseTestUtils.PromiseCopyListener(); + MailServices.copy.copyMessages( + source, + messages, + dest, + true, + copyListener, + top.msgWindow, + false + ); + await copyListener.promise; +} + +function reportBadSelectEvent() { + Assert.report( + true, + undefined, + undefined, + "should not have fired a select event" + ); +} + +function reportBadLoad() { + Assert.report(true, undefined, undefined, "should not have loaded a message"); +} + +async function restoreMessages() { + // Move all of the messages back to folder A. + await move([...folderB.messages], folderB, folderA); + let archiveFolder = rootFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Archive + ); + if (archiveFolder) { + for (let folder of archiveFolder.subFolders) { + await move([...folder.messages], folder, folderA); + } + } + await move([...trashFolder.messages], trashFolder, folderA); + + // Restore all of the messages in `sourceMessages`, in the right order. + sourceMessages = [...folderA.messages].sort( + (a, b) => + sourceMessageIDs.indexOf(a.messageId) - + sourceMessageIDs.indexOf(b.messageId) + ); +} + +add_task(async function testThreadTreeA11yRoles() { + Assert.equal( + threadTree.table.body.getAttribute("role"), + "listbox", + "The tree view should be presented as ListBox" + ); + Assert.equal( + threadTree.getRowAtIndex(0).getAttribute("role"), + "option", + "The message row should be presented as Option" + ); + + about3Pane.sortController.sortThreaded(); + + await BrowserTestUtils.waitForCondition( + () => threadTree.table.body.getAttribute("role") == "tree", + "The tree view should switch to a Tree View role" + ); + Assert.equal( + threadTree.getRowAtIndex(0).getAttribute("role"), + "treeitem", + "The message row should be presented as Tree Item" + ); + + about3Pane.sortController.groupBySort(); + threadTree.scrollToIndex(0, true); + + await BrowserTestUtils.waitForCondition( + () => threadTree.table.body.getAttribute("role") == "tree", + "The message list table should remain presented as Tree View" + ); + Assert.equal( + threadTree.getRowAtIndex(0).getAttribute("role"), + "treeitem", + "The first dummy message row should be presented as Tree Item" + ); + + about3Pane.sortController.sortUnthreaded(); +}); diff --git a/comm/mail/base/test/browser/browser_threadTreeSorting.js b/comm/mail/base/test/browser/browser_threadTreeSorting.js new file mode 100644 index 0000000000..be3bf0f2eb --- /dev/null +++ b/comm/mail/base/test/browser/browser_threadTreeSorting.js @@ -0,0 +1,344 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let tabmail = document.getElementById("tabmail"); +let about3Pane = tabmail.currentAbout3Pane; +let { sortController, threadTree } = about3Pane; +let rootFolder, testFolder, sourceMessageIDs; +let menuHelper = new MenuTestHelper("menu_View"); + +add_setup(async function () { + Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false); + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + + testFolder = rootFolder + .createLocalSubfolder("threadTreeSort") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + testFolder.addMessageBatch( + generator + .makeMessages({ count: 320 }) + .map(message => message.toMboxString()) + ); + + about3Pane.restoreState({ + messagePaneVisible: false, + folderURI: testFolder.URI, + }); + await new Promise(resolve => requestAnimationFrame(resolve)); + + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + + registerCleanupFunction(async () => { + await ensure_cards_view(); + MailServices.accounts.removeAccount(account, false); + Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true); + }); +}); + +add_task(async function () { + const messagesByDate = [...testFolder.messages]; + const messagesBySubject = messagesByDate + .slice() + .sort((m1, m2) => (m1.subject < m2.subject ? -1 : 1)); + const dateHeaderButton = about3Pane.document.getElementById("dateCol"); + const subjectHeaderButton = about3Pane.document.getElementById("subjectCol"); + + // Sanity check. + + Assert.equal( + about3Pane.gViewWrapper.primarySortType, + Ci.nsMsgViewSortType.byDate, + "initial sort column should be byDate" + ); + Assert.equal( + about3Pane.gViewWrapper.primarySortOrder, + Ci.nsMsgViewSortOrder.ascending, + "initial sort order should be ascending" + ); + Assert.ok( + about3Pane.gViewWrapper.showThreaded, + "initial mode should be threaded" + ); + Assert.equal( + threadTree.view.rowCount, + 320, + "correct number of rows in the view" + ); + Assert.equal( + threadTree.getLastVisibleIndex(), + 319, + "should be scrolled to the bottom" + ); + + Assert.equal(getCardActualSubject(320 - 1), messagesByDate.at(-1).subject); + Assert.equal(getCardActualSubject(320 - 2), messagesByDate.at(-2).subject); + Assert.equal(getCardActualSubject(320 - 3), messagesByDate.at(-3).subject); + + // Switch to horizontal layout and table view so we can interact with the + // table header and sort rows properly. + await ensure_table_view(); + + // Check sorting with no message selected. + + await clickHeader(dateHeaderButton, "byDate", "descending"); + Assert.equal( + threadTree.view.rowCount, + 320, + "correct number of rows in the view" + ); + Assert.equal( + threadTree.getFirstVisibleIndex(), + 0, + "should be scrolled to the top" + ); + + Assert.equal(getActualSubject(0), messagesByDate.at(-1).subject); + Assert.equal(getActualSubject(1), messagesByDate.at(-2).subject); + Assert.equal(getActualSubject(2), messagesByDate.at(-3).subject); + + await clickHeader(dateHeaderButton, "byDate", "ascending"); + Assert.equal( + threadTree.view.rowCount, + 320, + "correct number of rows in the view" + ); + Assert.equal( + threadTree.getLastVisibleIndex(), + 319, + "should be scrolled to the bottom" + ); + + Assert.equal(getActualSubject(320 - 1), messagesByDate.at(-1).subject); + Assert.equal(getActualSubject(320 - 2), messagesByDate.at(-2).subject); + Assert.equal(getActualSubject(320 - 3), messagesByDate.at(-3).subject); + + // Select a message and check the selection remains after sorting. + + const targetMessage = messagesByDate[49]; + info(`selecting message "${targetMessage.subject}"`); + threadTree.scrollToIndex(49, true); + await new Promise(resolve => requestAnimationFrame(resolve)); + threadTree.selectedIndex = 49; + verifySelection([49], [targetMessage.subject]); + + await clickHeader(dateHeaderButton, "byDate", "descending"); + verifySelection([319 - 49], [targetMessage.subject], { + where: "last", + }); + + await clickHeader(dateHeaderButton, "byDate", "ascending"); + verifySelection([49], [targetMessage.subject], { where: "first" }); + + const targetIndexBySubject = messagesBySubject.indexOf(targetMessage); + + // Switch columns. + + await clickHeader(subjectHeaderButton, "bySubject", "ascending"); + verifySelection([targetIndexBySubject], [targetMessage.subject]); + + await clickHeader(subjectHeaderButton, "bySubject", "descending"); + verifySelection([319 - targetIndexBySubject], [targetMessage.subject]); + + await clickHeader(subjectHeaderButton, "bySubject", "ascending"); + verifySelection([targetIndexBySubject], [targetMessage.subject]); + + // Switch back again. + + await clickHeader(dateHeaderButton, "byDate", "ascending"); + verifySelection([49], [targetMessage.subject], { where: "first" }); + + // Select multiple messages, two adjacent to each other, one non-adjacent, + // and check the selection remains after sorting. + + const targetMessages = [ + messagesByDate[80], + messagesByDate[81], + messagesByDate[83], + ]; + info( + `selecting messages "${targetMessages.map(m => m.subject).join('", "')}"` + ); + threadTree.scrollToIndex(83, true); + await new Promise(resolve => requestAnimationFrame(resolve)); + threadTree.selectedIndices = [80, 81, 83]; + verifySelection( + [80, 81, 83], + [ + targetMessages[0].subject, + targetMessages[1].subject, + targetMessages[2].subject, + ], + { currentIndex: 83 } + ); + + await clickHeader(dateHeaderButton, "byDate", "descending"); + verifySelection( + [319 - 83, 319 - 81, 319 - 80], + [ + targetMessages[2].subject, + // Rows for these two messages probably don't exist yet. + // targetMessages[1].subject, + // targetMessages[0].subject, + ], + { where: "last" } + ); + + await clickHeader(dateHeaderButton, "byDate", "ascending"); + verifySelection( + [80, 81, 83], + [ + // Rows for these two messages probably don't exist yet. + undefined, // targetMessages[0].subject, + undefined, // targetMessages[1].subject, + targetMessages[2].subject, + ], + { currentIndex: 83, where: "first" } + ); +}); + +async function clickHeader(header, type, order) { + info(`sorting ${type} ${order}`); + const button = header.querySelector("button"); + + let scrollEvents = 0; + let listener = () => scrollEvents++; + + threadTree.addEventListener("scroll", listener); + EventUtils.synthesizeMouseAtCenter(button, {}, about3Pane); + + // Wait long enough that any more scrolling would trigger more events. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + threadTree.removeEventListener("scroll", listener); + Assert.lessOrEqual(scrollEvents, 1, "only one scroll event should fire"); + + Assert.equal( + about3Pane.gViewWrapper.primarySortType, + Ci.nsMsgViewSortType[type], + `sort type should be ${type}` + ); + Assert.equal( + about3Pane.gViewWrapper.primarySortOrder, + Ci.nsMsgViewSortOrder[order], + `sort order should be ${order}` + ); + Assert.ok(about3Pane.gViewWrapper.showThreaded, "mode should be threaded"); + + Assert.ok( + button.classList.contains("sorting"), + "header button should have the sorted class" + ); + Assert.equal( + button.classList.contains("ascending"), + order == "ascending", + `header button ${ + order == "ascending" ? "should" : "should not" + } have the ascending class` + ); + Assert.equal( + button.classList.contains("descending"), + order == "descending", + `header button ${ + order == "descending" ? "should" : "should not" + } have the descending class` + ); + + Assert.equal( + threadTree.table.header.querySelectorAll( + ".sorting, .ascending, .descending" + ).length, + 1, + "no other header buttons should have sorting classes" + ); + + if (AppConstants.platform != "macosx") { + await menuHelper.testItems({ + viewSortMenu: {}, + sortByDateMenuitem: { checked: type == "byDate" }, + sortBySubjectMenuitem: { checked: type == "bySubject" }, + sortAscending: { checked: order == "ascending" }, + sortDescending: { checked: order == "descending" }, + sortThreaded: { checked: true }, + sortUnthreaded: {}, + groupBySort: {}, + }); + } +} + +function verifySelection( + selectedIndices, + subjects, + { currentIndex, where } = {} +) { + if (currentIndex === undefined) { + currentIndex = selectedIndices[0]; + } + + Assert.deepEqual( + threadTree.selectedIndices, + selectedIndices, + "selectedIndices" + ); + Assert.equal(threadTree.currentIndex, currentIndex, "currentIndex"); + if (where == "first") { + Assert.equal( + threadTree.getFirstVisibleIndex(), + currentIndex, + "currentIndex should be first" + ); + } else { + Assert.lessOrEqual( + threadTree.getFirstVisibleIndex(), + currentIndex, + "currentIndex should be at or below first" + ); + } + if (where == "last") { + Assert.equal( + threadTree.getLastVisibleIndex(), + currentIndex, + "currentIndex should be last" + ); + } else { + Assert.greaterOrEqual( + threadTree.getLastVisibleIndex(), + currentIndex, + "currentIndex should be at or above last" + ); + } + for (let i = 0; i < subjects.length; i++) { + if (subjects[i]) { + Assert.equal(getActualSubject(selectedIndices[i]), subjects[i]); + } + } +} + +function getCardActualSubject(index) { + let row = threadTree.getRowAtIndex(index); + return row.querySelector(".thread-card-subject-container > .subject") + .textContent; +} + +function getActualSubject(index) { + let row = threadTree.getRowAtIndex(index); + return row.querySelector(".subject-line > span").textContent; +} + +function getActualSubjects() { + return Array.from( + threadTree.table.body.rows, + row => row.querySelector(".subject-line > span").textContent + ); +} diff --git a/comm/mail/base/test/browser/browser_threads.js b/comm/mail/base/test/browser/browser_threads.js new file mode 100644 index 0000000000..4bfcb5fc11 --- /dev/null +++ b/comm/mail/base/test/browser/browser_threads.js @@ -0,0 +1,385 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +let tabmail = document.getElementById("tabmail"); +let about3Pane = tabmail.currentAbout3Pane; +let { threadPane, threadTree } = about3Pane; +let { notificationBox } = threadPane; +let rootFolder, testFolder, testMessages; + +add_setup(async function () { + Services.prefs.setStringPref( + "mail.ignore_thread.learn_more_url", + "http://mochi.test:8888/" + ); + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("threads", null); + testFolder = rootFolder + .getChildNamed("threads") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + testFolder.addMessageBatch( + generator + .makeMessages({ count: 25, msgsPerThread: 5 }) + .map(message => message.toMboxString()) + ); + testMessages = [...testFolder.messages]; + + about3Pane.displayFolder(testFolder.URI); + about3Pane.paneLayout.messagePaneVisible = false; + goDoCommand("cmd_expandAllThreads"); + + await ensure_table_view(); + + // Check the initial state of a sample of messages. + + checkRowThreadState(0, true); + checkRowThreadState(1, false); + checkRowThreadState(2, false); + checkRowThreadState(3, false); + checkRowThreadState(4, false); + checkRowThreadState(5, true); + checkRowThreadState(10, true); + checkRowThreadState(15, true); + checkRowThreadState(20, true); + + registerCleanupFunction(async () => { + await ensure_cards_view(); + MailServices.accounts.removeAccount(account, false); + about3Pane.paneLayout.messagePaneVisible = true; + Services.prefs.clearUserPref("mail.ignore_thread.learn_more_url"); + }); +}); + +/** + * Test that a double click on a button doesn't trigger the opening of the + * message. + */ +add_task(async function checkDoubleClickOnThreadButton() { + let row = threadTree.getRowAtIndex(20); + Assert.ok( + !row.classList.contains("collapsed"), + "The thread row should be expanded" + ); + + Assert.equal(tabmail.tabInfo.length, 1, "Only 1 tab currently visible"); + + let button = row.querySelector(".thread-container .twisty"); + // Simulate a double click on the twisty icon. + EventUtils.synthesizeMouseAtCenter(button, { clickCount: 2 }, about3Pane); + + Assert.equal( + tabmail.tabInfo.length, + 1, + "The message wasn't opened in another tab" + ); + + // Normally a double click on the twisty would close and open the thread, but + // this simulated click is too fast and the second click happens before the + // row is collapsed. Let's click on it again once to return to the original + // state. + EventUtils.synthesizeMouseAtCenter(button, {}, about3Pane); + + Assert.ok( + !row.classList.contains("collapsed"), + "The double click was registered as 2 separate clicks and the thread row is still expanded" + ); +}); + +add_task(async function testIgnoreThread() { + // Check the menu items for the root message in a thread. + + threadTree.selectedIndex = 0; + await checkMessageMenu({ killThread: false }); + await checkContextMenu(0, { "mailContext-ignoreThread": false }); + + // Check and use the menu items for a message inside a thread. Ignoring a + // thread should work from any message in the thread. + threadTree.selectedIndex = 2; + await checkMessageMenu({ killThread: false }); + await checkContextMenu( + 2, + { "mailContext-ignoreThread": false }, + "mailContext-ignoreThread" + ); + + // Check the thread is ignored and collapsed. + + checkRowThreadState(0, "ignore"); + Assert.ok( + threadTree.getRowAtIndex(0).classList.contains("collapsed"), + "ignored row should have the 'collapsed' class" + ); + + // Restore the thread using the context menu item. + + threadTree.selectedIndex = 0; + await checkMessageMenu({ killThread: true }); + await checkContextMenu( + 0, + { "mailContext-ignoreThread": true }, + "mailContext-ignoreThread" + ); + + checkRowThreadState(0, true); + + // Ignore the next thread. The first thread was collapsed by ignoring it, + // so the next thread is at index 1. + + threadTree.selectedIndex = 1; + await checkMessageMenu({ killThread: false }); + await checkContextMenu( + 1, + { "mailContext-ignoreThread": false }, + "mailContext-ignoreThread" + ); + + checkRowThreadState(1, "ignore"); + Assert.ok( + threadTree.getRowAtIndex(1).classList.contains("collapsed"), + "ignored row should have the 'collapsed' class" + ); + + // Check the notification about the ignored thread. + + let notification = + notificationBox.getNotificationWithValue("ignoreThreadInfo"); + let label = notification.shadowRoot.querySelector( + "label.notification-message" + ); + Assert.stringContains(label.textContent, testMessages[5].subject); + let buttons = notification.shadowRoot.querySelectorAll( + "button.notification-button" + ); + Assert.equal(buttons.length, 2); + + // Click the Learn More button, and check it opens the support page in a new tab. + let tabOpenPromise = BrowserTestUtils.waitForEvent( + tabmail.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeMouseAtCenter(buttons[0], {}, about3Pane); + let event = await tabOpenPromise; + await BrowserTestUtils.browserLoaded(event.detail.tabInfo.browser); + Assert.equal( + event.detail.tabInfo.browser.currentURI.spec, + "http://mochi.test:8888/" + ); + tabmail.closeTab(event.detail.tabInfo); + Assert.ok(notification.parentNode, "notification should not be closed"); + + // Click the Undo button, and check it stops ignoring the thread. + EventUtils.synthesizeMouseAtCenter(buttons[1], {}, about3Pane); + await TestUtils.waitForCondition(() => !notification.parentNode); + checkRowThreadState(1, true); + + goDoCommand("cmd_expandAllThreads"); +}); + +add_task(async function testIgnoreSubthread() { + // Check and use the menu items for a message inside a thread. + + threadTree.selectedIndex = 12; + await checkMessageMenu({ killSubthread: false }); + await checkContextMenu( + 12, + { "mailContext-ignoreSubthread": false }, + "mailContext-ignoreSubthread" + ); + + // Check all messages in that subthread are marked as ignored. + + checkRowThreadState(12, "ignoreSubthread"); + checkRowThreadState(13, "ignoreSubthread"); + checkRowThreadState(14, "ignoreSubthread"); + + // Restore the subthread using the context menu item. + + threadTree.selectedIndex = 12; + await checkMessageMenu({ killSubthread: true }); + await checkContextMenu( + 12, + { "mailContext-ignoreSubthread": true }, + "mailContext-ignoreSubthread" + ); + + checkRowThreadState(12, false); + checkRowThreadState(13, false); + checkRowThreadState(14, false); + + // Ignore a different subthread. + + threadTree.selectedIndex = 17; + await checkMessageMenu({ killSubthread: false }); + await checkContextMenu( + 17, + { "mailContext-ignoreSubthread": false }, + "mailContext-ignoreSubthread" + ); + + checkRowThreadState(17, "ignoreSubthread"); + checkRowThreadState(18, "ignoreSubthread"); + checkRowThreadState(19, "ignoreSubthread"); + + // Check the notification about the ignored subthread. + + let notification = + notificationBox.getNotificationWithValue("ignoreThreadInfo"); + let label = notification.shadowRoot.querySelector( + "label.notification-message" + ); + Assert.stringContains(label.textContent, testMessages[17].subject); + let buttons = notification.shadowRoot.querySelectorAll( + "button.notification-button" + ); + Assert.equal(buttons.length, 2); + + // Click the Undo button, and check it stops ignoring the subthread. + EventUtils.synthesizeMouseAtCenter(buttons[1], {}, about3Pane); + await TestUtils.waitForCondition(() => !notification.parentNode); + checkRowThreadState(17, false); + checkRowThreadState(18, false); + checkRowThreadState(19, false); +}); + +add_task(async function testWatchThread() { + threadTree.selectedIndex = 20; + await checkMessageMenu({ watchThread: false }); + await checkContextMenu( + 20, + { "mailContext-watchThread": false }, + "mailContext-watchThread" + ); + + checkRowThreadState(20, "watched"); + checkRowThreadState(21, false); + + await checkMessageMenu({ watchThread: true }); + await checkContextMenu( + 20, + { "mailContext-watchThread": true }, + "mailContext-watchThread" + ); + + checkRowThreadState(20, true); + checkRowThreadState(21, false); +}); + +async function checkContextMenu(index, expectedStates, itemToActivate) { + let contextMenu = about3Pane.document.getElementById("mailContext"); + let row = threadTree.getRowAtIndex(index); + + let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".subject-line"), + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + + for (let [id, checkedState] of Object.entries(expectedStates)) { + assertCheckedState(about3Pane.document.getElementById(id), checkedState); + } + + let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + if (itemToActivate) { + contextMenu.activateItem( + about3Pane.document.getElementById(itemToActivate) + ); + } else { + contextMenu.hidePopup(); + } + await hiddenPromise; +} + +async function checkMessageMenu(expectedStates) { + if (AppConstants.platform == "macosx") { + // Can't check the menu. + return; + } + + let messageMenu = document.getElementById("messageMenu"); + + let shownPromise = BrowserTestUtils.waitForEvent( + messageMenu.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(messageMenu, {}, window); + await shownPromise; + + for (let [id, checkedState] of Object.entries(expectedStates)) { + assertCheckedState(document.getElementById(id), checkedState); + } + + messageMenu.menupopup.hidePopup(); +} + +function assertCheckedState(menuItem, checkedState) { + if (checkedState) { + Assert.equal(menuItem.getAttribute("checked"), "true"); + } else { + Assert.ok( + !menuItem.hasAttribute("checked") || + menuItem.getAttribute("checked") == "false" + ); + } +} + +function checkRowThreadState(index, expected) { + let row = threadTree.getRowAtIndex(index); + let icon = row.querySelector(".threadcol-column img"); + + if (!expected) { + Assert.ok( + !row.classList.contains("children"), + "row should not have the 'children' class" + ); + Assert.ok(BrowserTestUtils.is_hidden(icon), "icon should be hidden"); + return; + } + + Assert.ok(BrowserTestUtils.is_visible(icon), "icon should be visible"); + + let shouldHaveChildrenClass = true; + let iconContent = getComputedStyle(icon).content; + + switch (expected) { + case true: + Assert.stringContains(iconContent, "/thread-sm.svg"); + break; + case "ignore": + Assert.stringContains(row.dataset.properties, "ignore"); + Assert.stringContains(iconContent, "/thread-ignored.svg"); + break; + case "ignoreSubthread": + Assert.stringContains(row.dataset.properties, "ignoreSubthread"); + Assert.stringContains(iconContent, "/subthread-ignored.svg"); + shouldHaveChildrenClass = false; + break; + case "watched": + Assert.stringContains(row.dataset.properties, "watch"); + Assert.stringContains(iconContent, "/eye.svg"); + break; + } + + Assert.equal( + row.classList.contains("children"), + shouldHaveChildrenClass, + `row should${ + shouldHaveChildrenClass ? "" : " not" + } have the 'children' class` + ); +} diff --git a/comm/mail/base/test/browser/browser_toolsMenu.js b/comm/mail/base/test/browser/browser_toolsMenu.js new file mode 100644 index 0000000000..8dbf5911c8 --- /dev/null +++ b/comm/mail/base/test/browser/browser_toolsMenu.js @@ -0,0 +1,109 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +/** @type MenuData */ +const toolsMenuData = { + tasksMenuMail: { hidden: true }, + addressBook: {}, + menu_openSavedFilesWnd: {}, + addonsManager: {}, + activityManager: {}, + imAccountsStatus: { disabled: true }, + imStatusAvailable: {}, + imStatusUnavailable: {}, + imStatusOffline: {}, + imStatusShowAccounts: {}, + joinChatMenuItem: { disabled: true }, + filtersCmd: {}, + applyFilters: { disabled: ["mail3PaneTab", "contentTab"] }, + applyFiltersToSelection: { disabled: ["mail3PaneTab", "contentTab"] }, + runJunkControls: { disabled: true }, + deleteJunk: { disabled: true }, + menu_import: {}, + menu_export: {}, + manageKeysOpenPGP: {}, + devtoolsMenu: {}, + devtoolsToolbox: {}, + addonDebugging: {}, + javascriptConsole: {}, + sanitizeHistory: {}, +}; +if (AppConstants.platform == "win") { + toolsMenuData.menu_preferences = {}; + toolsMenuData.menu_accountmgr = {}; +} +let helper = new MenuTestHelper("tasksMenu", toolsMenuData); + +let tabmail = document.getElementById("tabmail"); +let rootFolder, testFolder, testMessages; + +add_setup(async function () { + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("tools menu", null); + testFolder = rootFolder + .getChildNamed("tools menu") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + testFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + testMessages = [...testFolder.messages]; + + window.OpenMessageInNewTab(testMessages[0], { background: true }); + await BrowserTestUtils.waitForEvent( + tabmail.tabInfo[1].chromeBrowser, + "MsgLoaded" + ); + + window.openTab("contentTab", { + url: "https://example.com/", + background: true, + }); + + registerCleanupFunction(() => { + tabmail.closeOtherTabs(0); + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function test3PaneTab() { + tabmail.currentAbout3Pane.displayFolder(rootFolder); + await helper.testAllItems("mail3PaneTab"); + + tabmail.currentAbout3Pane.displayFolder(testFolder); + await helper.testItems({ + applyFilters: {}, + runJunkControls: {}, + deleteJunk: {}, + }); + + tabmail.currentAbout3Pane.threadTree.selectedIndex = 1; + await helper.testItems({ + applyFilters: {}, + applyFiltersToSelection: {}, + runJunkControls: {}, + deleteJunk: {}, + }); +}); + +add_task(async function testMessageTab() { + tabmail.switchToTab(1); + await helper.testAllItems("mailMessageTab"); +}); + +add_task(async function testContentTab() { + tabmail.switchToTab(2); + await helper.testAllItems("contentTab"); +}); diff --git a/comm/mail/base/test/browser/browser_treeListbox.js b/comm/mail/base/test/browser/browser_treeListbox.js new file mode 100644 index 0000000000..558bef991e --- /dev/null +++ b/comm/mail/base/test/browser/browser_treeListbox.js @@ -0,0 +1,1313 @@ +/* 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/. */ + +let tabmail = document.getElementById("tabmail"); +registerCleanupFunction(() => { + tabmail.closeOtherTabs(tabmail.tabInfo[0]); +}); + +async function withTab(callback) { + let tab = tabmail.openTab("contentTab", { + url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/treeListbox.xhtml", + }); + await BrowserTestUtils.browserLoaded(tab.browser); + + tab.browser.focus(); + await SpecialPowers.spawn(tab.browser, [], callback); + + tabmail.closeTab(tab); +} + +add_task(async function testKeyboard() { + await withTab(subtestKeyboard); +}); +add_task(async function testMutation() { + await withTab(subtestMutation); +}); +add_task(async function testExpandCollapse() { + await withTab(subtestExpandCollapse); +}); +add_task(async function testSelectOnRemoval1() { + await withTab(subtestSelectOnRemoval1); +}); +add_task(async function testSelectOnRemoval2() { + await withTab(subtestSelectOnRemoval2); +}); +add_task(async function testSelectOnRemoval3() { + await withTab(subtestSelectOnRemoval3); +}); +add_task(async function testUnselectable() { + await withTab(subtestUnselectable); +}); + +/** + * Tests keyboard navigation up and down the list. + */ +async function subtestKeyboard() { + let doc = content.document; + + let list = doc.querySelector(`ul[is="tree-listbox"]`); + Assert.ok(!!list, "the list exists"); + + let initialRowIds = [ + "row-1", + "row-2", + "row-2-1", + "row-2-2", + "row-3", + "row-3-1", + "row-3-1-1", + "row-3-1-2", + ]; + Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct"); + Assert.deepEqual( + list.rows.map(r => r.id), + initialRowIds, + "initial rows are correct" + ); + Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0"); + + let selectHandler = { + seenEvent: null, + selectedAtEvent: null, + + reset() { + this.seenEvent = null; + this.selectedAtEvent = null; + }, + handleEvent(event) { + this.seenEvent = event; + this.selectedAtEvent = list.selectedIndex; + }, + }; + + function pressKey(key, expectEvent = true) { + info(`pressing ${key}`); + + selectHandler.reset(); + list.addEventListener("select", selectHandler); + EventUtils.synthesizeKey(key, {}, content); + list.removeEventListener("select", selectHandler); + Assert.equal( + !!selectHandler.seenEvent, + expectEvent, + `'select' event ${expectEvent ? "fired" : "did not fire"}` + ); + } + + function checkSelected(expectedIndex, expectedId) { + Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct"); + if (selectHandler.selectedAtEvent !== null) { + // Check the value was already set when the select event fired. + Assert.deepEqual( + selectHandler.selectedAtEvent, + expectedIndex, + "selectedIndex was correct at the last 'select' event" + ); + } + + Assert.deepEqual( + Array.from(list.querySelectorAll(".selected"), row => row.id), + [expectedId], + "correct rows have the 'selected' class" + ); + } + + // Key down the list. + + list.focus(); + for (let i = 1; i < initialRowIds.length; i++) { + pressKey("KEY_ArrowDown"); + checkSelected(i, initialRowIds[i]); + } + + pressKey("KEY_ArrowDown", false); + checkSelected(7, "row-3-1-2"); + + pressKey("KEY_PageDown", false); + checkSelected(7, "row-3-1-2"); + + pressKey("KEY_End", false); + checkSelected(7, "row-3-1-2"); + + // And up again. + + for (let i = initialRowIds.length - 2; i >= 0; i--) { + pressKey("KEY_ArrowUp"); + checkSelected(i, initialRowIds[i]); + } + + pressKey("KEY_ArrowUp", false); + checkSelected(0, "row-1"); + + pressKey("KEY_PageUp", false); + checkSelected(0, "row-1"); + + pressKey("KEY_Home", false); + checkSelected(0, "row-1"); + + // Jump around. + + pressKey("KEY_End"); + checkSelected(7, "row-3-1-2"); + + pressKey("KEY_PageUp"); + checkSelected(0, "row-1"); + + pressKey("KEY_PageDown"); + checkSelected(7, "row-3-1-2"); + + pressKey("KEY_Home"); + checkSelected(0, "row-1"); +} + +/** + * Tests that rows added to or removed from the tree cause their parent rows + * to gain or lose the 'children' class as appropriate. This is done with a + * mutation observer so the tree is not updated immediately, but at the end of + * the event loop. + */ +async function subtestMutation() { + let doc = content.document; + let list = doc.querySelector(`ul[is="tree-listbox"]`); + let idsWithChildren = ["row-2", "row-3", "row-3-1"]; + let idsWithoutChildren = [ + "row-1", + "row-2-1", + "row-2-2", + "row-3-1-1", + "row-3-1-2", + ]; + + // Check the initial state. + + function createNewRow() { + let template = doc.getElementById("rowToAdd"); + return template.content.cloneNode(true).firstElementChild; + } + + function checkHasClass(id, shouldHaveClass = true) { + let row = doc.getElementById(id); + if (shouldHaveClass) { + Assert.ok( + row.classList.contains("children"), + `${id} should have the 'children' class` + ); + } else { + Assert.ok( + !row.classList.contains("children"), + `${id} should NOT have the 'children' class` + ); + } + } + + for (let id of idsWithChildren) { + checkHasClass(id, true); + } + for (let id of idsWithoutChildren) { + checkHasClass(id, false); + } + + // Add a new row without children to the end of the list. + + info("adding new row to end of list"); + let newRow = list.appendChild(createNewRow()); + // Wait for mutation observer. It does nothing, but let's be sure. + await new Promise(r => content.setTimeout(r)); + checkHasClass("new-row", false); + newRow.remove(); + await new Promise(r => content.setTimeout(r)); + + // Add and remove a single row to rows with existing children. + + for (let id of idsWithChildren) { + let row = doc.getElementById(id); + + info(`adding new row to ${id}`); + newRow = row.querySelector("ul").appendChild(createNewRow()); + // Wait for mutation observer. It does nothing, but let's be sure. + await new Promise(r => content.setTimeout(r)); + checkHasClass("new-row", false); + checkHasClass(id, true); + + info(`removing new row from ${id}`); + newRow.remove(); + // Wait for mutation observer. It does nothing, but let's be sure. + await new Promise(r => content.setTimeout(r)); + checkHasClass(id, true); + + if (id == "row-3-1") { + checkHasClass("row-3", true); + } + } + + // Add and remove a single row to rows without existing children. + + for (let id of idsWithoutChildren) { + let row = doc.getElementById(id); + let childList = row.appendChild(doc.createElement("ul")); + + info(`adding new row to ${id}`); + newRow = childList.appendChild(createNewRow()); + // Wait for mutation observer. + await new Promise(r => content.setTimeout(r)); + checkHasClass("new-row", false); + checkHasClass(id, true); + + info(`removing new row from ${id}`); + newRow.remove(); + // Wait for mutation observer. + await new Promise(r => content.setTimeout(r)); + checkHasClass(id, false); + + // This time remove the child list, not the row itself. + + info(`adding new row to ${id} again`); + newRow = childList.appendChild(createNewRow()); + // Wait for mutation observer. + await new Promise(r => content.setTimeout(r)); + checkHasClass("new-row", false); + checkHasClass(id, true); + + info(`removing child list from ${id}`); + childList.remove(); + // Wait for mutation observer. + await new Promise(r => content.setTimeout(r)); + checkHasClass(id, false); + + if (["row-2-1", "row-2-2"].includes(id)) { + checkHasClass("row-2", true); + } else if (["row-3-1-1", "row-3-1-2"].includes(id)) { + checkHasClass("row-3-1", true); + checkHasClass("row-3", true); + } + } + + // Add a row with children and a grandchild to the end of the list. The new + // row should be given the "children" class. The child with a grandchild + // should be given the "children" class. I think it's safe to assume this + // works no matter where in the tree it's added. + + let template = doc.getElementById("rowsToAdd"); + newRow = template.content.cloneNode(true).firstElementChild; + list.appendChild(newRow); + // Wait for mutation observer. + await new Promise(r => content.setTimeout(r)); + checkHasClass("added-row", true); + checkHasClass("added-row-1", true); + checkHasClass("added-row-1-1", false); + checkHasClass("added-row-2", false); + newRow.remove(); + await new Promise(r => content.setTimeout(r)); + + // Add a new row without children to the middle of the list. Selection should + // be maintained. + + list.selectedIndex = 5; // row-3-1 + + info("adding new row to middle of list"); + newRow = template.content.cloneNode(true).firstElementChild; + list.insertBefore(newRow, list.querySelector("#row-3")); + await new Promise(r => content.setTimeout(r)); + Assert.equal(list.selectedIndex, 9, "row-3-1 is still selected"); + + newRow.remove(); + await new Promise(r => content.setTimeout(r)); + Assert.equal(list.selectedIndex, 5, "row-3-1 is still selected"); + + list.selectedIndex = 0; +} + +/** + * Checks that expanding and collapsing works. Twisties in the test file are + * styled as coloured squares: red for collapsed, green for expanded. + * + * @note This is practically the same test as in browser_treeView.js, + * but for TreeListbox instead of TreeView. If you make changes here + * you may want to make changes there too. + */ +async function subtestExpandCollapse() { + let doc = content.document; + let list = doc.querySelector(`ul[is="tree-listbox"]`); + let allIds = [ + "row-1", + "row-2", + "row-2-1", + "row-2-2", + "row-3", + "row-3-1", + "row-3-1-1", + "row-3-1-2", + ]; + let idsWithoutChildren = [ + "row-1", + "row-2-1", + "row-2-2", + "row-3-1-1", + "row-3-1-2", + ]; + + let listener = { + reset() { + this.collapsedRow = null; + this.expandedRow = null; + }, + handleEvent(event) { + if (event.type == "collapsed") { + this.collapsedRow = event.target; + } else if (event.type == "expanded") { + this.expandedRow = event.target; + } + }, + }; + list.addEventListener("collapsed", listener); + list.addEventListener("expanded", listener); + + let selectHandler = { + seenEvent: null, + selectedAtEvent: null, + + reset() { + this.seenEvent = null; + this.selectedAtEvent = null; + }, + handleEvent(event) { + this.seenEvent = event; + this.selectedAtEvent = list.selectedIndex; + }, + }; + + Assert.equal( + list.querySelectorAll("collapsed").length, + 0, + "no rows are collapsed" + ); + Assert.equal(list.rowCount, 8, "row count"); + Assert.deepEqual( + list.rows.map(r => r.id), + [ + "row-1", + "row-2", + "row-2-1", + "row-2-2", + "row-3", + "row-3-1", + "row-3-1-1", + "row-3-1-2", + ], + "rows property" + ); + + function checkSelected(expectedIndex, expectedId) { + Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct"); + let selected = [...list.querySelectorAll(".selected")].map(row => row.id); + Assert.deepEqual( + selected, + [expectedId], + "correct rows have the 'selected' class" + ); + } + + checkSelected(0, "row-1"); + + // Click the twisties of rows without children. + + function performChange(id, expectedChange, changeCallback) { + listener.reset(); + let row = doc.getElementById(id); + let before = row.classList.contains("collapsed"); + + changeCallback(row); + + if (expectedChange == "collapsed") { + Assert.ok(!before, `${id} was expanded`); + Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`); + Assert.equal(listener.collapsedRow, row, `${id} fired 'collapse' event`); + Assert.ok(!listener.expandedRow, `${id} did not fire 'expand' event`); + } else if (expectedChange == "expanded") { + Assert.ok(before, `${id} was collapsed`); + Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`); + Assert.ok(!listener.collapsedRow, `${id} did not fire 'collapse' event`); + Assert.equal(listener.expandedRow, row, `${id} fired 'expand' event`); + } else { + Assert.equal( + row.classList.contains("collapsed"), + before, + `${id} state did not change` + ); + } + } + + function clickTwisty(id, expectedChange) { + info(`clicking the twisty on ${id}`); + performChange(id, expectedChange, row => + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".twisty"), + {}, + content + ) + ); + } + + function doubleClick(id, expectedChange) { + info(`double clicking on ${id}`); + performChange(id, expectedChange, row => + EventUtils.synthesizeMouseAtCenter(row, { clickCount: 2 }, content) + ); + } + + for (let id of idsWithoutChildren) { + clickTwisty(id, null); + Assert.equal(list.querySelector(".selected").id, id); + } + + checkSelected(7, "row-3-1-2"); + + // Click the twisties of rows with children. + + function checkRowsAreHidden(...hiddenIds) { + let remainingIds = allIds.slice(); + + for (let id of allIds) { + if (hiddenIds.includes(id)) { + Assert.equal(doc.getElementById(id).clientHeight, 0, `${id} is hidden`); + remainingIds.splice(remainingIds.indexOf(id), 1); + } else { + Assert.greater( + doc.getElementById(id).clientHeight, + 0, + `${id} is visible` + ); + } + } + + Assert.equal(list.rowCount, 8 - hiddenIds.length, "row count"); + Assert.deepEqual( + list.rows.map(r => r.id), + remainingIds, + "rows property" + ); + } + + // Collapse row 2. + + clickTwisty("row-2", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelected(5, "row-3-1-2"); + + // Collapse row 3. + + clickTwisty("row-3", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(2, "row-3"); + + // Expand row 2. + + doubleClick("row-2", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + // Expand row 3. + + doubleClick("row-3", "expanded"); + checkRowsAreHidden(); + checkSelected(4, "row-3"); + + // Collapse row 3-1. + + clickTwisty("row-3-1", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + // Collapse row 3. + + clickTwisty("row-3", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + // Expand row 3. + + clickTwisty("row-3", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + // Expand row 3-1. + + clickTwisty("row-3-1", "expanded"); + checkRowsAreHidden(); + checkSelected(4, "row-3"); + + // Test key presses. + + function pressKey(id, key, expectedChange) { + info(`pressing ${key}`); + performChange(id, expectedChange, row => { + EventUtils.synthesizeKey(key, {}, content); + }); + } + + // Row 0 has no children or parent, nothing should happen. + + list.selectedIndex = 0; + pressKey("row-1", "VK_LEFT"); + checkSelected(0, "row-1"); + pressKey("row-1", "VK_RIGHT"); + checkSelected(0, "row-1"); + + // Collapse row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelected(1, "row-2"); + + pressKey("row-2", "VK_LEFT"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelected(1, "row-2"); + + // Collapse row 3. + + list.selectedIndex = 2; + pressKey("row-3", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(2, "row-3"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(2, "row-3"); + + // Expand row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_RIGHT", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(1, "row-2"); + + // Expand row 3. + + list.selectedIndex = 4; + pressKey("row-3", "VK_RIGHT", "expanded"); + checkRowsAreHidden(); + checkSelected(4, "row-3"); + + // Go down the tree to row 3-1-1. + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + // Collapse row 3-1. + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + + pressKey("row-3-1", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(5, "row-3-1"); + + // Collapse row 3. + + pressKey("row-3-1", "VK_LEFT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + pressKey("row-3", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + // Expand row 3. + + pressKey("row-3", "VK_RIGHT", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(5, "row-3-1"); + + // Expand row 3-1. + + pressKey("row-3-1", "VK_RIGHT", "expanded"); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + + pressKey("row-3-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + // Toggle expansion of row 3-1 with Enter key. + + list.selectedIndex = 5; + pressKey("row-3-1", "KEY_Enter", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(5, "row-3-1"); + + pressKey("row-3-1", "KEY_Enter", "expanded"); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + + // Same again, with a RTL tree. + + info("switching to RTL"); + doc.documentElement.dir = "rtl"; + + // Row 0 has no children or parent, nothing should happen. + + list.selectedIndex = 0; + pressKey("row-1", "VK_RIGHT"); + checkSelected(0, "row-1"); + pressKey("row-1", "VK_LEFT"); + checkSelected(0, "row-1"); + + // Collapse row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelected(1, "row-2"); + + pressKey("row-2", "VK_RIGHT"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelected(1, "row-2"); + + // Collapse row 3. + + list.selectedIndex = 2; + pressKey("row-3", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(2, "row-3"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(2, "row-3"); + + // Expand row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_LEFT", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(1, "row-2"); + + // Expand row 3. + + list.selectedIndex = 4; + pressKey("row-3", "VK_LEFT", "expanded"); + checkRowsAreHidden(); + checkSelected(4, "row-3"); + + // Go down the tree to row 3-1-1. + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + // Collapse row 3-1. + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + + pressKey("row-3-1", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(5, "row-3-1"); + + // Collapse row 3. + + pressKey("row-3-1", "VK_RIGHT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + pressKey("row-3", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + // Expand row 3. + + pressKey("row-3", "VK_LEFT", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(5, "row-3-1"); + + // Expand row 3-1. + + pressKey("row-3-1", "VK_LEFT", "expanded"); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + + pressKey("row-3-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelected(6, "row-3-1-1"); + + // Use the class methods for expanding and collapsing. + + selectHandler.reset(); + list.addEventListener("select", selectHandler); + listener.reset(); + + list.collapseRowAtIndex(6); // No children, no effect. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.ok(!listener.collapsedRow, "'collapsed' event did not fire"); + + list.expandRowAtIndex(6); // No children, no effect. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.ok(!listener.expandedRow, "'expanded' event did not fire"); + + list.collapseRowAtIndex(1); // Item with children that aren't selected. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal( + listener.collapsedRow.id, + "row-2", + "row-2 fired 'collapsed' event" + ); + listener.reset(); + + list.expandRowAtIndex(1); // Item with children that aren't selected. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal( + listener.expandedRow.id, + "row-2", + "row-2 fired 'expanded' event" + ); + listener.reset(); + + list.collapseRowAtIndex(5); // Item with children that are selected. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 5, + "selectedIndex was correct when 'select' event fired" + ); + Assert.equal( + listener.collapsedRow.id, + "row-3-1", + "row-3-1 fired 'collapsed' event" + ); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelected(5, "row-3-1"); + selectHandler.reset(); + listener.reset(); + + list.expandRowAtIndex(5); // Selected item with children. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal( + listener.expandedRow.id, + "row-3-1", + "row-3-1 fired 'expanded' event" + ); + checkRowsAreHidden(); + checkSelected(5, "row-3-1"); + listener.reset(); + + list.selectedIndex = 7; + selectHandler.reset(); + + list.collapseRowAtIndex(4); // Item with grandchildren that are selected. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 4, + "selectedIndex was correct when 'select' event fired" + ); + Assert.equal( + listener.collapsedRow.id, + "row-3", + "row-3 fired 'collapsed' event" + ); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelected(4, "row-3"); + selectHandler.reset(); + listener.reset(); + + list.expandRowAtIndex(4); // Selected item with grandchildren. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal( + listener.expandedRow.id, + "row-3", + "row-3 fired 'expanded' event" + ); + checkRowsAreHidden(); + checkSelected(4, "row-3"); + listener.reset(); + + list.removeEventListener("collapsed", listener); + list.removeEventListener("expanded", listener); + list.removeEventListener("select", selectHandler); + doc.documentElement.dir = null; +} + +/** + * Tests what happens to selection when a row is removed. + */ +async function subtestSelectOnRemoval1() { + let doc = content.document; + let list = doc.getElementById("deleteTree"); + + let selectPromise; + function promiseSelectEvent() { + selectPromise = new Promise(resolve => + list.addEventListener( + "select", + () => resolve([list.selectedIndex, list.selectedRow?.id ?? null]), + { + once: true, + } + ) + ); + } + // dRow-1 + // dRow-2 + // dRow-2-1 + // dRow-2-2 + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-2 + // dRow-3-1-3 + // dRow-4 + // dRow-4-1 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-4-4 + // dRow-5 + // dRow-5-1 + // dRow-6 + + // Delete a row that is selected, and not at the top level. Selection should + // move to the next row under the shared parent. + + list.selectedIndex = 2; + Assert.equal(list.selectedRow.id, "dRow-2-1"); + + promiseSelectEvent(); + list.querySelector("#dRow-2-1").remove(); + Assert.deepEqual( + await selectPromise, + [2, "dRow-2-2"], + "selection moved to the next row" + ); + + // dRow-1 + // dRow-2 + // dRow-2-2 + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-2 + // dRow-3-1-3 + // dRow-4 + // dRow-4-1 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-4-4 + // dRow-5 + // dRow-5-1 + // dRow-6 + + // Delete a row that contains the selected, and at the top level. Selection + // should move to the next top-level row. + + Assert.equal(list.selectedRow.id, "dRow-2-2"); + promiseSelectEvent(); + list.querySelector("#dRow-2").remove(); + Assert.deepEqual( + await selectPromise, + [1, "dRow-3"], + "selection moved to the next row" + ); + + // dRow-1 + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-2 + // dRow-3-1-3 + // dRow-4 + // dRow-4-1 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-4-4 + // dRow-5 + // dRow-5-1 + // dRow-6 + + // Delete the first top-level row that is selected. Should select the first + // row. + list.selectedIndex = 0; + Assert.equal(list.selectedRow.id, "dRow-1"); + promiseSelectEvent(); + list.querySelector("#dRow-1").remove(); + Assert.deepEqual( + await selectPromise, + [0, "dRow-3"], + "selection moved to the first row" + ); + + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-2 + // dRow-3-1-3 + // dRow-4 + // dRow-4-1 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-4-4 + // dRow-5 + // dRow-5-1 + // dRow-6 + + // Delete the last top-level row that is selected. Should select the last row. + list.selectedIndex = 14; + Assert.equal(list.selectedRow.id, "dRow-6"); + promiseSelectEvent(); + list.querySelector("#dRow-6").remove(); + Assert.deepEqual( + await selectPromise, + [13, "dRow-5-1"], + "selection moved to the new last row" + ); + + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-2 + // dRow-3-1-3 + // dRow-4 + // dRow-4-1 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-4-4 + // dRow-5 + // dRow-5-1 + + // Delete the last selected descendant should move selection to the new + // descendant child. + list.selectedIndex = 11; + Assert.equal(list.selectedRow.id, "dRow-4-4"); + promiseSelectEvent(); + list.querySelector("#dRow-4-4").remove(); + Assert.deepEqual( + await selectPromise, + [10, "dRow-4-3-2"], + "selection moved to the new last row" + ); + + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-2 + // dRow-3-1-3 + // dRow-4 + // dRow-4-1 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-5 + // dRow-5-1 + + // Delete the first selected child should move selection to the new first child. + list.selectedIndex = 6; + Assert.equal(list.selectedRow.id, "dRow-4-1"); + promiseSelectEvent(); + list.querySelector("#dRow-4-1").remove(); + Assert.deepEqual( + await selectPromise, + [6, "dRow-4-2"], + "selection moved to the new first row" + ); + + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-2 + // dRow-3-1-3 + // dRow-4 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-5 + // dRow-5-1 + + // Delete a row that isn't selected. Nothing should happen. + + list.selectedIndex = 2; + Assert.equal(list.selectedRow.id, "dRow-3-1-1"); + + list.querySelector("#dRow-3-1-2").remove(); + await new Promise(resolve => content.setTimeout(resolve)); + Assert.equal(list.selectedIndex, 2, "selection did not change"); + Assert.equal(list.selectedRow.id, "dRow-3-1-1", "selection did not change"); + + // dRow-3 + // dRow-3-1 + // dRow-3-1-1 + // dRow-3-1-3 + // dRow-4 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-5 + // dRow-5-1 + + // Deleting the last row under a parent that contains the selection should + // select the parent. + list.selectedIndex = 2; + Assert.equal(list.selectedRow.id, "dRow-3-1-1"); + + promiseSelectEvent(); + let rowToReplace = list.querySelector("#dRow-3-1"); + rowToReplace.remove(); + Assert.deepEqual( + await selectPromise, + [0, "dRow-3"], + "selection moved to the parent row" + ); + + // dRow-3 + // dRow-4 + // dRow-4-2 + // dRow-4-3 + // dRow-4-3-1 + // dRow-4-3-2 + // dRow-5 + // dRow-5-1 + + // Deleting several rows under a parent, should select the parent row. + list.selectedIndex = 4; + Assert.equal(list.selectedRow.id, "dRow-4-3-1"); + promiseSelectEvent(); + list.querySelector("#dRow-4 ul").remove(); + Assert.deepEqual( + await selectPromise, + [1, "dRow-4"], + "selection moved to the parent row" + ); + + // Delete the last remaining rows. The selected index should be -1. + + promiseSelectEvent(); + list.replaceChildren(); + Assert.deepEqual(await selectPromise, [-1, null], "selection was cleared"); + + // Add back a row. One of the row's children was selected, this should be + // removed and the selection set to the top-level row. + + promiseSelectEvent(); + list.appendChild(rowToReplace); + Assert.deepEqual( + await selectPromise, + [0, "dRow-3-1"], + "selection set to the added row" + ); + Assert.ok(list.querySelector("#dRow-3-1-1"), "child of the added row exists"); + Assert.ok( + !list.querySelector("#dRow-3-1-1").classList.contains("selected"), + "child of the added row is not selected" + ); +} + +/** + * Tests what happens to selection when a row is removed. + */ +async function subtestSelectOnRemoval2() { + let doc = content.document; + let list = doc.querySelector(`ul[is="tree-listbox"]`); + + let selectPromise; + function promiseSelectEvent() { + selectPromise = new Promise(resolve => + list.addEventListener("select", () => resolve(list.selectedIndex), { + once: true, + }) + ); + } + + // Delete row-3 containing the selection. + + list.selectedIndex = 7; // row-3-1-2 + + promiseSelectEvent(); + list.querySelector("#row-3").remove(); + Assert.equal(await selectPromise, 3, "selection moved to the last row"); + + // Delete row-2. Selection should move to the only row. + + promiseSelectEvent(); + list.querySelector("#row-2").remove(); + Assert.equal( + await selectPromise, + 0, // row-1 + "selection moved to the last row" + ); +} + +/** + * Tests what happens to selection when elements above it are removed. + */ +async function subtestSelectOnRemoval3() { + let doc = content.document; + let list = doc.querySelector(`ul[is="tree-listbox"]`); + + // Delete a row. + + list.selectedIndex = 6; // row-3-1-1 + + list.querySelector("#row-2-1").remove(); + await new Promise(r => content.setTimeout(r)); + Assert.deepEqual( + list.rows.map(r => r.id), + ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"] + ); + Assert.equal( + list.selectedIndex, + 5, // row-3-1-1 + "selection moved to the previous top-level row" + ); + + // Delete an element that isn't a row. + + list.querySelector("#row-2 div").remove(); + await new Promise(r => content.setTimeout(r)); + Assert.deepEqual( + list.rows.map(r => r.id), + ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"] + ); + Assert.equal( + list.selectedIndex, + 5, // row-3-1-1 + "selection moved to the previous top-level row" + ); + + // Delete an element that contains a row. + + list.querySelector("#row-2 ul").remove(); + await new Promise(r => content.setTimeout(r)); + Assert.deepEqual( + list.rows.map(r => r.id), + ["row-1", "row-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"] + ); + Assert.equal( + list.selectedIndex, + 4, // row-3-1-1 + "selection moved to the previous top-level row" + ); +} + +/** + * Tests that rows marked as unselectable cannot be selected. + */ +async function subtestUnselectable() { + let doc = content.document; + + let list = doc.querySelector(`ul#unselectableTree`); + Assert.ok(!!list, "the list exists"); + + let initialRowIds = [ + "uRow-2-1", + "uRow-2-2", + "uRow-3-1", + "uRow-3-1-1", + "uRow-3-1-2", + ]; + Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct"); + Assert.deepEqual( + list.rows.map(r => r.id), + initialRowIds, + "initial rows are correct" + ); + + function checkSelected(expectedIndex, expectedId) { + Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct"); + Assert.deepEqual( + Array.from(list.querySelectorAll(".selected"), row => row.id), + [expectedId], + "correct rows have the 'selected' class" + ); + } + + checkSelected(0, "uRow-2-1"); + + // Clicking unselectable rows should not change the selection. + EventUtils.synthesizeMouseAtCenter( + doc.querySelector("#uRow-1 > div"), + {}, + content + ); + checkSelected(0, "uRow-2-1"); + EventUtils.synthesizeMouseAtCenter( + doc.querySelector("#uRow-2 > div"), + {}, + content + ); + checkSelected(0, "uRow-2-1"); + EventUtils.synthesizeMouseAtCenter( + doc.querySelector("#uRow-3 > div"), + {}, + content + ); + checkSelected(0, "uRow-2-1"); + + // Unselectable rows should not be accessible by keyboard. + EventUtils.synthesizeKey("KEY_ArrowUp", {}, content); + checkSelected(0, "uRow-2-1"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, content); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, content); + checkSelected(2, "uRow-3-1"); + EventUtils.synthesizeKey("KEY_Home", {}, content); + checkSelected(0, "uRow-2-1"); + EventUtils.synthesizeKey("KEY_End", {}, content); + checkSelected(4, "uRow-3-1-2"); + EventUtils.synthesizeKey("KEY_PageUp", {}, content); + checkSelected(0, "uRow-2-1"); + EventUtils.synthesizeKey("KEY_PageDown", {}, content); + checkSelected(4, "uRow-3-1-2"); + + EventUtils.synthesizeKey("VK_LEFT", {}, content); // Move up to 3-1. + checkSelected(2, "uRow-3-1"); + EventUtils.synthesizeKey("VK_LEFT", {}, content); // Collapse. + checkSelected(2, "uRow-3-1"); + EventUtils.synthesizeKey("VK_LEFT", {}, content); // Try to move to 3. + checkSelected(2, "uRow-3-1"); +} diff --git a/comm/mail/base/test/browser/browser_treeView.js b/comm/mail/base/test/browser/browser_treeView.js new file mode 100644 index 0000000000..bf1eeda35a --- /dev/null +++ b/comm/mail/base/test/browser/browser_treeView.js @@ -0,0 +1,1941 @@ +/* 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/. */ + +let tabmail = document.getElementById("tabmail"); +registerCleanupFunction(() => { + tabmail.closeOtherTabs(tabmail.tabInfo[0]); +}); + +// We wish to run several variants of each test with minor differences, based on +// changes in the source file, in order to verify that certain variables don't +// impact behavior. +const TEST_VARIANTS = ["header", "no-header"]; + +/** + * Run a given test in a new tab and script sandbox. + * + * @param {Function} test - The test function to run in the sandbox. + * @param {string} filenameFragment - The fragment of the filename representing + * the test variant to run. + * @param {object[]} sandboxArgs - Arguments to the sandbox spawner to pass to + * the test function. + */ +async function runTestInSandbox(test, filenameFragment, sandboxArgs = []) { + // Create a new tab with our custom content. + const tab = tabmail.openTab("contentTab", { + url: `chrome://mochitests/content/browser/comm/mail/base/test/browser/files/tree-element-test-${filenameFragment}.xhtml`, + }); + + await BrowserTestUtils.browserLoaded(tab.browser); + tab.browser.focus(); + + // Spawn a new JavaScript sandbox for the tab and run the test function inside + // of it. + await SpecialPowers.spawn(tab.browser, sandboxArgs, test); + + tabmail.closeTab(tab); +} + +/** + * Checks that interactions with the widget do as expected. + */ +add_task(async function testKeyboardAndMouse() { + for (const variant of TEST_VARIANTS) { + info(`Running keyboard and mouse test for ${variant}`); + await runTestInSandbox(subtestKeyboardAndMouse, variant, [variant]); + } +}); + +async function subtestKeyboardAndMouse(variant) { + let doc = content.document; + + let list = doc.getElementById("testTree"); + Assert.ok(!!list, "the list exists"); + + async function doListActionAndWaitForRowBuffer(actionFn) { + // Filling the row buffer is fiddly timing, so provide an event to indicate + // that actions which may trigger changes in the row buffer have finished. + const eventName = "_treerowbufferfill"; + list._rowBufferReadyEvent = new content.CustomEvent(eventName); + + const promise = new Promise(resolve => + list.addEventListener(eventName, resolve, { once: true }) + ); + + await actionFn(); + await promise; + + list._rowBufferReadyEvent = null; + } + + async function scrollListToPosition(topOfScroll) { + await doListActionAndWaitForRowBuffer(() => { + list.scrollTo(0, topOfScroll); + }); + } + + Assert.equal(list._rowElementName, "test-row"); + Assert.equal(list._rowElementClass, content.customElements.get("test-row")); + Assert.equal( + list._toleranceSize, + 26, + "list should have tolerance twice the number of visible rows" + ); + + // We should be scrolled to the top already, but this will ensure we get an + // event fire one way or another. + await scrollListToPosition(0); + + let rows = list.querySelectorAll(`tr[is="test-row"]`); + // Count is calculated from the height of `list` divided by + // TestCardRow.ROW_HEIGHT, plus list._toleranceSize. + Assert.equal(rows.length, 13 + 26, "the list has the right number of rows"); + + Assert.equal(doc.activeElement, doc.body); + + // Verify the tab order of list elements by tabbing both forward and backward + // through them. + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal( + doc.activeElement.id, + "before", + "the element before the list should have focus" + ); + + if (variant == "header") { + // Tab order changes slightly if the table has a header versus if it + // doesn't, so we need to account for that variation. + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal( + doc.activeElement.id, + "testColButton", + "the list header button should have focus" + ); + } + + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal(doc.activeElement.id, "testBody", "the list should have focus"); + + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal( + doc.activeElement.id, + "after", + "the element after the list should have focus" + ); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content); + Assert.equal(doc.activeElement.id, "testBody", "the list should have focus"); + + if (variant == "header") { + // Tab order changes slightly if the table has a header versus if it + // doesn't, so we need to account for that variation. + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content); + Assert.equal( + doc.activeElement.id, + "testColButton", + "the list header button should have focus" + ); + } + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content); + Assert.equal( + doc.activeElement.id, + "before", + "the element before the list should have focus" + ); + + // Check initial selection. + + let selectHandler = { + seenEvent: null, + currentAtEvent: null, + selectedAtEvent: null, + t0: Date.now(), + time: 0, + + reset() { + this.seenEvent = null; + this.currentAtEvent = null; + this.selectedAtEvent = null; + this.t0 = Date.now(); + }, + handleEvent(event) { + this.seenEvent = event; + this.currentAtEvent = list.currentIndex; + this.selectedAtEvent = list.selectedIndices; + this.time = Date.now() - this.t0; + }, + }; + + /** + * Check if the spacerTop TBODY of the TreeViewTable is properly allocating + * the height of non existing rows. + * + * @param {int} rows - The number of rows that the spacerTop should be + * simulating their height allocation. + */ + function checkTopSpacerHeight(rows) { + let table = doc.querySelector(`[is="tree-view-table"]`); + // -26 to account for the tolerance buffer. + Assert.equal( + table.spacerTop.clientHeight, + list.getRowAtIndex(rows).clientHeight * (rows - 26), + "The top spacer has the correct height" + ); + } + + function checkCurrent(expectedIndex) { + Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct"); + if (selectHandler.currentAtEvent !== null) { + Assert.equal( + selectHandler.currentAtEvent, + expectedIndex, + "currentIndex was correct at the last 'select' event" + ); + } + + let current = list.querySelectorAll(".current"); + if (expectedIndex == -1) { + Assert.equal(current.length, 0, "no rows have the 'current' class"); + } else { + Assert.equal(current.length, 1, "only one row has the 'current' class"); + Assert.equal( + current[0].index, + expectedIndex, + "correct row has the 'current' class" + ); + } + } + + function checkSelected(...expectedIndices) { + Assert.deepEqual( + list.selectedIndices, + expectedIndices, + "selectedIndices are correct" + ); + + if (selectHandler.selectedAtEvent !== null) { + // Check the value was already set when the select event fired. + Assert.deepEqual( + selectHandler.selectedAtEvent, + expectedIndices, + "selectedIndices were correct at the last 'select' event" + ); + } + + let selected = [...list.querySelectorAll(".selected")].map( + row => row.index + ); + expectedIndices.sort((a, b) => a - b); + Assert.deepEqual( + selected, + expectedIndices, + "correct rows have the 'selected' class" + ); + } + + checkCurrent(0); + checkSelected(0); + + // Click on some individual rows. + + const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" + ); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + async function clickOnRow(index, modifiers = {}, expectEvent = true) { + if (modifiers.shiftKey) { + info(`clicking on row ${index} with shift key`); + } else if (modifiers.accelKey) { + info(`clicking on row ${index} with ctrl key`); + } else { + info(`clicking on row ${index}`); + } + + let x = list.clientWidth / 2; + let y = list.table.header.clientHeight + index * 50 + 25; + + selectHandler.reset(); + list.addEventListener("select", selectHandler, { once: true }); + EventUtils.synthesizeMouse(list, x, y, modifiers, content); + await TestUtils.waitForCondition( + () => !!selectHandler.seenEvent == expectEvent, + `'select' event should ${expectEvent ? "" : "not "}get fired` + ); + } + + await clickOnRow(0, {}, false); + checkCurrent(0); + checkSelected(0); + + await clickOnRow(1); + checkCurrent(1); + checkSelected(1); + + await clickOnRow(2); + checkCurrent(2); + checkSelected(2); + + // Select multiple rows by shift-clicking. + + await clickOnRow(4, { shiftKey: true }); + checkCurrent(4); + checkSelected(2, 3, 4); + + // Holding ctrl and shift should always produce a range selection. + await clickOnRow(6, { accelKey: true, shiftKey: true }); + checkCurrent(6); + checkSelected(2, 3, 4, 5, 6); + + await clickOnRow(0, { shiftKey: true }); + checkCurrent(0); + checkSelected(0, 1, 2); + + await clickOnRow(2, { shiftKey: true }); + checkCurrent(2); + checkSelected(2); + + // Select multiple rows by ctrl-clicking. + + await clickOnRow(5, { accelKey: true }); + checkCurrent(5); + checkSelected(2, 5); + + await clickOnRow(1, { accelKey: true }); + checkCurrent(1); + checkSelected(1, 2, 5); + + await clickOnRow(5, { accelKey: true }); + checkCurrent(5); + checkSelected(1, 2); + + await clickOnRow(1, { accelKey: true }); + checkCurrent(1); + checkSelected(2); + + await clickOnRow(2, { accelKey: true }); + checkCurrent(2); + checkSelected(); + + // Move around by pressing keys. + + async function pressKey(key, modifiers = {}, expectEvent = true) { + if (modifiers.shiftKey) { + info(`pressing ${key} with shift key`); + } else if (modifiers.accelKey) { + info(`pressing ${key} with accel key`); + } else { + info(`pressing ${key}`); + } + + selectHandler.reset(); + list.addEventListener("select", selectHandler, { once: true }); + EventUtils.synthesizeKey(key, modifiers, content); + await TestUtils.waitForCondition( + () => !!selectHandler.seenEvent == expectEvent, + `'select' event should ${expectEvent ? "" : "not "}get fired` + ); + // We don't enforce any delay on multiselection. + let multiselect = + (AppConstants.platform == "macosx" && key == " ") || + modifiers.shiftKey || + modifiers.accelKey; + if (expectEvent && !multiselect) { + // We have data-select-delay="250" in treeView.xhtml + Assert.greater(selectHandler.time, 240, "should select only after delay"); + } + } + + await pressKey("VK_UP"); + checkCurrent(1); + checkSelected(1); + + await pressKey("VK_UP", { accelKey: true }, false); + checkCurrent(0); + checkSelected(1); + + // Without Ctrl selection moves with focus again. + await pressKey("VK_UP"); + checkCurrent(0); + checkSelected(0); + + // Does nothing. + await pressKey("VK_UP", {}, false); + checkCurrent(0); + checkSelected(0); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(1); + checkSelected(0); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(2); + checkSelected(0); + + // Multi select with only Space on macOS on a focused row, since Cmd+Space is + // captured by the OS. + if (AppConstants.platform == "macosx") { + await pressKey(" "); + } else { + // Multi select with Ctrl+Space for Windows and Linux. + await pressKey(" ", { accelKey: true }); + } + checkCurrent(2); + checkSelected(0, 2); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(3); + checkSelected(0, 2); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(4); + checkSelected(0, 2); + + if (AppConstants.platform == "macosx") { + await pressKey(" "); + } else { + await pressKey(" ", { accelKey: true }); + } + checkCurrent(4); + checkSelected(0, 2, 4); + + // Single selection restored with normal navigation. + await pressKey("VK_UP"); + checkCurrent(3); + checkSelected(3); + + // We don't allow unselecting a selected row with Space on macOS due to + // conflict with the `mail.advance_on_spacebar` pref. + if (AppConstants.platform != "macosx") { + // Can select none using Ctrl+Space. + await pressKey(" ", { accelKey: true }); + checkCurrent(3); + checkSelected(); + } + + await pressKey("VK_DOWN"); + checkCurrent(4); + checkSelected(4); + + await pressKey("VK_HOME", { accelKey: true }, false); + checkCurrent(0); + checkSelected(4); + + if (AppConstants.platform == "macosx") { + // We can't clear the selection with only Space on macOS, simulate a Arrow + // Up to force clear selection and only select the top most row. + await pressKey("VK_UP"); + } else { + // Select only the current item with Space (no modifier). + await pressKey(" "); + } + checkCurrent(0); + checkSelected(0); + + // The list is 630px high, so rows 0-12 are fully or partly visible. + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_DOWN"); + }); + checkCurrent(13); + checkSelected(13); + Assert.equal( + list.getFirstVisibleIndex(), + 1, + "should have scrolled down a page" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_UP", { shiftKey: true }); + }); + checkCurrent(0); + checkSelected(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "should have scrolled up a page" + ); + + // Shrink shift selection. + await pressKey("VK_DOWN", { shiftKey: true }); + checkCurrent(1); + checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(2); + checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(3); + checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + if (AppConstants.platform == "macosx") { + // We don't allow unselecting a selected row with Space on macOS due to + // conflict with the `mail.advance_on_spacebar` pref, so simulate a + // CMD+click to unselect it. + await clickOnRow(3, { accelKey: true }); + } else { + // Break the shift sequence by Ctrl+Space. + await pressKey(" ", { accelKey: true }); + } + checkCurrent(3); + checkSelected(1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + await pressKey("VK_DOWN", { shiftKey: true }); + checkCurrent(4); + checkSelected(3, 4); + + // Reverse selection direction. + await pressKey("VK_HOME", { shiftKey: true }); + checkCurrent(0); + checkSelected(0, 1, 2, 3); + + // Now rows 138-149 are fully visible. + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_END"); + }); + checkCurrent(149); + checkSelected(149); + Assert.equal( + list.getFirstVisibleIndex(), + 137, + "should have scrolled to the end" + ); + checkTopSpacerHeight(137); + + // Does nothing. + await pressKey("VK_DOWN", {}, false); + checkCurrent(149); + checkSelected(149); + Assert.equal( + list.getFirstVisibleIndex(), + 137, + "should not have changed view" + ); + checkTopSpacerHeight(137); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_UP"); + }); + checkCurrent(136); + checkSelected(136); + Assert.equal( + list.getFirstVisibleIndex(), + 136, + "should have scrolled up a page" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_DOWN", { shiftKey: true }); + }); + checkCurrent(149); + checkSelected( + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149 + ); + Assert.equal( + list.getFirstVisibleIndex(), + 137, + "should have scrolled down a page" + ); + checkTopSpacerHeight(137); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_HOME"); + }); + checkCurrent(0); + checkSelected(0); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "should have scrolled to the beginning" + ); + + // Scroll around. Which rows are current and selected should be remembered + // even if the row element itself disappears. + + selectHandler.reset(); + await scrollListToPosition(125); + checkCurrent(0); + checkSelected(0); + Assert.equal( + list.getFirstVisibleIndex(), + 2, + "getFirstVisibleIndex is correct" + ); + + await scrollListToPosition(2525); + Assert.equal(list.currentIndex, 0, "currentIndex is still set"); + Assert.ok( + !list.querySelector(".current"), + "no visible rows have the 'current' class" + ); + Assert.deepEqual(list.selectedIndices, [0], "selectedIndices are still set"); + Assert.ok( + !list.querySelector(".selected"), + "no visible rows have the 'selected' class" + ); + Assert.equal( + list.getFirstVisibleIndex(), + 50, + "getFirstVisibleIndex is correct" + ); + Assert.ok(!selectHandler.seenEvent, "should not have fired 'select' event"); + checkTopSpacerHeight(50); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_DOWN"); + }); + checkCurrent(1); + checkSelected(1); + Assert.equal( + list.getFirstVisibleIndex(), + 1, + "should have scrolled so that the second row is in view" + ); + + selectHandler.reset(); + await scrollListToPosition(0); + checkCurrent(1); + checkSelected(1); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "getFirstVisibleIndex is correct" + ); + Assert.ok( + !selectHandler.seenEvent, + "'select' event did not fire as expected" + ); + + await pressKey("VK_UP"); + checkCurrent(0); + checkSelected(0); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "should have scrolled so that the first row is in view" + ); + + // Some literal edge cases. Clicking on a partially visible row should + // scroll it into view. + + // Calculate the visible area in order to verify that rows appear where they + // are intended to appear. + const listRect = list.getBoundingClientRect(); + const headerHeight = list.table.header.clientHeight; + + const visibleRect = new content.DOMRect( + listRect.x, + listRect.y + headerHeight, + listRect.width, + listRect.height - headerHeight + ); + + Assert.equal(visibleRect.height, 630, "the table body should be 630px tall"); + + rows = list.querySelectorAll(`tr[is="test-row"]`); + let bcr = rows[12].getBoundingClientRect(); + Assert.less( + Math.round(bcr.top), + Math.round(visibleRect.bottom), + "top of row 12 is visible" + ); + Assert.greater( + Math.round(bcr.bottom), + Math.round(visibleRect.bottom), + "bottom of row 12 is not visible" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await clickOnRow(12); + }); + rows = list.querySelectorAll(`tr[is="test-row"]`); + bcr = rows[12].getBoundingClientRect(); + Assert.less( + Math.round(bcr.top), + Math.round(visibleRect.bottom), + "the top of row 12 should be visible" + ); + Assert.equal( + Math.round(bcr.bottom), + Math.round(visibleRect.bottom), + "row 12 should be at the bottom of the visible area" + ); + + bcr = rows[0].getBoundingClientRect(); + Assert.less( + Math.round(bcr.top), + Math.round(visibleRect.top), + "top of row 0 is not visible" + ); + Assert.greater( + Math.round(bcr.bottom), + Math.round(visibleRect.top), + "bottom of row 0 is visible" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await clickOnRow(0); + }); + rows = list.querySelectorAll(`tr[is="test-row"]`); + bcr = rows[0].getBoundingClientRect(); + Assert.equal( + Math.round(bcr.top), + Math.round(visibleRect.top), + "row 0 should be at the top of the visible area" + ); + Assert.greater( + Math.round(bcr.bottom), + Math.round(visibleRect.top), + "the bottom of row 0 should be visible" + ); +} + +/** + * Checks that changes in the view are propagated to the list. + */ +add_task(async function testRowCountChange() { + for (const variant of TEST_VARIANTS) { + info(`Running row count change test for ${variant}`); + await runTestInSandbox(subtestRowCountChange, variant); + } +}); + +async function subtestRowCountChange() { + let doc = content.document; + + let ROW_HEIGHT = 50; + let list = doc.getElementById("testTree"); + + async function doListActionAndWaitForRowBuffer(actionFn) { + // Filling the row buffer is fiddly timing, so provide an event to indicate + // that actions which may trigger changes in the row buffer have finished. + const eventName = "_treerowbufferfill"; + list._rowBufferReadyEvent = new content.CustomEvent(eventName); + + const promise = new Promise(resolve => + list.addEventListener(eventName, resolve, { once: true }) + ); + + await actionFn(); + await promise; + + list._rowBufferReadyEvent = null; + } + + let view = list.view; + let rows; + + // Check the initial state. + + function checkRows(first, last) { + let expectedIndices = []; + for (let i = first; i <= last; i++) { + expectedIndices.push(i); + } + rows = list.querySelectorAll(`tr[is="test-row"]`); + Assert.deepEqual( + Array.from(rows, r => r.index), + expectedIndices, + "the list has the right rows" + ); + Assert.deepEqual( + Array.from(rows, r => r.dataset.value), + view.values.slice(first, last + 1), + "the list has the right rows" + ); + } + + function checkSelected(indices, existingIndices) { + Assert.deepEqual(list.selectedIndices, indices); + let selectedRows = list.querySelectorAll(`tr[is="test-row"].selected`); + Assert.deepEqual( + Array.from(selectedRows, r => r.index), + existingIndices + ); + } + + let expectedCount = 150; + + // Select every tenth row. We'll check what is selected remains selected. + + list.selectedIndices = [4, 14, 24, 34, 44]; + + function getRowsHeight() { + return list.scrollHeight - list.table.header.clientHeight; + } + + async function addValues(index, values) { + view.values.splice(index, 0, ...values); + info(`Added ${values.join(", ")} at ${index}`); + info(view.values); + + expectedCount += values.length; + Assert.equal( + view.rowCount, + expectedCount, + "the view has the right number of rows" + ); + + await doListActionAndWaitForRowBuffer(() => { + list.rowCountChanged(index, values.length); + }); + + Assert.equal( + getRowsHeight(), + expectedCount * ROW_HEIGHT, + "space for all rows is allocated" + ); + } + + async function removeValues(index, count, expectedRemoved) { + let values = view.values.splice(index, count); + info(`Removed ${values.join(", ")} from ${index}`); + info(view.values); + + Assert.deepEqual(values, expectedRemoved); + + expectedCount -= values.length; + Assert.equal( + view.rowCount, + expectedCount, + "the view has the right number of rows" + ); + + await doListActionAndWaitForRowBuffer(() => { + list.rowCountChanged(index, -count); + }); + + Assert.equal( + getRowsHeight(), + expectedCount * ROW_HEIGHT, + "space for all rows is allocated" + ); + } + + async function scrollListToPosition(topOfScroll) { + await doListActionAndWaitForRowBuffer(() => { + list.scrollTo(0, topOfScroll); + }); + } + + Assert.equal( + view.rowCount, + expectedCount, + "the view has the right number of rows" + ); + Assert.equal(list.scrollTop, 0, "the list is scrolled to the top"); + Assert.equal( + getRowsHeight(), + expectedCount * ROW_HEIGHT, + "space for all rows is allocated" + ); + + // We should be scrolled to the top already, but this will ensure we get an + // event fire one way or another. + await scrollListToPosition(0); + + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 150 * 50); + + // Add a value at the end. Only the scroll height should change. + + await addValues(150, [150]); + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 151 * 50); + + // Add more values at the end. Only the scroll height should change. + + await addValues(151, [151, 152, 153]); + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 154 * 50); + + // Add values between the last row and the end. + // Only the scroll height should change. + + await addValues(40, ["39a", "39b"]); + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 46], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 156 * 50); + + // Add values between the last visible row and the last row. + // The changed rows and those below them should be updated. + + await addValues(18, ["17a", "17b", "17c"]); + checkRows(0, 38); + // Hard-coded sanity checks to prove checkRows is working as intended. + Assert.equal(rows[17].dataset.value, "17"); + Assert.equal(rows[18].dataset.value, "17a"); + Assert.equal(rows[19].dataset.value, "17b"); + Assert.equal(rows[20].dataset.value, "17c"); + Assert.equal(rows[21].dataset.value, "18"); + checkSelected([4, 14, 27, 37, 49], [4, 14, 27, 37]); + Assert.equal(getRowsHeight(), 159 * 50); + + // Add values in the visible rows. + // The changed rows and those below them should be updated. + + await addValues(8, ["7a", "7b"]); + checkRows(0, 38); + Assert.equal(rows[7].dataset.value, "7"); + Assert.equal(rows[8].dataset.value, "7a"); + Assert.equal(rows[9].dataset.value, "7b"); + Assert.equal(rows[10].dataset.value, "8"); + Assert.equal(rows[22].dataset.value, "17c"); + checkSelected([4, 16, 29, 39, 51], [4, 16, 29]); + Assert.equal(getRowsHeight(), 161 * 50); + + // Add a value at the start. All rows should be updated. + + await addValues(0, [-1]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-1"); + Assert.equal(rows[1].dataset.value, "0"); + Assert.equal(rows[22].dataset.value, "17b"); + checkSelected([5, 17, 30, 40, 52], [5, 17, 30]); + Assert.equal(getRowsHeight(), 162 * 50); + + // Add more values at the start. All rows should be updated. + + await addValues(0, [-3, -2]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[1].dataset.value, "-2"); + Assert.equal(rows[2].dataset.value, "-1"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 54], [7, 19, 32]); + Assert.equal(getRowsHeight(), 164 * 50); + + Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top"); + + // Remove values in the order we added them. + + await removeValues(160, 1, [150]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 54], [7, 19, 32]); + Assert.equal(getRowsHeight(), 163 * 50); + + await removeValues(160, 3, [151, 152, 153]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 54], [7, 19, 32]); + Assert.equal(getRowsHeight(), 160 * 50); + + await removeValues(48, 2, ["39a", "39b"]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 52], [7, 19, 32]); + Assert.equal(getRowsHeight(), 158 * 50); + + await removeValues(23, 3, ["17a", "17b", "17c"]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 29, 39, 49], [7, 19, 29]); + Assert.equal(getRowsHeight(), 155 * 50); + + await removeValues(11, 2, ["7a", "7b"]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[10].dataset.value, "7"); + Assert.equal(rows[11].dataset.value, "8"); + Assert.equal(rows[22].dataset.value, "19"); + checkSelected([7, 17, 27, 37, 47], [7, 17, 27, 37]); + Assert.equal(getRowsHeight(), 153 * 50); + + await removeValues(2, 1, [-1]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[1].dataset.value, "-2"); + Assert.equal(rows[2].dataset.value, "0"); + Assert.equal(rows[22].dataset.value, "20"); + checkSelected([6, 16, 26, 36, 46], [6, 16, 26, 36]); + Assert.equal(getRowsHeight(), 152 * 50); + + await removeValues(0, 2, [-3, -2]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "0"); + Assert.equal(rows[1].dataset.value, "1"); + Assert.equal(rows[22].dataset.value, "22"); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 150 * 50); + + Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top"); + + // Now scroll to the middle and repeat. + + await scrollListToPosition(1735); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[65].dataset.value, "73"); + checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]); + + await addValues(150, [150]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[65].dataset.value, "73"); + checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]); + + await addValues(38, ["37a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[29].dataset.value, "37"); + Assert.equal(rows[30].dataset.value, "37a"); + Assert.equal(rows[31].dataset.value, "38"); + Assert.equal(rows[65].dataset.value, "72"); + checkSelected([4, 14, 24, 34, 45], [14, 24, 34, 45]); + + await addValues(25, ["24a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[16].dataset.value, "24"); + Assert.equal(rows[17].dataset.value, "24a"); + Assert.equal(rows[18].dataset.value, "25"); + Assert.equal(rows[65].dataset.value, "71"); + checkSelected([4, 14, 24, 35, 46], [14, 24, 35, 46]); + + await addValues(11, ["10a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[2].dataset.value, "10"); + Assert.equal(rows[3].dataset.value, "10a"); + Assert.equal(rows[4].dataset.value, "11"); + Assert.equal(rows[65].dataset.value, "70"); + checkSelected([4, 15, 25, 36, 47], [15, 25, 36, 47]); + + await addValues(0, ["-1"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "69"); + checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]); + + Assert.equal( + list.scrollTop, + 1735, + "the list is still scrolled to the middle" + ); + + await removeValues(154, 1, [150]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "69"); + checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]); + + await removeValues(41, 1, ["37a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "70"); + checkSelected([5, 16, 26, 37, 47], [16, 26, 37, 47]); + + await removeValues(27, 1, ["24a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "71"); + checkSelected([5, 16, 26, 36, 46], [16, 26, 36, 46]); + + await removeValues(12, 1, ["10a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "72"); + checkSelected([5, 15, 25, 35, 45], [15, 25, 35, 45]); + + await removeValues(0, 1, ["-1"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[65].dataset.value, "73"); + checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]); + + Assert.equal( + list.scrollTop, + 1735, + "the list is still scrolled to the middle" + ); + + // Now scroll to the bottom and repeat. + + await scrollListToPosition(6870); + checkRows(111, 149); + Assert.equal(rows[0].dataset.value, "111"); + Assert.equal(rows[38].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + await addValues(50, [50]); + checkRows(111, 150); + Assert.equal(rows[0].dataset.value, "110"); + Assert.equal(rows[39].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + await addValues(49, ["48a"]); + checkRows(111, 151); + Assert.equal(rows[0].dataset.value, "109"); + Assert.equal(rows[40].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + await addValues(30, ["29a"]); + checkRows(111, 152); + Assert.equal(rows[0].dataset.value, "108"); + Assert.equal(rows[41].dataset.value, "149"); + checkSelected([4, 14, 24, 35, 45], []); + + await addValues(0, ["-1"]); + checkRows(111, 153); + Assert.equal(rows[0].dataset.value, "107"); + Assert.equal(rows[42].dataset.value, "149"); + checkSelected([5, 15, 25, 36, 46], []); + + Assert.equal( + list.scrollTop, + 6870, + "the list is still scrolled to the bottom" + ); + + await removeValues(53, 1, [50]); + checkRows(111, 152); + Assert.equal(rows[0].dataset.value, "108"); + Assert.equal(rows[41].dataset.value, "149"); + checkSelected([5, 15, 25, 36, 46], []); + + await removeValues(51, 1, ["48a"]); + checkRows(111, 151); + Assert.equal(rows[0].dataset.value, "109"); + Assert.equal(rows[40].dataset.value, "149"); + checkSelected([5, 15, 25, 36, 46], []); + + await removeValues(31, 1, ["29a"]); + checkRows(111, 150); + Assert.equal(rows[0].dataset.value, "110"); + Assert.equal(rows[39].dataset.value, "149"); + checkSelected([5, 15, 25, 35, 45], []); + + await removeValues(0, 1, ["-1"]); + checkRows(111, 149); + Assert.equal(rows[0].dataset.value, "111"); + Assert.equal(rows[38].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + Assert.equal( + list.scrollTop, + 6870, + "the list is still scrolled to the bottom" + ); + + // Remove a selected row and check the selection changes. + + await scrollListToPosition(0); + + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + + await removeValues(3, 3, [3, 4, 5]); // 4 is selected. + checkSelected([11, 21, 31, 41], [11, 21, 31]); + + await addValues(3, [3, 4, 5]); + checkSelected([14, 24, 34, 44], [14, 24, 34]); + + // Remove some consecutive selected rows. + + list.selectedIndices = [6, 7, 8, 9]; + checkSelected([6, 7, 8, 9], [6, 7, 8, 9]); + + await removeValues(7, 1, [7]); + checkSelected([6, 7, 8], [6, 7, 8]); + + await removeValues(7, 1, [8]); + checkSelected([6, 7], [6, 7]); + + await removeValues(7, 1, [9]); + checkSelected([6], [6]); + + // Reset the list. + + await addValues(7, [7, 8, 9]); + list.selectedIndex = -1; +} + +/** + * Checks that expanding and collapsing works. Twisties in the test file are + * styled as coloured squares: red for collapsed, green for expanded. + * + * @note This is practically the same test as in browser_treeListbox.js, but + * for TreeView instead of TreeListbox. If you make changes here you + * may want to make changes there too. + */ +add_task(async function testExpandCollapse() { + await runTestInSandbox(subtestExpandCollapse, "levels"); +}); + +async function subtestExpandCollapse() { + let doc = content.document; + let list = doc.getElementById("testTree"); + let allIds = [ + "row-1", + "row-2", + "row-2-1", + "row-2-2", + "row-3", + "row-3-1", + "row-3-1-1", + "row-3-1-2", + ]; + let idsWithoutChildren = [ + "row-1", + "row-2-1", + "row-2-2", + "row-3-1-1", + "row-3-1-2", + ]; + + let listener = { + reset() { + this.collapsedIndex = null; + this.expandedIndex = null; + }, + handleEvent(event) { + if (event.type == "collapsed") { + this.collapsedIndex = event.detail; + } else if (event.type == "expanded") { + this.expandedIndex = event.detail; + } + }, + }; + list.addEventListener("collapsed", listener); + list.addEventListener("expanded", listener); + + let selectHandler = { + seenEvent: null, + selectedAtEvent: null, + + reset() { + this.seenEvent = null; + this.selectedAtEvent = null; + }, + handleEvent(event) { + this.seenEvent = event; + this.selectedAtEvent = list.selectedIndex; + }, + }; + + Assert.equal( + list.querySelectorAll("collapsed").length, + 0, + "no rows are collapsed" + ); + Assert.equal(list.view.rowCount, 8, "row count"); + Assert.deepEqual( + Array.from(list.table.body.children, r => r.id), + [ + "row-1", + "row-2", + "row-2-1", + "row-2-2", + "row-3", + "row-3-1", + "row-3-1-1", + "row-3-1-2", + ], + "rows property" + ); + + function checkCurrent(expectedIndex) { + Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct"); + const current = list.querySelectorAll(".current"); + if (expectedIndex == -1) { + Assert.equal(current.length, 0, "no rows have the 'current' class"); + } else { + Assert.equal(current.length, 1, "only one row has the 'current' class"); + Assert.equal( + current[0].index, + expectedIndex, + "correct row has the 'current' class" + ); + } + } + + function checkMultiSelect(...expectedIds) { + let selected = [...list.querySelectorAll(".selected")].map(row => row.id); + Assert.deepEqual(selected, expectedIds, "selection should be correct"); + } + + function checkSelectedAndCurrent(expectedIndex, expectedId) { + Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct"); + let selected = [...list.querySelectorAll(".selected")].map(row => row.id); + Assert.deepEqual( + selected, + [expectedId], + "correct rows have the 'selected' class" + ); + checkCurrent(expectedIndex); + } + + list.selectedIndex = 0; + checkSelectedAndCurrent(0, "row-1"); + + // Click the twisties of rows without children. + + function performChange(id, expectedChange, changeCallback) { + listener.reset(); + let row = doc.getElementById(id); + let before = row.classList.contains("collapsed"); + + changeCallback(row); + + row = doc.getElementById(id); + if (expectedChange == "collapsed") { + Assert.ok(!before, `${id} was expanded`); + Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`); + Assert.notEqual( + listener.collapsedIndex, + null, + `${id} fired 'collapse' event` + ); + Assert.ok(!listener.expandedIndex, `${id} did not fire 'expand' event`); + } else if (expectedChange == "expanded") { + Assert.ok(before, `${id} was collapsed`); + Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`); + Assert.ok( + !listener.collapsedIndex, + `${id} did not fire 'collapse' event` + ); + Assert.notEqual( + listener.expandedIndex, + null, + `${id} fired 'expand' event` + ); + } else { + Assert.equal( + row.classList.contains("collapsed"), + before, + `${id} state did not change` + ); + } + } + + function clickTwisty(id, expectedChange) { + info(`clicking the twisty on ${id}`); + performChange(id, expectedChange, row => + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".twisty"), + {}, + content + ) + ); + } + + function clickThread(id, expectedChange) { + info(`clicking the thread on ${id}`); + performChange(id, expectedChange, row => { + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".tree-button-thread"), + {}, + content + ); + }); + } + + for (let id of idsWithoutChildren) { + clickTwisty(id, null); + Assert.equal(list.querySelector(".selected").id, id); + } + + checkSelectedAndCurrent(7, "row-3-1-2"); + + // Click the twisties of rows with children. + + function checkRowsAreHidden(...hiddenIds) { + let remainingIds = allIds.slice(); + + for (let id of allIds) { + if (hiddenIds.includes(id)) { + Assert.ok(!doc.getElementById(id), `${id} is hidden`); + remainingIds.splice(remainingIds.indexOf(id), 1); + } else { + Assert.greater( + doc.getElementById(id).clientHeight, + 0, + `${id} is visible` + ); + } + } + + Assert.equal(list.view.rowCount, 8 - hiddenIds.length, "row count"); + Assert.deepEqual( + Array.from(list.table.body.children, r => r.id), + remainingIds, + "rows property" + ); + } + + // Collapse row 2. + + clickTwisty("row-2", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(5, "row-3-1-2"); + + // Collapse row 3. + + clickTwisty("row-3", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + // Expand row 2. + + clickTwisty("row-2", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + clickTwisty("row-3", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Collapse row 3-1. + + clickTwisty("row-3-1", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Collapse row 3. + + clickTwisty("row-3", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + clickTwisty("row-3", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3-1. + + clickTwisty("row-3-1", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Test key presses. + + function pressKey(id, key, expectedChange) { + info(`pressing ${key}`); + performChange(id, expectedChange, row => { + EventUtils.synthesizeKey(key, {}, content); + }); + } + + // Row 0 has no children or parent, nothing should happen. + + list.selectedIndex = 0; + pressKey("row-1", "VK_LEFT"); + checkSelectedAndCurrent(0, "row-1"); + pressKey("row-1", "VK_RIGHT"); + checkSelectedAndCurrent(0, "row-1"); + + // Collapse row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + pressKey("row-2", "VK_LEFT"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Collapse row 3. + + list.selectedIndex = 2; + pressKey("row-3", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + // Expand row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_RIGHT", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Expand row 3. + + list.selectedIndex = 4; + pressKey("row-3", "VK_RIGHT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Go down the tree to row 3-1-1. + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + // Collapse row 3-1. + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Collapse row 3. + + pressKey("row-3-1", "VK_LEFT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + pressKey("row-3", "VK_RIGHT", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Expand row 3-1. + + pressKey("row-3-1", "VK_RIGHT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + // Same again, with a RTL tree. + + info("switching to RTL"); + doc.documentElement.dir = "rtl"; + + // Row 0 has no children or parent, nothing should happen. + + list.selectedIndex = 0; + pressKey("row-1", "VK_RIGHT"); + checkSelectedAndCurrent(0, "row-1"); + pressKey("row-1", "VK_LEFT"); + checkSelectedAndCurrent(0, "row-1"); + + // Collapse row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + pressKey("row-2", "VK_RIGHT"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Collapse row 3. + + list.selectedIndex = 2; + pressKey("row-3", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + // Expand row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_LEFT", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Expand row 3. + + list.selectedIndex = 4; + pressKey("row-3", "VK_LEFT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Go down the tree to row 3-1-1. + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + // Collapse row 3-1. + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Collapse row 3. + + pressKey("row-3-1", "VK_RIGHT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + pressKey("row-3", "VK_LEFT", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Expand row 3-1. + + pressKey("row-3-1", "VK_LEFT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + // Use the class methods for expanding and collapsing. + + selectHandler.reset(); + list.addEventListener("select", selectHandler); + listener.reset(); + + list.collapseRowAtIndex(6); // No children, no effect. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.ok(!listener.collapsedIndex, "'collapsed' event did not fire"); + + list.expandRowAtIndex(6); // No children, no effect. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.ok(!listener.expandedIndex, "'expanded' event did not fire"); + + list.collapseRowAtIndex(1); // Item with children that aren't selected. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event"); + listener.reset(); + + list.expandRowAtIndex(1); // Item with children that aren't selected. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event"); + listener.reset(); + + list.collapseRowAtIndex(5); // Item with children that are selected. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 5, + "selectedIndex was correct when 'select' event fired" + ); + Assert.equal(listener.collapsedIndex, 5, "row-3-1 fired 'collapsed' event"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + selectHandler.reset(); + listener.reset(); + + list.expandRowAtIndex(5); // Selected item with children. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.expandedIndex, 5, "row-3-1 fired 'expanded' event"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + listener.reset(); + + list.selectedIndex = 7; + selectHandler.reset(); + + list.collapseRowAtIndex(4); // Item with grandchildren that are selected. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 4, + "selectedIndex was correct when 'select' event fired" + ); + Assert.equal(listener.collapsedIndex, 4, "row-3 fired 'collapsed' event"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + selectHandler.reset(); + listener.reset(); + + list.expandRowAtIndex(4); // Selected item with grandchildren. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.expandedIndex, 4, "row-3 fired 'expanded' event"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + listener.reset(); + + // Click thread for already expanded thread. Should select all in thread. + selectHandler.reset(); + clickThread("row-3"); // Item with grandchildren. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 4, + "selectedIndex was correct when 'select' event fired" + ); + checkRowsAreHidden(); + checkMultiSelect("row-3", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkCurrent(4); + + // Click thread for collapsed thread. Should expand the thread and select all + // children. + list.collapseRowAtIndex(1); // Item with children that aren't selected. + Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event"); + checkRowsAreHidden("row-2-1", "row-2-2"); + clickThread("row-2", "expanded"); + Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event"); + checkMultiSelect("row-2", "row-2-1", "row-2-2"); + checkCurrent(1); + + // Select multiple messages in an expanded thread by keyboard, ending with a + // child message, then collapse the thread. After that, currentIndex should + // be the root message. + selectHandler.reset(); + list.selectedIndex = 1; + checkSelectedAndCurrent(1, "row-2"); + info(`pressing VK_DOWN with shift key twice`); + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content); + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content); + checkMultiSelect("row-2", "row-2-1", "row-2-2"); + checkCurrent(3); + clickTwisty("row-2", "collapsed"); + checkSelectedAndCurrent(1, "row-2"); + + list.removeEventListener("collapsed", listener); + list.removeEventListener("expanded", listener); + list.removeEventListener("select", selectHandler); + doc.documentElement.dir = null; +} + +/** + * Checks that the row widget can be changed, redrawing the rows and + * maintaining the selection. + */ +add_task(async function testRowClassChange() { + for (const variant of TEST_VARIANTS) { + info(`Running row class change test for ${variant}`); + await runTestInSandbox(subtestRowClassChange, variant); + } +}); + +async function subtestRowClassChange() { + let doc = content.document; + let list = doc.getElementById("testTree"); + let indices = (list.selectedIndices = [1, 2, 3, 5, 8, 13, 21, 34]); + list.currentIndex = 5; + + for (let row of list.table.body.children) { + Assert.equal(row.getAttribute("is"), "test-row"); + Assert.equal(row.clientHeight, 50); + Assert.equal( + row.classList.contains("selected"), + indices.includes(row.index) + ); + Assert.equal(row.classList.contains("current"), row.index == 5); + } + + info("switching row class to AlternativeCardRow"); + list.setAttribute("rows", "alternative-row"); + Assert.deepEqual(list.selectedIndices, indices); + Assert.equal(list.currentIndex, 5); + + for (let row of list.table.body.children) { + Assert.equal(row.getAttribute("is"), "alternative-row"); + Assert.equal(row.clientHeight, 80); + Assert.equal( + row.classList.contains("selected"), + indices.includes(row.index) + ); + Assert.equal(row.classList.contains("current"), row.index == 5); + } + + list.selectedIndex = -1; + Assert.deepEqual(list.selectedIndices, []); + Assert.equal(list.currentIndex, -1); + + info("switching row class to TestCardRow"); + list.setAttribute("rows", "test-row"); + Assert.deepEqual(list.selectedIndices, []); + Assert.equal(list.currentIndex, -1); + + for (let row of list.table.body.children) { + Assert.equal(row.getAttribute("is"), "test-row"); + Assert.equal(row.clientHeight, 50); + Assert.ok(!row.classList.contains("selected")); + Assert.ok(!row.classList.contains("current")); + } +} + +/** + * Checks that resizing the widget automatically adds more rows if necessary. + */ +add_task(async function testResize() { + for (const variant of TEST_VARIANTS) { + info(`Running resize test for ${variant}`); + await runTestInSandbox(subtestResize, variant); + } +}); + +async function subtestResize() { + let doc = content.document; + + let list = doc.getElementById("testTree"); + Assert.ok(!!list, "the list exists"); + + async function doListActionAndWaitForRowBuffer(actionFn) { + // Filling the row buffer is fiddly timing, so provide an event to indicate + // that actions which may trigger changes in the row buffer have finished. + const eventName = "_treerowbufferfill"; + list._rowBufferReadyEvent = new content.CustomEvent(eventName); + + const promise = new Promise(resolve => + list.addEventListener(eventName, resolve, { once: true }) + ); + + await actionFn(); + await promise; + + list._rowBufferReadyEvent = null; + } + + async function scrollVerticallyBy(scrollDistance) { + await doListActionAndWaitForRowBuffer(() => { + list.scrollBy(0, scrollDistance); + }); + } + + async function changeHeightTo(newHeight) { + await doListActionAndWaitForRowBuffer(() => { + list.style.height = `${newHeight}px`; + }); + } + + let rowCount = function () { + return list.querySelectorAll(`tr[is="test-row"]`).length; + }; + + let originalHeight = list.clientHeight; + + // We should already be at the top, but this will force us to have finished + // loading before we trigger another scroll. Otherwise, we may get back a fill + // event for the initial fill when we expect an event in response to a scroll. + await doListActionAndWaitForRowBuffer(() => { + list.scrollTo(0, 0); + }); + + // Start by scrolling to somewhere in the middle of the list, so that we + // don't have to think about buffer rows that don't exist at the ends. + await scrollVerticallyBy(2650); + + // The list has enough space for 13 visible rows, and 26 buffer rows should + // exist above and below. + Assert.equal( + rowCount(), + 13 + 26 + 26, + "the list should contain the right number of rows" + ); + + // Make the list shorter by 5 rows. This should not affect the number of rows, + // but this is a bit flaky, so check we have at least the minimum required. + await changeHeightTo(originalHeight - 250); + Assert.equal(list._toleranceSize, 16); + Assert.greaterOrEqual( + rowCount(), + 8 + 26 + 26, + "making the list shorter should not change the number of rows" + ); + + // Scrolling the list by any amount should remove excess rows. + await scrollVerticallyBy(50); + Assert.equal( + rowCount(), + 8 + 16 + 16, + "scrolling the list after resize should remove the excess rows" + ); + + // Return to the original height. More buffer rows should be added. We have + // to wait for the ResizeObserver to be triggered. + await changeHeightTo(originalHeight); + Assert.equal(list._toleranceSize, 26); + Assert.equal( + rowCount(), + 13 + 26 + 26, + "making the list taller should change the number of rows" + ); + + // Make the list taller by 5 rows. We have to wait for the ResizeObserver + // to be triggered. + await changeHeightTo(originalHeight + 250); + Assert.equal(list._toleranceSize, 36); + Assert.equal( + rowCount(), + 18 + 36 + 36, + "making the list taller should change the number of rows" + ); + + // Scrolling the list should not affect the number of rows. + await scrollVerticallyBy(50); + Assert.equal( + rowCount(), + 18 + 36 + 36, + "scrolling the list should not change the number of rows" + ); +} diff --git a/comm/mail/base/test/browser/browser_viewMenu.js b/comm/mail/base/test/browser/browser_viewMenu.js new file mode 100644 index 0000000000..d24e868595 --- /dev/null +++ b/comm/mail/base/test/browser/browser_viewMenu.js @@ -0,0 +1,218 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +/** @type MenuData */ +const viewMenuData = { + menu_Toolbars: {}, + view_toolbars_popup_quickFilterBar: { checked: true }, + viewToolbarsPopupSpacesToolbar: { checked: true }, + menu_showTaskbar: { checked: true }, + customizeMailToolbars: {}, + menu_MessagePaneLayout: {}, + messagePaneClassic: {}, + messagePaneWide: {}, + messagePaneVertical: { checked: true }, + menu_showFolderPane: { checked: true }, + menu_toggleThreadPaneHeader: { disabled: true, checked: true }, + menu_showMessage: {}, + menu_FolderViews: {}, + menu_toggleFolderHeader: { checked: true }, + menu_allFolders: { disabled: true, checked: true }, + menu_smartFolders: {}, + menu_unreadFolders: {}, + menu_favoriteFolders: {}, + menu_recentFolders: {}, + menu_tags: {}, + menu_compactMode: { disabled: true }, + menu_uiDensity: {}, + uiDensityCompact: {}, + uiDensityNormal: { checked: true }, + uiDensityTouch: {}, + viewFullZoomMenu: {}, + menu_fullZoomEnlarge: { disabled: true }, + menu_fullZoomReduce: { disabled: true }, + menu_fullZoomReset: { disabled: true }, + menu_fullZoomToggle: { disabled: true }, + menu_uiFontSize: {}, + menu_fontSizeEnlarge: {}, + menu_fontSizeReduce: {}, + menu_fontSizeReset: {}, + calTodayPaneMenu: { hidden: true }, + "calShowTodayPane-2": {}, + calTodayPaneDisplayMiniday: {}, + calTodayPaneDisplayMinimonth: {}, + calTodayPaneDisplayNone: {}, + calCalendarMenu: { hidden: true }, + calChangeViewDay: {}, + calChangeViewWeek: {}, + calChangeViewMultiweek: {}, + calChangeViewMonth: {}, + calCalendarPaneMenu: {}, + calViewCalendarPane: {}, + calTasksViewMinimonth: {}, + calTasksViewCalendarlist: {}, + calCalendarCurrentViewMenu: {}, + calWorkdaysOnlyMenuitem: {}, + calTasksInViewMenuitem: {}, + calShowCompletedInViewMenuItem: {}, + calViewRotated: {}, + calTasksMenu: { hidden: true }, + calTasksViewFilterTasks: {}, + calTasksViewFilterCurrent: {}, + calTasksViewFilterToday: {}, + calTasksViewFilterNext7days: {}, + calTasksViewFilterNotstartedtasks: {}, + calTasksViewFilterOverdue: {}, + calTasksViewFilterCompleted: {}, + calTasksViewFilterOpen: {}, + calTasksViewFilterAll: {}, + viewSortMenu: { disabled: true }, + sortByDateMenuitem: {}, + sortByReceivedMenuitem: {}, + sortByFlagMenuitem: {}, + sortByOrderReceivedMenuitem: {}, + sortByPriorityMenuitem: {}, + sortByFromMenuitem: {}, + sortByRecipientMenuitem: {}, + sortByCorrespondentMenuitem: {}, + sortBySizeMenuitem: {}, + sortByStatusMenuitem: {}, + sortBySubjectMenuitem: {}, + sortByUnreadMenuitem: {}, + sortByTagsMenuitem: {}, + sortByJunkStatusMenuitem: {}, + sortByAttachmentsMenuitem: {}, + sortAscending: {}, + sortDescending: {}, + sortThreaded: {}, + sortUnthreaded: {}, + groupBySort: {}, + viewMessageViewMenu: { hidden: true }, + viewMessageAll: {}, + viewMessageUnread: {}, + viewMessageNotDeleted: {}, + viewMessageTags: {}, + viewMessageCustomViews: {}, + viewMessageVirtualFolder: {}, + viewMessageCustomize: {}, + viewMessagesMenu: { disabled: true }, + viewAllMessagesMenuItem: { disabled: true, checked: true }, + viewUnreadMessagesMenuItem: { disabled: true }, + viewThreadsWithUnreadMenuItem: { disabled: true }, + viewWatchedThreadsWithUnreadMenuItem: { disabled: true }, + viewIgnoredThreadsMenuItem: { disabled: true }, + menu_expandAllThreads: { disabled: true }, + collapseAllThreads: { disabled: true }, + viewheadersmenu: {}, + viewallheaders: {}, + viewnormalheaders: { checked: true }, + viewBodyMenu: {}, + bodyAllowHTML: { checked: true }, + bodySanitized: {}, + bodyAsPlaintext: {}, + bodyAllParts: { hidden: true }, + viewFeedSummary: { hidden: true }, + bodyFeedGlobalWebPage: {}, + bodyFeedGlobalSummary: {}, + bodyFeedPerFolderPref: {}, + bodyFeedSummaryAllowHTML: {}, + bodyFeedSummarySanitized: {}, + bodyFeedSummaryAsPlaintext: {}, + viewAttachmentsInlineMenuitem: { checked: true }, + pageSourceMenuItem: { disabled: true }, +}; +let helper = new MenuTestHelper("menu_View", viewMenuData); + +let tabmail = document.getElementById("tabmail"); +let inboxFolder, rootFolder, testMessages; + +add_setup(async function () { + document.getElementById("toolbar-menubar").removeAttribute("autohide"); + + let generator = new MessageGenerator(); + + MailServices.accounts.createLocalMailAccount(); + let account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + rootFolder = account.incomingServer.rootFolder; + + rootFolder.createSubfolder("view menu", null); + inboxFolder = rootFolder + .getChildNamed("view menu") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + inboxFolder.addMessageBatch( + generator.makeMessages({ count: 5 }).map(message => message.toMboxString()) + ); + testMessages = [...inboxFolder.messages]; + + registerCleanupFunction(() => { + tabmail.closeOtherTabs(0); + MailServices.accounts.removeAccount(account, false); + }); +}); + +add_task(async function test3PaneTab() { + tabmail.currentAbout3Pane.restoreState({ + folderPaneVisible: true, + messagePaneVisible: true, + folderURI: rootFolder, + }); + await new Promise(resolve => setTimeout(resolve)); + await helper.testAllItems("mail3PaneTab"); + + tabmail.currentAbout3Pane.displayFolder(inboxFolder); + await helper.testItems({ + menu_Toolbars: {}, + view_toolbars_popup_quickFilterBar: { checked: true }, + menu_MessagePaneLayout: {}, + menu_showFolderPane: { checked: true }, + menu_toggleThreadPaneHeader: { checked: true }, + menu_showMessage: { checked: true }, + viewSortMenu: { disabled: false }, + viewMessagesMenu: { disabled: false }, + }); + + goDoCommand("cmd_toggleQuickFilterBar"); + await helper.testItems({ + menu_Toolbars: {}, + view_toolbars_popup_quickFilterBar: { checked: false }, + }); + + goDoCommand("cmd_toggleFolderPane"); + await helper.testItems({ + menu_MessagePaneLayout: {}, + menu_showFolderPane: { checked: false }, + menu_showMessage: { checked: true }, + }); + + goDoCommand("cmd_toggleThreadPaneHeader"); + await helper.testItems({ + menu_MessagePaneLayout: {}, + menu_toggleThreadPaneHeader: { checked: false }, + }); + + goDoCommand("cmd_toggleMessagePane"); + await helper.testItems({ + menu_MessagePaneLayout: {}, + menu_showFolderPane: { checked: false }, + menu_showMessage: { checked: false }, + }); + + goDoCommand("cmd_toggleQuickFilterBar"); + goDoCommand("cmd_toggleFolderPane"); + goDoCommand("cmd_toggleThreadPaneHeader"); + goDoCommand("cmd_toggleMessagePane"); + await helper.testItems({ + menu_Toolbars: {}, + view_toolbars_popup_quickFilterBar: { checked: true }, + menu_MessagePaneLayout: {}, + menu_showFolderPane: { checked: true }, + menu_toggleThreadPaneHeader: { checked: true }, + menu_showMessage: { checked: true }, + }); +}); diff --git a/comm/mail/base/test/browser/browser_webSearchTelemetry.js b/comm/mail/base/test/browser/browser_webSearchTelemetry.js new file mode 100644 index 0000000000..cd420e3b83 --- /dev/null +++ b/comm/mail/base/test/browser/browser_webSearchTelemetry.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* globals openWebSearch */ + +/** + * Test telemetry related to web search usage. + */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +let { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +/** @implements {nsIExternalProtocolService} */ +let mockExternalProtocolService = { + loadURI(aURI, aWindowContext) {}, + QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]), +}; + +let mockExternalProtocolServiceCID = MockRegistrar.register( + "@mozilla.org/uriloader/external-protocol-service;1", + mockExternalProtocolService +); + +registerCleanupFunction(() => { + MockRegistrar.unregister(mockExternalProtocolServiceCID); +}); + +/** + * Test that we're counting how many times search on web was used. + */ +add_task(async function test_web_search_usage() { + Services.telemetry.clearScalars(); + + const NUM_SEARCH = 5; + let engine = await Services.search.getDefault(); + await Promise.all( + Array.from({ length: NUM_SEARCH }).map(() => openWebSearch("thunderbird")) + ); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + Assert.equal( + scalars["tb.websearch.usage"][engine.name.toLowerCase()], + NUM_SEARCH, + "Count of search on web times must be correct." + ); +}); diff --git a/comm/mail/base/test/browser/browser_zoom.js b/comm/mail/base/test/browser/browser_zoom.js new file mode 100644 index 0000000000..0dbda439ca --- /dev/null +++ b/comm/mail/base/test/browser/browser_zoom.js @@ -0,0 +1,110 @@ +/* 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/. */ + +const { MessageGenerator } = ChromeUtils.import( + "resource://testing-common/mailnews/MessageGenerator.jsm" +); + +const tabmail = document.getElementById("tabmail"); +const about3Pane = tabmail.currentAbout3Pane; +const { threadTree } = about3Pane; + +add_setup(async function () { + Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false); + // Create an account for the test. + MailServices.accounts.createLocalMailAccount(); + const account = MailServices.accounts.accounts[0]; + account.addIdentity(MailServices.accounts.createIdentity()); + + // Create a folder for the account to store test messages. + const rootFolder = account.incomingServer.rootFolder; + rootFolder.createSubfolder("zoom", null); + const testFolder = rootFolder + .getChildNamed("zoom") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + + // Generate test messages. + const generator = new MessageGenerator(); + testFolder.addMessageBatch( + generator + .makeMessages({ count: 5, msgsPerThread: 5 }) + .map(message => message.toMboxString()) + ); + + // Use the test folder. + about3Pane.displayFolder(testFolder.URI); + await ensure_cards_view(); + + // Remove test account on cleanup. + registerCleanupFunction(() => { + MailServices.accounts.removeAccount(account, false); + Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true); + }); +}); + +/** + * Tests zooming in and out of the multi-message view using keyboard shortcuts + * when viewing a thread. + */ +add_task(async function testMultiMessageZoom() { + // Threads need to be collapsed, otherwise the multi-message view + // won't be shown. + const row = threadTree.getRowAtIndex(0); + Assert.ok( + row.classList.contains("collapsed"), + "The thread row should be collapsed" + ); + + const subjectLine = row.querySelector( + ".thread-card-subject-container .subject" + ); + // Simulate a click on the row's subject line to select the row. + const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + EventUtils.synthesizeMouseAtCenter( + subjectLine, + { clickCount: 1 }, + about3Pane + ); + await selectPromise; + // Make sure the correct thread is selected and that the multi-message view is + // visible. + Assert.ok( + row.classList.contains("selected"), + "The thread row should be selected" + ); + Assert.ok( + BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser), + "The multi-message browser should be visible" + ); + + // Record the zoom value before the operation. + let previousZoom = top.ZoomManager.getZoomForBrowser( + about3Pane.multiMessageBrowser + ); + + // Emulate a zoom in. + EventUtils.synthesizeKey("+", { accelKey: true }); + + // Test that the zoom value increases. + await TestUtils.waitForCondition( + () => + top.ZoomManager.getZoomForBrowser(about3Pane.multiMessageBrowser) > + previousZoom, + "zoom value should be greater than before keyboard event" + ); + + // Emulate a zoom out. + previousZoom = top.ZoomManager.getZoomForBrowser( + about3Pane.multiMessageBrowser + ); + EventUtils.synthesizeKey("-", { accelKey: true }); + + // Test that the zoom value decreases. + await TestUtils.waitForCondition( + () => + previousZoom > + top.ZoomManager.getZoomForBrowser(about3Pane.multiMessageBrowser), + "zoom value should be less than before keyboard event" + ); +}); diff --git a/comm/mail/base/test/browser/files/formContent.html b/comm/mail/base/test/browser/files/formContent.html new file mode 100644 index 0000000000..6779051746 --- /dev/null +++ b/comm/mail/base/test/browser/files/formContent.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>Form Content</title> + </head> + <body> + <form> + <div> + <input type="date" /> + </div> + <div> + <select> + <option value=""></option> + <option value="3.141592654">π</option> + <option value="6.283185308">τ</option> + </select> + </div> + <div> + <input list="letters"/> + <datalist id="letters"> + <option value="alpha"/> + <option value="beta"/> + <option value="gamma"/> + <option value="delta"/> + <option value="epsilon"/> + <option value="zeta"/> + <option value="eta"/> + <option value="theta"/> + <option value="iota"/> + <option value="kappa"/> + </datalist> + </div> + </form> + </body> +</html> diff --git a/comm/mail/base/test/browser/files/links.html b/comm/mail/base/test/browser/files/links.html new file mode 100644 index 0000000000..f5703dc4ef --- /dev/null +++ b/comm/mail/base/test/browser/files/links.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"/> + <title>Links to other places</title> +</head> +<body> + <h1>Links to things</h1> + <p>This page is a test of what happens when you click on links. It should be loaded from http://example.org:80.</p> + + <h2>This page:</h2> + <ul> + <li><a id="this-hash" href="#hash">Anchor on this page</a></li> + <li><a id="this-nohash" href="links.html">This page</a></li> + </ul> + + <h2>Pages on this domain:</h2> + <ul> + <li><a id="local-here" href="sampleContent.html">A page in the same directory</a></li> + <li><a id="local-elsewhere" href="/browser/comm/mail/components/extensions/test/browser/data/content.html">A page elsewhere</a></li> + </ul> + + <h2>Pages on other places on this TLD:</h2> + <ul> + <li><a id="other-https" href="https://example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but over HTTPS</a></li> + <li><a id="other-port" href="http://example.org:8000/browser/comm/mail/base/test/browser/files/links.html">This page, but on example.com:8000</a></li> + <li><a id="other-subdomain" href="http://test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on test1.example.com</a></li> + <li><a id="other-subsubdomain" href="http://sub1.test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on sub1.test1.example.com</a></li> + </ul> + + <h2>Pages on a completely different domain:</h2> + <ul style="margin-bottom: 100vh;"> + <li><a id="other-domain" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/links.html">This page, but on mochi.test</a></li> + </ul> + + <h2 id="hash">This is the hash target!</h2> +</body> +</html> diff --git a/comm/mail/base/test/browser/files/menulist.xhtml b/comm/mail/base/test/browser/files/menulist.xhtml new file mode 100644 index 0000000000..cba2bbcf86 --- /dev/null +++ b/comm/mail/base/test/browser/files/menulist.xhtml @@ -0,0 +1,30 @@ +<?xml version="1.0"?> +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/menulist.css"?> + +<window align="start" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml"> + <button id="before" label="I'm just a button" onclick="alert('I\'m a button!')"/> + + <menulist> + <menupopup> + <menuitem value="foo" label="foo"/> + <menuitem value="bar" label="bar"/> + </menupopup> + </menulist> + + <menulist is="menulist-editable"> + <menupopup> + <menuitem value="foo" label="foo"/> + <menuitem value="bar" label="bar"/> + </menupopup> + </menulist> + + <menulist is="menulist-editable" editable="true" width="100"> + <menupopup> + <menuitem value="foo" label="foo"/> + <menuitem value="bar" label="bar"/> + </menupopup> + </menulist> + + <button id="after" label="I'm just a button"/> +</window> diff --git a/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml new file mode 100644 index 0000000000..63154cbce9 --- /dev/null +++ b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml @@ -0,0 +1,171 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Test for the orderable-tree-listbox custom element</title> + <style> + :focus { + outline: 3px blue solid; + } + html { + height: 100%; + } + body { + height: 100%; + display: flex; + margin: 0; + } + #list { + overflow-y: auto; + white-space: nowrap; + margin: 1em; + border: 1px solid black; + width: 400px; + outline: none; + } + @media not (prefers-reduced-motion) { + #list { + scroll-behavior: smooth; + } + } + ol, ul { + margin: 0; + padding: 0; + list-style: none; + } + li > div { + display: flex; + align-items: center; + padding: 4px; + line-height: 24px; + } + li.selected > div { + color: white; + background-color: blue; + } + li > ul > li > div { + padding-inline-start: calc(1em + 8px); + } + li.collapsed > ul { + display: none; + } + div.twisty { + width: 1em; + height: 1em; + margin-inline-end: 4px; + } + li.children > div > div.twisty { + background-color: green; + } + li.children.collapsed > div > div.twisty { + background-color: red; + } + + #list > li { + transition: opacity 250ms; + } + #list > li.dragging { + opacity: 0.75; + } + </style> + <!-- This script is used for the automated test. --> + <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script> + <!-- This script is used when this file is loaded in a browser. --> + <script defer="defer" src="../../../content/widgets/tree-listbox.js"></script> +</head> +<body> + <ol id="list" is="orderable-tree-listbox" role="tree"> + <li id="row-1"> + <div draggable="true"> + <div class="twisty"></div> + Item 1 + </div> + </li> + <li id="row-2"> + <div draggable="true"> + <div class="twisty"></div> + Item 2 + </div> + <ul> + <li id="row-2-1"> + <div> + <div class="twisty"></div> + First child + </div> + </li> + <li id="row-2-2"> + <div> + <div class="twisty"></div> + Second child + </div> + </li> + </ul> + </li> + <li id="row-3"> + <div draggable="true"> + <div class="twisty"></div> + Item 3 + </div> + <ul> + <li id="row-3-1"> + <div> + <div class="twisty"></div> + First child + </div> + </li> + <li id="row-3-2"> + <div> + <div class="twisty"></div> + Second child + </div> + </li> + <li id="row-3-3"> + <div> + <div class="twisty"></div> + Third child + </div> + </li> + </ul> + </li> + <li id="row-4"> + <div draggable="true"> + <div class="twisty"></div> + Item 4 + </div> + </li> + <li id="row-5"> + <div draggable="true"> + <div class="twisty"></div> + Item 5 + </div> + <ul> + <li id="row-5-1"> + <div> + <div class="twisty"></div> + First child + </div> + </li> + <li id="row-5-2"> + <div> + <div class="twisty"></div> + Second child + </div> + </li> + </ul> + </li> + </ol> + + <div id="marker" style="position: absolute; left: 500px; border-top: 1px red solid;"></div> + <script> + function moveMarker(event) { + let marker = document.getElementById("marker"); + marker.style.top = `${event.clientY}px`; + marker.textContent = `${event.type} event here`; + } + + document.addEventListener("dragstart", moveMarker); + document.addEventListener("dragover", moveMarker); + document.addEventListener("drop", moveMarker); + </script> +</body> +</html> diff --git a/comm/mail/base/test/browser/files/paneSplitter.xhtml b/comm/mail/base/test/browser/files/paneSplitter.xhtml new file mode 100644 index 0000000000..7d25e5596e --- /dev/null +++ b/comm/mail/base/test/browser/files/paneSplitter.xhtml @@ -0,0 +1,122 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Test for the pane-splitter custom element</title> + <style> + hr[is="pane-splitter"] { + margin: 0 -3px; + border: none; + z-index: 1; + cursor: ew-resize; + opacity: .4; + background-color: red; + } + + #splitter3, + #splitter4 { + margin: -3px 0; + cursor: ns-resize; + } + + #horizontal-before { + display: grid; + grid-template-columns: minmax(auto, var(--splitter1-width)) 0 auto; + width: 500px; + height: 100px; + --splitter1-width: 200px; + margin: 1em; + } + + #horizontal-after { + display: grid; + grid-template-columns: auto 0 minmax(auto, var(--splitter2-width)); + width: 500px; + height: 100px; + --splitter2-width: 200px; + margin: 1em; + } + + #vertical-before { + display: inline-grid; + grid-template-rows: minmax(auto, var(--splitter3-height)) 0 auto; + width: 100px; + height: 500px; + --splitter3-height: 200px; + margin: 1em; + } + + #vertical-after { + display: inline-grid; + grid-template-rows: auto 0 minmax(auto, var(--splitter4-height)); + width: 100px; + height: 500px; + --splitter4-height: 200px; + margin: 1em; + } + + .resized { + background-color: lightblue; + } + + .fill { + background-color: lightslategrey; + } + </style> + <!-- This path is used for the automated test. --> + <script src="chrome://messenger/content/pane-splitter.js"></script> + <!-- This path is used when this file is loaded in a browser. --> + <script src="../../../content/widgets/pane-splitter.js"></script> + <script> + function moveMarker(event) { + let markerX = document.getElementById("markerX"); + markerX.style.left = `${event.clientX + window.scrollX}px`; + markerX.textContent = `${event.type} event here`; + + let markerY = document.getElementById("markerY"); + markerY.style.top = `${event.clientY + window.scrollY}px`; + markerY.textContent = `${event.type} event here`; + } + + document.addEventListener("mousedown", moveMarker); + document.addEventListener("mousemove", moveMarker); + document.addEventListener("mouseup", moveMarker); + + window.addEventListener("load", () => { + for (let splitter of document.querySelectorAll('hr[is="pane-splitter"]')) { + splitter.resizeElement = splitter.parentNode.querySelector(".resized"); + } + }); + </script> +</head> +<body> + <div id="horizontal-before"> + <div id="splitter1-before" class="resized"></div> + <hr is="pane-splitter" id="splitter1" resize-direction="horizontal" /> + <div id="splitter1-after" class="fill"></div> + </div> + + <div id="horizontal-after"> + <div id="splitter2-before" class="fill"></div> + <hr is="pane-splitter" id="splitter2" resize="next" resize-direction="horizontal" /> + <div id="splitter2-after" class="resized"></div> + </div> + + <div style="display: flex;"> + <div id="vertical-before"> + <div id="splitter3-before" class="resized"></div> + <hr is="pane-splitter" id="splitter3" /> + <div id="splitter3-after" class="fill"></div> + </div> + + <div id="vertical-after"> + <div id="splitter4-before" class="fill"></div> + <hr is="pane-splitter" id="splitter4" resize="next" /> + <div id="splitter4-after" class="resized"></div> + </div> + </div> + + <div id="markerX" style="position: absolute; top: 0px; border-left: 1px red solid;"></div> + <div id="markerY" style="position: absolute; left: 550px; border-top: 1px red solid;"></div> +</body> +</html> diff --git a/comm/mail/base/test/browser/files/rss.xml b/comm/mail/base/test/browser/files/rss.xml new file mode 100644 index 0000000000..8ff0540a66 --- /dev/null +++ b/comm/mail/base/test/browser/files/rss.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0"> + <channel> + <title>Test Feed</title> + <link>https://example.org/</link> + <description></description> + <lastBuildDate>Thu, 21 Jan 2021 17:57:54 +0000</lastBuildDate> + <language>en-US</language> + + <item> + <title>Test Article</title> + <link>https://example.org/browser/comm/mail/base/test/browser/files/sampleContent.html</link> + <pubDate>Wed, 20 Jan 2021 17:00:39 +0000</pubDate> + </item> + </channel> +</rss> diff --git a/comm/mail/base/test/browser/files/sampleContent.eml b/comm/mail/base/test/browser/files/sampleContent.eml new file mode 100644 index 0000000000..f0465ad2bd --- /dev/null +++ b/comm/mail/base/test/browser/files/sampleContent.eml @@ -0,0 +1,160 @@ +From andy@anway.invalid +Content-Type: multipart/related; + boundary="--------------CHOPCHOP0" +Subject: Big Meeting Today +From: "Andy Anway" <andy@anway.invalid> +To: "Bob Bell" <bob@bell.invalid> +Message-Id: <0@made.up.invalid> +Date: Tue, 01 Feb 2000 00:00:00 +1300 + +This is a multi-part message in MIME format. +----------------CHOPCHOP0 +Content-Type: text/html; charset=ISO-8859-1; format=flowed +Content-Transfer-Encoding: 7bit + +<!DOCTYPE html> +<html> + <head> + <link rel="icon" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png" /> + </head> + <body> + <p>This is a page of sample content for tests.</p> + <p><a href="https://www.thunderbird.net/">Link to a web page</a></p> + <form> + <input type="text" /> + </form> + <p><img src="cid:logo" width="304" height="84" /></p> + </body> +</html> + +----------------CHOPCHOP0 +Content-Type: image/png; charset=ISO-8859-1; format=flowed; + name="tb-logo.png" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="tb-logo.png" +Content-ID: <logo> + +iVBORw0KGgoAAAANSUhEUgAAATAAAABUCAMAAAAyN5s5AAAC91BMVEVMaXErLDVPU1wYFx1O +T1RPT1RNT1MTExoFBwZNTlNOTlNNT1RNTlNMTFRNTlMGBgoDBgYFBQZNTlQGBgdOTlNNTlMA +AwMEBQVNTlNOUFRMTFUAAABNTlMEBgdNT1QFBwoEBAZOTlNNTlMuOoNNTlQACghNTlRNTlNO +T1RNTlMTDytNT1N/qs1NTlMxPolNTlSZydyfmI8pMHosPIYaFSFNTlMdEkRNUFVNTlMrM38U +EBczT5hNTlMtNX8UDy8+XqIqN4AkGmCTiIEwN4JHaKgRCygnLHdzmsNYdq2Fd3N2nMRvlL8g +G00RCi0TCzAsQYsqHGJMbq5pjLsWDj9HYqV/qdh6nsxpYFljhsL////7+PL38+z59u////z8 ++/f8+vT28ufz7+UoG2D+/fnx7ODu6NvRyb3g2c7k39bSzcXr5NbIvq/Z0si9sqLJw7jDuKjb +0cCJhYHOxbbn3s/n5eGjn5mtqKPY1M+0r6rl2MS9ubVwbW54dXZgXl5HREZnZWHHxcJWVlU/ +PjevpJXazbR/fX0cJGV/d29UaaFPTka5rZx0b2P/++T77c9UVEphW01BQFtXTl6QnavN3exg +hcBIa65bgL0rS5dOcbGKt9NKZ6dPdLZdhbk8ZKlDcbNVd7RWe7l0pcszX6c5WpyKvN5EYaI9 +aK8yWaNJdrRff7OBsdc0RZUvUppukr8xN3knRZItLXkoUqFiisUoPIlsnMVpirxKfcdekL+Z +yusoQY4EAQktOIQOBVVWicRBaqgqMmp3msRTgr40VJ1SiLhAbrFwmc1glc5DWaEqMoBRcKtp +kMh0ntRMfrlHdronKVek1vYVCzd/ptg7aaEYGG83UZM6X6JDc6taea1cZXwkIUJDV5kLAyW3 +6P2PwOlhp+A4QIIcDg8+TpYoIXQiFVY0SYuClLAFABlZKzUdFWgyN4zU//82MTUrIGkpHWVO +T1SUjoosIm0eK3oSCS89RYwpGjNNQHREVIaXdlSjqrsmJCHZ+P6hTAAuJnRz9RRfAAAAWXRS +TlMAAw0H2DaeARv9u/JCJ39bSVawUY/kECNSFBsL6jdqQS2nyiBjFnOGWS6XSv75/cP+/Y1X +gpf+INIxbcWMRrneatP7sn305HhA1FEovMfh4/W3xtjNzNPUx47MeaUAABWdSURBVHhe7JTH +a1xXHIUleZ1lwPEygpDgFzEZEQUjhGQiYiEtjIwV8ofd13uv03vvvaj34t7Se1/kzigkJLGJ +DN69+VZv8eM++DjnjL2YyVXv2PjYRRlxI/bRxOJFhY0YX4tdH781Oz13oesRE2sg61377jaj +3phYWVpeXl5aWpl44fWISx93toN1FE/sVRAPIlpIkRHM/fnVxbnndnTEJ51UU+O2EomEkrQQ +yycwTIvK75Yd56Z37j9JG7HacXC94osqIkkiluVjKgSlynggNWTKO/mP6xGLHXZvi0laikLC +OopMpdJS5TIeCOAOBIcf3qW/r0d4sR4TNdYVMblORsWoub9PQV9lJxTSdUliWUmSQqH5xfNm +jrh2vZEzTuF2iUpSSUR9574cR4eqqlzVMFiJzaMsy1KL8HqEV93p9RRFXEes5Knoi/3pK6Sz +WrXKGWy1qlVZlCAIFKVuT47i9WHMPKrW1hWPYsG9h75ME+6XrEsatFXqsqymcRyNogSPDlhw +ua8F2xJ/lOvJpMdDIkjRx5gMQ6hQFywj1zUMbhgzjqYJAUVpCDrr6pBNR0D0zgP2dOCLRIqw +jibDq7I09LXXzUtcb1BL2hB4YQBN8xx+zcW+dHYnkw12PENfvv1Wi2eEPMyXBC11u908rGWP +MwwaI2gBwwQAnWFayrXGptmw/HD7a8Qa+jIpimoxOUIuwxoaRrfUBiwH6Q36yA908TwvhAVM +v3nJnb6W1E1Bnsr+fCJCX8VWQ4W+EiW1vCPvVnu5er1taL0Bw2wBrN0WiBYP2mE0MOtKXysq +Im5NPb3zq4dUyFjjXqNBYfUcKpfxQr9feHhExOPxbreUK5XCAACBAAwtwIzVajyOu9HYBAWs +van72UefkiRp2vcaRypTrxuyg+OpwuHMzMZG34nHSyWAUqBdqwECxkvAYDc7IBQKudDYLbZd +iXzTzD46IUmfbTcaKl87K+06eAASKRQK/Y3jB1I8v8uiFAFqYBivMDRWq4UgC64rpEnndzLP +vkjvnyBIy7bvb9udszo38JWCRDL+gr//+DgSh5tPU1SrxUNXcMEw3lfEoTB90m0BA5i8Y//+ +OH2wjhQbdvb7J5+fwYCd+4pAMk2/37+RLsTjtCAQBMFjAPaxBng+ltIlXXJZKZdj7YScCh6n +7x6cirHy0x+e/PbTWT3/l68MpBn0Z2bS/XhOwHiC57EwgAPWxhrlgCNpEjv9EgLeuHz1VQn4 +4K2rr116iam+/PZzX7nyP4Ku/Os3iE/cCzS/vfvLZwebRdPOfnWYfu+s5wRwuPm4nYLCmpBg +0H+YLtQxDNBQWLstYJs1UA6E8LKmafMXFfDO+2++++Xrr0rYH9Taa0xbV54A8AvEMCbgD8iW +MEJonCIeojYiFXGiTbVVsttKuyuNZjRayQ9sLNtgk5TGwRhi3qQB0mTSxwJmTAgkLjEJJHXi +MCkkdRpPaAykXcjYycRWCiZe1IiJzSOTCRIf9pxzL8fGNYnbrdLu/wO+HI7v43f/93+Oj0ny ++/3p+eyotGjM/K1+f4R9bPNzd7/kpOFhOLjhnSsXTo5cW55tbjpR9+XE/cG52dl/PPzTkOHS +yFILiCXHYxQWMMFYHvV9BUpXV1/nBHgep6d7YQUznPn61tnwFMvhxH8/OMlEph8ePOYnAqP5 +YfCjAkuFXbnsyLuIfyFYMnor/kbt9Svjhs8eja7W1dY1XRgefzzS9hx4XRp5tDw7u7q6+q/L +Ho9neWoKvoDN7smJiV4ANjlxe/ih0Qoq2NClU19/HZ5iHH+kyCVy8Wn/ZGD0tOgqAR3KJBOR +TzT2pWBbcIKdvjL0mWN+daHpSE3T/ZPjg8Zvvz1rNrQsA62F5vqjJ04crV9YnR0dHZ33LC/P +t0yOjXX2to9Nj5lun/eCkn/GbOjpvNMRlmLMSF7pCUQaHefEKwaLSY8IlkKCsaMH+8/Tw0Pm +R7PN/vramtove1w+Y4f5jHVqFmo1NdU11Bwp0OkqA7V19IWny575+fHpyfbuzsnzY/2G/sEh +Oyj5l8ydNwfCBsrCSGDbQN3dii/xFYMRWyKCZSCvnUTUYP9+4fVzZ02e1aMN9U0N6sZn5+qd +Zw0tnlnIBbACldWNVVVVjfrqyoCmtqaufr5levryTePwwwnTkME+ZHfdOmu+1Nd+071xLpa9 +lQyUUFzqF+YvECwhH9WK6MHeGT7fe3NmtBnkUn2DrOGTTz68sTQ1u1DfVFdTq6nUV2lFEoFA +UCTWNuoCtaBpwXP54cQX7rbPTxrMZrN9yHXq1JlLA5e/uL19A0BcLBnodAqpXxJ+SWA4slLi +gVfUYL8bPj/e+c3s0boGTaVG+1//0/zg6WozyK1akFyNWokAR5FIpQ/UNNTN+/578gv3ymSH +HXwVYrebu7p7Lt26fNn9dkSARAiWjRt+YWA4ogeLbbvw+rjxm9UTDUf00irp89WFj+uglkZT +UF0lRlAw5HI5+KFoDNS0Pr3++aft3hX3bbDC32Oy27s6b5nPTlz2OXZtCsb8fw7GpwfB/s39 +5en71h0LTbXVWoHo+Ucfq6sRV6BSr5UgLcpLIpHKJTK9RlO/PP15r8874+4AAcEuD5jPjvU5 +rr0VPRgf1I48RnxhFg0P+knZqcE7mpqdixpTC9FrEuiaseFS01IZjJxkgr0BLCGJUcjZnRli +QsvOQZ8HOClZFBifiMvZzWFmhRStBH5eNumYy8iE+wbdmXGkVA46TAyZYQmpmTTinZXXT590 +TDU3VJZKiwTPn6oE1Rrk1aiVYy7SSyiUSlU6TYNn+KHX613xgi+Pulwue9dE95lbY7eXrNuj +BvMTBIPlR5GIHHJ2ckGlW68p8MqAXmYiwI0HrzvJroVBUA4XtTCT00PAsqhd+lNIDTYHNHBB +Ox22kWBbiZz1A/Mpr3jYkATOLB6KgmET7TsGysWTXRkxLASWnJKYQfzL0pfnrz6eqj+iL5ZL +5QJ1gVavI70kCAoF8pIKxWKxTF2te+AeHrzt9q4Yu3sBmLl7rPvMQJ91xLo3ajAWHzWj4Cav +T0IY2Ji8AnS6KbFoorRhOp6JW1gsDAa748hBh0KbRB7VgsBYwb1tpbIb+YDtONSaQPOvV91U +JIeSiwSjMQszid/+5dynf/ZOHQ1UgfwBOaYLVOl0gQK9VgqQMBf0AlwisVCkUvtb+m/3Q7C+ +zm4A1jc5cKt/xGqwvp0QLVg6UMOxE7yNEQoWT4HlUucaDFImyx8SCAx74UAYHKgZTx6Lj8Bw +4FtFNdPWxanuycHbgtUIdkZGJvF7x8wfr3TsaNJpwSMHxQLVjdXVuiqxHPhJqEDpJRTCLaFW +qWnp6O93r6wY29u7XT2u9ulTA4NWw9CI47VowVAkFlK5k7wJWEywawrJthsVGrKNw9i9bR0M +ezFpuRksPHNnQDCUTP58KBN+4PSEDWBZOPfgDDuPerwZ2fkUGDx7gvhnX8u5T+9dbypXIDCJ +QN2q1jeqAR/wowJtw3STCIsEcv3RG139RvfKTNvERJfLZhsbu9MBvADYrh8Als3Hz1ZSZDB8 +hUzQNQ5tJ0Iw8vzz0GCwZR2MfJDQsMFOR3RBgcS8WD4fg6XAvEomtzM2gOWQMPlk93REijI1 +NR1PKwCY96Jl7Nvv6g8qhGJIJBC0arSNWhGoV0IqxECrSFAkBz8E2oJjDR8+6zIa3d6ZtrGJ +jh5b1/TEHednZgB27c2oweg0PNK/DIxFC9atbXh1ITNsWoEOwyEb89azJ8OPlSkZXAfTcNIE +wVJxW8i9xIfBYG7f0tqVWzs+kEEwYZFQpW7Qi2RSEUkG3SRyAeACWkXlmmOt75a996zDaPR6 +L96fHrZ19PSe/8prNbvsAOyNqMGSqAbOy8GSMQ0EozhZeEZJgrFx4YKByl4uBcZN2wCGx0aq +GoSD0UETzuOtFDU7FKzfNzjV/KelI8eBjVyiUMlkumMiiVBcLIIhJpNLAEJd0NpaUCoQFO9r +s40bV1ZuDE939twxjX9qWzKccdntPwCMHkM1FL4UjBsbBpaOZxghGZaDLw/fhkwKDCcYlkGR +SiVqOFh+KHpKhJn+P7X5Hj9ornv21wqRUCrTqiRFWm1BgUAqLJYVAy44vy+CWvqC1oAaVLDi +Yll5W0ev22u5cX6y697NkfF2h9Xc02My/RCwNGzzYjDcFYMl4wsNBcsmrygGRQITFUkKjBMR +jNoPMwwMG7HRlCIrEth9J+/B0dY9lY0isUolAmOjSCs/XC0AEwixRF4kh8lVVdC6p7oKuIkB +l+y4vr9vfMW749nnw7ar/Vb74IjB1WFzmUasrwIsB9eWUDCUU1wWFahMxlNgKZHBYrmIJwwM +J2QSHkTCwfaeM/IenIAzVVUpGBph3RJJ5AG1ADyMUgnQ0ur2aCrLpYIiIXhCi2UA7HnPSfft +G/P3Hw5cHbcY7CbDkK2r467JynvzFYBl4WoVBGPDo4QH58VgcUg1MTIYvi8RwLbfP2l98GGl +rlJXrkSVHg6LYDAEVQwkl6x6jyZQDthUYuRVLFMojn9y6r57Zn7qwvkvxv9mNfe7DPaOvns2 +p5W362cDy/8+WP5LMuzFYJmbgr3RdgWAlZfr9Kri4mIRRSYorxYIRPqAJqAXCQRSsVypkiIv +mUL2h+E/G2fmPX8fG3Mvzg31dLgMpr6T92y+z3iv/bwZxmWFRHp8NGApPxzsNd6Y68GH1ftL +lUoZJQbIJJLDlQWaPdUygUCO5hZSlVKMvJTH1Xd6LaOewLN+p2XO4eoCYN6rJwfcVgMv7hWA +ZZBgkWpYPhETjLiEF4Dhr0TiI4LhQTQvAhjxdvtXO5ory2RKhQKJkWQCUeCwSgAKlxhyiUVi +mUoEvRQK5bD1uqf+A92cw8eb493q7bjLW7ly1e0wOH9DvAKwPHwlEUbJsNgUDH/AyooMhqfH +kcC28yYvLhSoK1RlZUqFDJiJUEiBlpgM2FAsBEMo5Dr+fOajuoKD79cuuUCGOW+es9nmVk6P +rznszr0/EVjhi8DYfmwbBKMGNXa0YLhI0SKC4TGBGQnszaW28QXNvv2lpaUqFTSjyAAUjmIQ +sv1KmVKpLKs4VFFW8sF7f12655ybM0303vN+NzPumOOZnG/9H8GyowEjdqKNsJl+DBclRBRg +aaHnlU5EAsO6yAgvFLGoxZgE3trMR8d0Jfv374dkKiXKM1GYlkwmUu2XAS+Qh4cOvvfuu3+3 +Dvjm5oxX7tgscxaHw+Jz+Xb9eLCskEtLS38hGAOXYzz688M+MIF90DYDY4Q+kczNwTjUQgpO +RwxGbB+8/s2xyn0lajVphvKMQitGIUPFq3h/qQJ4VVQcOLhv3+G/Dd6zzM2du3rXt7bm4F2z +OF28hB8PlhP81JfMwkuAkcCoZ3IrEsvkrl9Wmh/hxFG6LP9mYH5OLpyXpuP1tUhguIhtycUL +cMH/b/i1/bsdxyrf23cQkAGzKurZJNEwl0KpKCspUwKvUgD2fuC6q3/Rsma0OXlra9d8DovJ +tJf40WDUDWfl8HOz6dSazmZgRPb6qhYHCKCg4VYWg8anZcLlrsLIYIg9hVpI201sCoa/Ec8v +jEdFIPT/G/5jcEd96+EDFepy0gyhqVCmQTYUsHopSkuUFRUVhwDYoZrrvpk1yxrPabq2aOH5 +LJa7zjd+NBiqTGHBpW0GFsMK75uHLzAk+N8DC3sftNkcDK3Ahe+Ril95LZ75px8HDiorSsrL +SwAayjQy1VSwasGAryUHQM0/dKDkwPsf/WUNeK05bb7FxYtO3+LFe78hIga6mYUhYPhZwJUi +jyxcODKyqQqOZDAYLsLsfKySnhKsS4xQjHxaHFUZsQAfTbyCvTjYMXQBcRv2jQlZ/kebqZh+ +u3OHxzM//7RGdwCY7QNmJBrKNRAVIGD1Kqs4eAiCHfjD4etAa23RYru7trjIM/Ge+GzbiU0y +jE5HuY8zjE6nc4MZBn5D5SgNyiKUHPit1k404Id0pYFtvPaSRd39ncmxsJm6H3kYcksGNUrS +6dgFeIC+eUQGi6Qmlamj0FGGgVfoiyNzC6XPJ7jgTwwMFndxbcqzvOwZnV+o1ZUqSsuhGVYD +UUpGRdmBgyRYDQJ74rsz82RxzWmyPLnrfC0yGDsNRGywISENRkzoX+Oo692dmJjITEUgJAE7 +pGsM3GZj9yQGk5kFUTc052YwU1JQO4pY/Ee8N3i1qdnM7FR8SsGjxOHuOPLAYRhJ1GHSgu1v +zTwCYIBsfnS+WbOvDJmBpxOz/W/79fPithHHYXiEDjsEWkkRaKBikKDooltaYYggpk7ZwMLa +LOyh1zKka1hXlfES6KY/LvYpkEP/ht5Nzu02PvRajHDcXHzYg0zYwgwMm6sO/c5Kzm5buuQ+ +fjHo/vAZaTxsOh0Nwet0Mn+lBiZf/ywk//XPtXix+Bxp1aMpTKzuzS+rHx8OvnrcHw0ATamN +jjYNh6N8OJk8v3emvMTypZBSLhfTcrn4GGnV/ensshbr7sLOdp0P7z39+vj4KM8BDRptmmT5 +ZPT8+7MKpKq1FBWbvl4zAQPTLFrNVkqsiElRXBZdZND07pP+lyffZPngH43zYT5/JaGKcyn4 +y4Uo18sPkG65f83qiZlBD8TaBvGjINn79uRxf5Dl1w2y8akaWBNb/jEr+fknSL+st/M3amIJ +CuPdorBIEISYuGDWPx7mz7KrcvUbzy9kU3n+23nJZAdp184Oss6VWNElYWTHvR4NbIgSaiV7 ++enJUdaYZZMfzip+lWTV78uSCf4R0i7D9JHzdr5aFbuUKqbUsSEXCsIgvbuXwZUifwYNxuUF +rxNysS4Z4w80HJhhehGyHsG3skgwSAUBYEEOZLkBtdM7e+N8MsmywU9ncgM2fQFe4jOkJxgm +HolnrUuYWL0tR2XVOSBoJXceqr/djZYQHLSYeIB0TC2MhKHn7rdasZpYM64buXYYuukX/IKL +dzEmP0VatmP6Hg5DinHabdl0s7CbYI7juiQ6lDe9uAgQpOeZ9CNCQkoxsVwaNBu7eSgBzMbt +6lpLMNkhqE7T1z4GsjDEwEaD2gzQrgtwXCknwIK4YDFCGoPBxoAMEwjYrtE2OZTGFQcpwUvl +xQ8DhAyNxWBjpu97UBTVcEotsOtcm7idSigp0SqFkKztI9PcQZqPDPIhUFNoNVlz5U8Oqnpe +RYtxWSaG4ZmGAtN6ZHU1G6ApMiVGMd2vuOJirV4pJevaKIr8BkxzNOidmiIDMQLzkkJwznrt +9kElDlPPJNF/B7YdG5CBGE4PKthX2Woncacq2ykx8JXXv8G2Y6u/nF7SacdxYlFi7e8ngWdi +gj1/6/W/lw0Mh9NQ9zRqE9PwMI6A65bzuBWDIqyCB2gB123z2r7Mmm9mk19z3eK1FQMylQ/B +w3gfrq3Zpvfm2qo1Ia37G8S93Ux/GSLZAAAAAElFTkSuQmCC + +----------------CHOPCHOP0-- + diff --git a/comm/mail/base/test/browser/files/sampleContent.html b/comm/mail/base/test/browser/files/sampleContent.html new file mode 100644 index 0000000000..05528ac9f1 --- /dev/null +++ b/comm/mail/base/test/browser/files/sampleContent.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>Sample Content</title> + <link rel="icon" href="tb-logo.png" /> + </head> + <body> + <p>This is a page of sample content for tests.</p> + <p><a href="https://www.thunderbird.net/">Link to a web page</a></p> + <form> + <input type="text" /> + </form> + <p><img src="tb-logo.png" width="304" height="84" /></p> + </body> +</html> diff --git a/comm/mail/base/test/browser/files/selectionWidget.js b/comm/mail/base/test/browser/files/selectionWidget.js new file mode 100644 index 0000000000..b1e5f98e25 --- /dev/null +++ b/comm/mail/base/test/browser/files/selectionWidget.js @@ -0,0 +1,225 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { SelectionWidgetController } = ChromeUtils.import( + "resource:///modules/SelectionWidgetController.jsm" +); + +/** + * Data for a selectable item. + * + * @typedef {object} ItemData + * @property {HTMLElement} element - The DOM node for the item. + * @property {boolean} selected - Whether the item is selected. + */ + +class TestSelectionWidget extends HTMLElement { + /** + * The selectable items for this widget, in DOM ordering. + * + * @type {ItemData[]} + */ + items = []; + #focusItem = this; + #controller = null; + + connectedCallback() { + let widget = this; + + widget.tabIndex = 0; + widget.setAttribute("role", "listbox"); + widget.setAttribute("aria-label", "Test selection widget"); + widget.setAttribute( + "aria-orientation", + widget.getAttribute("layout-direction") + ); + let model = widget.getAttribute("selection-model"); + widget.setAttribute("aria-multiselectable", model == "browse-multi"); + + this.#controller = new SelectionWidgetController(widget, model, { + getLayoutDirection() { + return widget.getAttribute("layout-direction"); + }, + indexFromTarget(target) { + for (let i = 0; i < widget.items.length; i++) { + if (widget.items[i].element.contains(target)) { + return i; + } + } + return null; + }, + getPageSizeDetails() { + if (widget.hasAttribute("no-pages")) { + return null; + } + let itemRect = widget.items[0]?.element.getBoundingClientRect(); + if (widget.getAttribute("layout-direction") == "vertical") { + return { + itemSize: itemRect?.height ?? null, + viewSize: widget.clientHeight, + viewOffset: widget.scrollTop, + }; + } + return { + itemSize: itemRect?.width ?? null, + viewSize: widget.clientWidth, + viewOffset: Math.abs(widget.scrollLeft), + }; + }, + setFocusableItem(index, focus) { + widget.#focusItem.tabIndex = -1; + widget.#focusItem = + index == null ? widget : widget.items[index].element; + widget.#focusItem.tabIndex = 0; + if (focus) { + widget.#focusItem.focus(); + widget.#focusItem.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + } + }, + setItemSelectionState(index, number, selected) { + for (let i = index; i < index + number; i++) { + widget.items[i].selected = selected; + widget.items[i].element.classList.toggle("selected", selected); + widget.items[i].element.setAttribute("aria-selected", selected); + } + }, + }); + } + + #createItemElement(text) { + for (let { element } of this.items) { + if (element.textContent == text) { + throw new Error(`An item with the text "${text}" already exists`); + } + } + let element = this.ownerDocument.createElement("span"); + element.textContent = text; + element.setAttribute("role", "option"); + element.tabIndex = -1; + element.draggable = this.hasAttribute("items-draggable"); + return element; + } + + /** + * Create new items and add them to the widget. + * + * @param {number} index - The starting index at which to add the items. + * @param {string[]} textList - The textContent for the items to add. Each + * entry in the array will create one item in the same order. + */ + addItems(index, textList) { + for (let [i, text] of textList.entries()) { + let element = this.#createItemElement(text); + this.insertBefore(element, this.items[index + i]?.element ?? null); + this.items.splice(index + i, 0, { element }); + } + this.#controller.addedSelectableItems(index, textList.length); + // Force re-layout. This is needed for the items to be able to enter the + // focus cycle immediately. + this.getBoundingClientRect(); + } + + /** + * Remove items from the widget. + * + * @param {number} index - The starting index at which to remove items. + * @param {number} number - How many items to remove. + */ + removeItems(index, number) { + this.#controller.removeSelectableItems(index, number, () => { + for (let { element } of this.items.splice(index, number)) { + element.remove(); + } + }); + } + + /** + * Move items within the widget. + * + * @param {number} from - The index at which to move items from. + * @param {number} to - The index at which to move items to. + * @param {number} number - How many items to move. + * @param {boolean} reCreate - Whether to recreate the item when + * moving it. Otherwise the existing item is used. + */ + moveItems(from, to, number, reCreate) { + if (reCreate == undefined) { + throw new Error("Missing reCreate argument"); + } + this.#controller.moveSelectableItems(from, to, number, () => { + let moving = this.items.splice(from, number); + for (let [i, item] of moving.entries()) { + item.element.remove(); + if (reCreate) { + let text = item.element.textContent; + item = { element: this.#createItemElement(text) }; + } + this.insertBefore(item.element, this.items[to + i]?.element ?? null); + this.items.splice(to + i, 0, item); + } + }); + } + + /** + * Selects a single item via the SelectionWidgetController.selectSingleItem + * method. + * + * @param {number} index - The index of the item to select. + */ + selectSingleItem(index) { + this.#controller.selectSingleItem(index); + } + + /** + * Changes the selection state of an item via the + * SelectionWidgetController.setItemSelected method. + * + * @param {number} index - The index of the item to set the selection state + * of. + * @param {boolean} select - Whether to select the item. + */ + setItemSelected(index, select) { + this.#controller.setItemSelected(index, select); + } + + /** + * Get the list of selected item's indices. + * + * @returns {number[]} - The indices for selected items. + */ + selectedIndices() { + let indices = []; + for (let i = 0; i < this.items.length; i++) { + // Assert that the item has a defined selection state set in + // setItemSelectionState. + if (typeof this.items[i].selected != "boolean") { + throw new Error(`Item ${i} has an undefined selection state`); + } + // Assert that our stored selection state matches that returned by the + // controller API. + let itemIsSelected = this.#controller.itemIsSelected(i); + if (this.items[i].selected != itemIsSelected) { + throw new Error( + `itemIsSelected(${i}): "${itemIsSelected}" does not match stored selection state "${this.items[i].selected}"` + ); + } + if (itemIsSelected) { + indices.push(i); + } + } + return indices; + } + + /** + * Get the return of SelectionWidgetController.getSelectionRanges + */ + getSelectionRanges() { + return this.#controller.getSelectionRanges(); + } +} + +customElements.define("test-selection-widget", TestSelectionWidget); diff --git a/comm/mail/base/test/browser/files/selectionWidget.xhtml b/comm/mail/base/test/browser/files/selectionWidget.xhtml new file mode 100644 index 0000000000..e5f66fc30c --- /dev/null +++ b/comm/mail/base/test/browser/files/selectionWidget.xhtml @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Test for SelectionWidgetController</title> + <style> + test-selection-widget { + display: flex; + align-items: start; + border: 1px solid black; + width: 600px; + height: 600px; + overflow: auto; + } + + test-selection-widget[layout-direction="vertical"] { + flex-direction: column; + } + + /* Fit 20 items in the view. */ + test-selection-widget[layout-direction="vertical"] > * { + height: 30px; + } + test-selection-widget[layout-direction="horizontal"] > * { + width: 30px; + writing-mode: vertical-rl; + } + + test-selection-widget > * { + padding-inline: 10px; + box-sizing: border-box; + border: 1px solid grey; + white-space: nowrap; + flex: 0 0 auto; + } + + .selected { + background: pink; + } + + :focus { + outline: 3px dashed black; + outline-offset: -3px; + } + + :focus-visible { + outline-color: blue; + } + </style> + <!-- Load the SelectionWidgetController class inline if testing in a browser. + <script src="../../../../modules/SelectionWidgetController.jsm"></script> + --> + <script defer="defer" src="selectionWidget.js"></script> +</head> +<body> +</body> +</html> diff --git a/comm/mail/base/test/browser/files/tb-logo.png b/comm/mail/base/test/browser/files/tb-logo.png Binary files differnew file mode 100644 index 0000000000..aac56e2546 --- /dev/null +++ b/comm/mail/base/test/browser/files/tb-logo.png diff --git a/comm/mail/base/test/browser/files/tree-element-test-common.js b/comm/mail/base/test/browser/files/tree-element-test-common.js new file mode 100644 index 0000000000..6f22962aca --- /dev/null +++ b/comm/mail/base/test/browser/files/tree-element-test-common.js @@ -0,0 +1,73 @@ +/* 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/. */ + +// FIXME: Wrap the whole method around the document load listener to prevent the +// undefined state of the "tree-view-table-row" element. This is due to the .mjs +// nature of the class file. +window.addEventListener("load", () => { + class AlternativeCardRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 80; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.cell = this.appendChild(document.createElement("td")); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + this.cell.textContent = this.view.getCellText(index, { + id: "GeneratedName", + }); + } + } + customElements.define("alternative-row", AlternativeCardRow, { + extends: "tr", + }); + + class TestView { + values = []; + + constructor(start, count) { + for (let i = start; i < start + count; i++) { + this.values.push(i); + } + } + + get rowCount() { + return this.values.length; + } + + getCellText(index, column) { + return `${column.id} ${this.values[index]}`; + } + + isContainer() { + return false; + } + + isContainerOpen() { + return false; + } + + selectionChanged() {} + + setTree() {} + } + + const tree = document.getElementById("testTree"); + tree.table.setBodyID("testBody"); + tree.addEventListener("select", () => { + console.log("select event, selected indices:", tree.selectedIndices); + }); + tree.view = new TestView(0, 150); +}); diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.js b/comm/mail/base/test/browser/files/tree-element-test-header.js new file mode 100644 index 0000000000..37d3b583e4 --- /dev/null +++ b/comm/mail/base/test/browser/files/tree-element-test-header.js @@ -0,0 +1,64 @@ +/* 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/. */ + +// FIXME: Wrap the whole method around the document load listener to prevent the +// undefined state of the "tree-view-table-row" element. This is due to the .mjs +// nature of the class file. +window.addEventListener("load", () => { + class TestCardRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 50; + + static COLUMNS = [ + { + id: "testCol", + // Ensure that a table header is rendered in order to verify that the + // header's presence doesn't cause issues with scroll calculations. + l10n: { + header: "threadpane-column-header-subject", + menuitem: "threadpane-column-label-subject", + }, + }, + ]; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.cell = this.appendChild(document.createElement("td")); + let container = this.cell.appendChild(document.createElement("div")); + + this.d1 = container.appendChild(document.createElement("div")); + this.d1.classList.add("d1"); + + this.d2 = this.d1.appendChild(document.createElement("div")); + this.d2.classList.add("d2"); + + this.d3 = this.d1.appendChild(document.createElement("div")); + this.d3.classList.add("d3"); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + this.d2.textContent = this.view.getCellText(index, { + id: "GeneratedName", + }); + this.d3.textContent = this.view.getCellText(index, { + id: "PrimaryEmail", + }); + this.dataset.value = this.view.values[index]; + } + } + customElements.define("test-row", TestCardRow, { extends: "tr" }); + + const tree = document.getElementById("testTree"); + tree.setAttribute("rows", "test-row"); + tree.table.setColumns(TestCardRow.COLUMNS); +}); diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml new file mode 100644 index 0000000000..522e3e5c60 --- /dev/null +++ b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml @@ -0,0 +1,61 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Test for the tree-view custom element</title> + <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" /> + <!-- Localization is necessary for the table header to display text. --> + <link rel="localization" href="messenger/about3Pane.ftl" /> + <style> + :root { + --color-gray-20: gray; + --selected-item-color: rebeccapurple; + --selected-item-text-color: white; + } + + /* We want a total visible row area of 630px, but we need to account for the + * height of the header as well. */ + #testTree { + height: calc(var(--tree-header-table-height) + 630px); + } + + .tree-view-scrollable-container { + scroll-behavior: unset; + } + + tr[is="test-row"] td > div { + display: flex; + align-items: center; + box-sizing: border-box; + } + + tr[is="test-row"] td div.d1 { + flex: 1; + } + + tr[is="test-row"] td div.d1 > div.d2 { + line-height: 1.2; + } + + tr[is="test-row"] td div.d1 > div.d3 { + line-height: 1.2; + font-size: 13px; + } + </style> + <script type="module" src="chrome://messenger/content/tree-view.mjs"></script> + <script src="tree-element-test-header.js"></script> + <script src="tree-element-test-common.js"></script> +</head> +<!-- We force layout-table in order to ensure that table header rows are + displayed.--> +<body class="layout-table"> + <input id="before" placeholder="something to focus on" /> + <tree-view id="testTree" data-select-delay="250"/> + <input id="after" placeholder="something to focus on" /> +</body> +</html> diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.js b/comm/mail/base/test/browser/files/tree-element-test-levels.js new file mode 100644 index 0000000000..7ea7eb8232 --- /dev/null +++ b/comm/mail/base/test/browser/files/tree-element-test-levels.js @@ -0,0 +1,118 @@ +/* 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/. */ + +/* globals PROTO_TREE_VIEW */ + +// FIXME: Wrap the whole method around the document load listener to prevent the +// undefined state of the "tree-view-table-row" element. This is due to the .mjs +// nature of the class file. +window.addEventListener("load", () => { + class TestCardRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 30; + + static COLUMNS = [ + { + id: "testCol", + }, + ]; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.cell = this.appendChild(document.createElement("td")); + let container = this.cell.appendChild(document.createElement("div")); + + this.threader = container.appendChild(document.createElement("button")); + this.threader.textContent = "↳"; + this.threader.classList.add("tree-button-thread"); + + this.twisty = container.appendChild(document.createElement("div")); + this.twisty.textContent = "v"; + this.twisty.classList.add("twisty"); + + this.d2 = container.appendChild(document.createElement("div")); + this.d2.classList.add("d2"); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + this.id = this.view.getRowProperties(index); + this.classList.remove("level0", "level1", "level2"); + this.classList.add(`level${this.view.getLevel(index)}`); + this.d2.textContent = this.view.getCellText(index, { id: "text" }); + } + } + customElements.define("test-row", TestCardRow, { extends: "tr" }); + + class TreeItem { + _children = []; + + constructor(id, text, open = false, level = 0) { + this._id = id; + this._text = text; + this._open = open; + this._level = level; + } + + getText() { + return this._text; + } + + get open() { + return this._open; + } + + get level() { + return this._level; + } + + get children() { + return this._children; + } + + getProperties() { + return this._id; + } + + addChild(treeItem) { + treeItem._parent = this; + treeItem._level = this._level + 1; + this.children.push(treeItem); + } + } + + let testView = new PROTO_TREE_VIEW(); + testView._rowMap.push(new TreeItem("row-1", "Item with no children")); + testView._rowMap.push(new TreeItem("row-2", "Item with children")); + testView._rowMap.push(new TreeItem("row-3", "Item with grandchildren")); + testView._rowMap[1].addChild(new TreeItem("row-2-1", "First child")); + testView._rowMap[1].addChild(new TreeItem("row-2-2", "Second child")); + testView._rowMap[2].addChild(new TreeItem("row-3-1", "First child")); + testView._rowMap[2].children[0].addChild( + new TreeItem("row-3-1-1", "First grandchild") + ); + testView._rowMap[2].children[0].addChild( + new TreeItem("row-3-1-2", "Second grandchild") + ); + testView.toggleOpenState(1); + testView.toggleOpenState(4); + testView.toggleOpenState(5); + + let tree = document.getElementById("testTree"); + tree.table.setBodyID("testBody"); + tree.setAttribute("rows", "test-row"); + tree.table.setColumns(TestCardRow.COLUMNS); + tree.addEventListener("select", () => { + console.log("select event, selected indices:", tree.selectedIndices); + }); + tree.view = testView; +}); diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml new file mode 100644 index 0000000000..1175887e74 --- /dev/null +++ b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml @@ -0,0 +1,65 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Test for the tree-view custom element</title> + <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" /> + <style> + :root { + --color-gray-20: gray; + --selected-item-color: rebeccapurple; + --selected-item-text-color: white; + } + + .tree-view-scrollable-container { + height: 630px; + scroll-behavior: unset; + } + + tr[is="test-row"] td > div { + display: flex; + align-items: center; + } + + button.threader { + width: 1em; + height: 1em; + } + + div.twisty { + width: 1em; + height: 1em; + } + + tr[is="test-row"].children button.threader { + display: inline-block; + } + + tr[is="test-row"] button.threader { + display: hidden; + } + + tr[is="test-row"].children div.twisty { + background-color: green; + } + + tr[is="test-row"].children.collapsed div.twisty { + background-color: red; + } + + tr[is="test-row"].level1 .d2 { + padding-inline-start: 1em; + } + + tr[is="test-row"].level2 .d2 { + padding-inline-start: 2em; + } + </style> + <script type="module" defer="defer" src="chrome://messenger/content/tree-view.mjs"></script> + <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script> + <script defer="defer" src="tree-element-test-levels.js"></script> +</head> +<body> + <tree-view id="testTree" data-select-delay="250"/> +</body> +</html> diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.js b/comm/mail/base/test/browser/files/tree-element-test-no-header.js new file mode 100644 index 0000000000..8a515be5e2 --- /dev/null +++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.js @@ -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/. */ + +// FIXME: Wrap the whole method around the document load listener to prevent the +// undefined state of the "tree-view-table-row" element. This is due to the .mjs +// nature of the class file. +window.addEventListener("load", () => { + class TestCardRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 50; + + static COLUMNS = [ + { + id: "testCol", + }, + ]; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.cell = this.appendChild(document.createElement("td")); + let container = this.cell.appendChild(document.createElement("div")); + + this.d1 = container.appendChild(document.createElement("div")); + this.d1.classList.add("d1"); + + this.d2 = this.d1.appendChild(document.createElement("div")); + this.d2.classList.add("d2"); + + this.d3 = this.d1.appendChild(document.createElement("div")); + this.d3.classList.add("d3"); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + this.d2.textContent = this.view.getCellText(index, { + id: "GeneratedName", + }); + this.d3.textContent = this.view.getCellText(index, { + id: "PrimaryEmail", + }); + this.dataset.value = this.view.values[index]; + } + } + customElements.define("test-row", TestCardRow, { extends: "tr" }); + + const tree = document.getElementById("testTree"); + tree.setAttribute("rows", "test-row"); + tree.table.setColumns(TestCardRow.COLUMNS); +}); diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml new file mode 100644 index 0000000000..7605279ba6 --- /dev/null +++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml @@ -0,0 +1,54 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Test for the tree-view custom element</title> + <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" /> + <style> + :root { + --color-gray-20: gray; + --selected-item-color: rebeccapurple; + --selected-item-text-color: white; + } + + /* We want a total visible row area of 630px. Intentionally avoid leaving + * room for a header. */ + .tree-view-scrollable-container { + height: 630px; + scroll-behavior: unset; + } + + tr[is="test-row"] td > div { + display: flex; + align-items: center; + box-sizing: border-box; + } + + tr[is="test-row"] td div.d1 { + flex: 1; + } + + tr[is="test-row"] td div.d1 > div.d2 { + line-height: 1.2; + } + + tr[is="test-row"] td div.d1 > div.d3 { + line-height: 1.2; + font-size: 13px; + } + </style> + <script type="module" src="chrome://messenger/content/tree-view.mjs"></script> + <script src="tree-element-test-no-header.js"></script> + <script src="tree-element-test-common.js"></script> +</head> +<body> + <input id="before" placeholder="something to focus on" /> + <tree-view id="testTree" data-select-delay="250"/> + <input id="after" placeholder="something to focus on" /> +</body> +</html> diff --git a/comm/mail/base/test/browser/files/treeListbox.xhtml b/comm/mail/base/test/browser/files/treeListbox.xhtml new file mode 100644 index 0000000000..a760ca141d --- /dev/null +++ b/comm/mail/base/test/browser/files/treeListbox.xhtml @@ -0,0 +1,390 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Test for the tree-listbox custom element</title> + <style> + :focus { + outline: 3px blue solid; + } + html { + height: 100%; + } + body { + height: 100%; + display: flex; + margin: 0; + } + ul[is="tree-listbox"] { + overflow-y: auto; + white-space: nowrap; + } + ul { + margin: 0; + padding: 0; + list-style: none; + } + li > div { + display: flex; + align-items: center; + } + li.selected > div { + color: white; + background-color: blue; + } + li > ul { + padding-inline-start: 1em; + } + li.collapsed > ul { + display: none; + } + div.twisty { + width: 1em; + height: 1em; + } + li.children > div > div.twisty { + background-color: green; + } + li.children.collapsed > div > div.twisty { + background-color: red; + } + li.unselectable > div { + background-color: red; + } + </style> + <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script> +</head> +<body> + <ul is="tree-listbox" role="tree"> + <li id="row-1"> + <div> + <div class="twisty"></div> + Item with no children + </div> + </li> + <li id="row-2"> + <div> + <div class="twisty"></div> + Item with children + </div> + <ul> + <li id="row-2-1"> + <div> + <div class="twisty"></div> + First child + </div> + </li> + <li id="row-2-2"> + <div> + <div class="twisty"></div> + Second child + </div> + </li> + </ul> + </li> + <li id="row-3"> + <div> + <div class="twisty"></div> + Item with grandchildren + </div> + <ul> + <li id="row-3-1"> + <div> + <div class="twisty"></div> + First child + </div> + <ul> + <li id="row-3-1-1"> + <div> + <div class="twisty"></div> + First grandchild + </div> + </li> + <li id="row-3-1-2"> + <div> + <div class="twisty"></div> + Second grandchild + </div> + </li> + </ul> + </li> + </ul> + </li> + </ul> + <template id="rowToAdd"> + <li id="new-row"> + <div> + <div class="twisty"></div> + New row + </div> + </li> + </template> + <template id="rowsToAdd"> + <li id="added-row"> + <div> + <div class="twisty"></div> + Added row + </div> + <ul> + <li id="added-row-1"> + <div> + <div class="twisty"></div> + Added child + </div> + <ul> + <li id="added-row-1-1"> + <div> + <div class="twisty"></div> + Added grandchild + </div> + </li> + </ul> + </li> + <li id="added-row-2"> + <div> + <div class="twisty"></div> + Added child + </div> + </li> + </ul> + </li> + </template> + <!-- Larger tree for deleting from --> + <ul> + <li>Before</li> + <li> + <!-- Place under a plain <li> an <ul> to make sure our selector logic + - doesn't break down. --> + <ul is="tree-listbox" id="deleteTree" role="tree"> + <li id="dRow-1" class="collapsed"> + <div> + <div class="twisty"></div> + Item with collapsed children + </div> + <ul> + <li id="dRow-1-1"> + <div> + <div class="twisty"></div> + Hidden child + </div> + </li> + </ul> + </li> + <li id="dRow-2"> + <div> + <div class="twisty"></div> + Item with children + </div> + <ul> + <li id="dRow-2-1"> + <div> + <div class="twisty"></div> + First child + </div> + </li> + <li id="dRow-2-2"> + <div> + <div class="twisty"></div> + Second child + </div> + </li> + </ul> + </li> + <li id="dRow-3"> + <div> + <div class="twisty"></div> + Item with grandchildren + </div> + <ul> + <li id="dRow-3-1"> + <div> + <div class="twisty"></div> + First child + </div> + <ul> + <li id="dRow-3-1-1" class="collapsed"> + <div> + <div class="twisty"></div> + First grandchild + </div> + <ul> + <li id="dRow-3-1-1-1"> + <div> + <div class="twisty"></div> + Hidden child + </div> + </li> + </ul> + </li> + <li id="dRow-3-1-2"> + <div> + <div class="twisty"></div> + Second grandchild + </div> + </li> + <li id="dRow-3-1-3" class="collapsed"> + <div> + <div class="twisty"></div> + Third grandchild + </div> + <ul> + <li id="dRow-3-1-3-1"> + <div> + <div class="twisty"></div> + Hidden child + </div> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </li> + <li id="dRow-4"> + <div> + <div class="twisty"></div> + Fourth item + </div> + <ul> + <li id="dRow-4-1" class="collapsed"> + <div> + <div class="twisty"></div> + First child + </div> + <ul> + <li id="dRow-4-1-1"> + <div> + <div class="twisty"></div> + Hidden child 1 + </div> + </li> + <li id="dRow-4-1-2"> + <div> + <div class="twisty"></div> + Hidden child 2 + </div> + </li> + </ul> + </li> + <li id="dRow-4-2"> + <div> + <div class="twisty"></div> + Second child + </div> + </li> + <li id="dRow-4-3"> + <div> + <div class="twisty"></div> + Third child + </div> + <ul> + <li id="dRow-4-3-1"> + <div> + <div class="twisty"></div> + First Grand child + </div> + </li> + <li id="dRow-4-3-2"> + <div> + <div class="twisty"></div> + Second Grand child + </div> + </li> + </ul> + </li> + <li id="dRow-4-4" class="collapsed"> + <div> + <div class="twisty"></div> + Fourth child + </div> + <ul> + <li id="dRow-4-4-1"> + <div> + <div class="twisty"></div> + Hidden child 1 + </div> + </li> + <li id="dRow-4-4-2"> + <div> + <div class="twisty"></div> + Hidden child 2 + </div> + </li> + </ul> + </li> + </ul> + </li> + <li id="dRow-5"> + <div> + <div class="twisty"></div> + Second last item + </div> + <ul> + <li id="dRow-5-1"> + <div> + <div class="twisty"></div> + Last child + </div> + </li> + </ul> + </li> + <li id="dRow-6"> + <div> + <div class="twisty"></div> + Last item + </div> + </li> + </ul> + </li> + <li>After</li> + </ul> + <!-- Tree with unselectable rows --> + <ul is="tree-listbox" id="unselectableTree" role="tree"> + <li id="uRow-1" class="unselectable"> + <div>Item with no children</div> + </li> + <li id="uRow-2" class="unselectable"> + <div>Item with children</div> + <ul> + <li id="uRow-2-1"> + <div> + <div class="twisty"></div> + First child + </div> + </li> + <li id="uRow-2-2"> + <div> + <div class="twisty"></div> + Second child + </div> + </li> + </ul> + </li> + <li id="uRow-3" class="unselectable"> + <div>Item with grandchildren</div> + <ul> + <li id="uRow-3-1"> + <div> + <div class="twisty"></div> + First child + </div> + <ul> + <li id="uRow-3-1-1"> + <div> + <div class="twisty"></div> + First grandchild + </div> + </li> + <li id="uRow-3-1-2"> + <div> + <div class="twisty"></div> + Second grandchild + </div> + </li> + </ul> + </li> + </ul> + </li> + </ul> +</body> +</html> diff --git a/comm/mail/base/test/browser/head.js b/comm/mail/base/test/browser/head.js new file mode 100644 index 0000000000..4ee9845d89 --- /dev/null +++ b/comm/mail/base/test/browser/head.js @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +async function focusWindow(win) { + win.focus(); + await TestUtils.waitForCondition( + () => Services.focus.focusedWindow?.browsingContext.topChromeWindow == win, + "waiting for window to be focused" + ); +} + +async function openExtensionPopup(win, buttonId) { + await focusWindow(win.top); + + let actionButton = await TestUtils.waitForCondition( + () => + win.document.querySelector( + `#${buttonId}, [item-id="${buttonId}"] button` + ), + "waiting for the action button to exist" + ); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(actionButton), + "waiting for action button to be visible" + ); + EventUtils.synthesizeMouseAtCenter(actionButton, {}, win); + + let panel = win.top.document.getElementById( + "webextension-remote-preload-panel" + ); + let browser = panel.querySelector("browser"); + await TestUtils.waitForCondition( + () => browser.clientWidth > 100, + "waiting for browser to resize" + ); + + return { actionButton, panel, browser }; +} + +function getSmartServer() { + return MailServices.accounts.findServer("nobody", "smart mailboxes", "none"); +} + +function resetSmartMailboxes() { + let oldServer = getSmartServer(); + // Clean up any leftover server from an earlier test. + if (oldServer) { + let oldAccount = MailServices.accounts.FindAccountForServer(oldServer); + MailServices.accounts.removeAccount(oldAccount, false); + } +} + +class MenuTestHelper { + /** @type {XULMenuElement} */ + menu; + + /** + * An object describing the state of a <menu> or <menuitem>. + * + * @typedef {Object} MenuItemData + * @property {boolean|string[]} [hidden] - true if the item should be hidden + * in all modes, or a list of modes in which it should be hidden. + * @property {boolean|string[]} [disabled] - true if the item should be + * disabled in all modes, or a list of modes in which it should be + * disabled. If the item should be hidden this property is ignored. + * @property {boolean|string[]} [checked] - true if the item should be + * checked in all modes, or a list of modes in which it should be + * checked. If the item should be hidden this property is ignored. + * @property {string} [l10nID] - the ID of the Fluent string this item + * should be displaying. If specified, `l10nArgs` will be checked. + * @property {object} [l10nArgs] - the arguments for the Fluent string this + * item should be displaying. If not specified, the string should not have + * arguments. + */ + /** + * An object describing the possible states of a menu's items. Object keys + * are the item's ID, values describe the item's state. + * + * @typedef {Object.<string, MenuItemData>} MenuData + */ + + /** @type {MenuData} */ + baseData; + + constructor(menuID, baseData) { + this.menu = document.getElementById(menuID); + this.baseData = baseData; + } + + /** + * Clicks on the menu and waits for it to open. + */ + async openMenu() { + let shownPromise = BrowserTestUtils.waitForEvent( + this.menu.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(this.menu, {}); + await shownPromise; + } + + /** + * Check that an item matches the expected state. + * + * @param {XULElement} actual - A <menu> or <menuitem>. + * @param {MenuItemData} expected + */ + checkItem(actual, expected) { + Assert.equal( + BrowserTestUtils.is_hidden(actual), + !!expected.hidden, + `${actual.id} hidden` + ); + if (!expected.hidden) { + Assert.equal( + actual.disabled, + !!expected.disabled, + `${actual.id} disabled` + ); + } + if (expected.checked) { + Assert.equal( + actual.getAttribute("checked"), + "true", + `${actual.id} checked` + ); + } else if (["checkbox", "radio"].includes(actual.getAttribute("type"))) { + Assert.ok( + !actual.hasAttribute("checked") || + actual.getAttribute("checked") == "false", + `${actual.id} not checked` + ); + } + if (expected.l10nID) { + let attributes = actual.ownerDocument.l10n.getAttributes(actual); + Assert.equal(attributes.id, expected.l10nID, `${actual.id} L10n string`); + Assert.deepEqual( + attributes.args, + expected.l10nArgs ?? null, + `${actual.id} L10n args` + ); + } + } + + /** + * Recurses through submenus performing checks on items. + * + * @param {XULPopupElement} popup - The current pop-up to check. + * @param {MenuData} data - The expected values to test against. + * @param {boolean} [itemsMustBeInData=false] - If true, all menu items and + * menus within `popup` must be specified in `data`. If false, items not + * in `data` will be ignored. + */ + async iterate(popup, data, itemsMustBeInData = false) { + if (popup.state != "open") { + await BrowserTestUtils.waitForEvent(popup, "popupshown"); + } + + for (let item of popup.children) { + if (!item.id || item.localName == "menuseparator") { + continue; + } + + if (!(item.id in data)) { + if (itemsMustBeInData) { + Assert.report(true, undefined, undefined, `${item.id} in data`); + } + continue; + } + let itemData = data[item.id]; + this.checkItem(item, itemData); + delete data[item.id]; + + if (item.localName == "menu") { + if (BrowserTestUtils.is_visible(item) && !item.disabled) { + item.openMenu(true); + await this.iterate(item.menupopup, data, itemsMustBeInData); + } else { + for (let hiddenItem of item.querySelectorAll("menu, menuitem")) { + delete data[hiddenItem.id]; + } + } + } + } + + popup.hidePopup(); + await new Promise(resolve => setTimeout(resolve)); + } + + /** + * Checks every item in the menu and submenus against the expected states. + * + * @param {string} mode - The current mode, used to select the right expected + * values from `baseData`. + */ + async testAllItems(mode) { + // Get the data for just this mode. + let data = {}; + for (let [id, itemData] of Object.entries(this.baseData)) { + data[id] = { + ...itemData, + hidden: itemData.hidden === true || itemData.hidden?.includes(mode), + disabled: + itemData.disabled === true || itemData.disabled?.includes(mode), + checked: itemData.checked === true || itemData.checked?.includes(mode), + }; + } + + // Open the menu and all submenus and check the items. + await this.openMenu(); + await this.iterate(this.menu.menupopup, data, true); + + // Report any unexpected items. + for (let id of Object.keys(data)) { + Assert.report(true, undefined, undefined, `extra item ${id} in data`); + } + } + + /** + * Checks specific items in the menu. + * + * @param {MenuData} data - The expected values to test against. + */ + async testItems(data) { + await this.openMenu(); + await this.iterate(this.menu.menupopup, data); + + for (let id of Object.keys(data)) { + Assert.report(true, undefined, undefined, `extra item ${id} in data`); + } + + if (this.menu.menupopup.state != "closed") { + let hiddenPromise = BrowserTestUtils.waitForEvent( + this.menu.menupopup, + "popuphidden" + ); + this.menu.menupopup.hidePopup(); + await hiddenPromise; + } + await new Promise(resolve => setTimeout(resolve)); + } + + /** + * Activates the item in the menu. + * + * @note This currently only works on top-level items. + * @param {string} menuItemID - The item to activate. + * @param {MenuData} [data] - If given, the expected state of the menu item + * before activation. + */ + async activateItem(menuItemID, data) { + await this.openMenu(); + let hiddenPromise = BrowserTestUtils.waitForEvent( + this.menu.menupopup, + "popuphidden" + ); + let item = document.getElementById(menuItemID); + if (data) { + this.checkItem(item, data); + } + this.menu.menupopup.activateItem(item); + await hiddenPromise; + await new Promise(resolve => setTimeout(resolve)); + } +} + +/** + * Helper method to switch to a cards view with vertical layout. + */ +async function ensure_cards_view() { + const { threadTree, threadPane } = + document.getElementById("tabmail").currentAbout3Pane; + + Services.prefs.setIntPref("mail.pane_config.dynamic", 2); + Services.xulStore.setValue( + "chrome://messenger/content/messenger.xhtml", + "threadPane", + "view", + "cards" + ); + threadPane.updateThreadView("cards"); + + await BrowserTestUtils.waitForCondition( + () => threadTree.getAttribute("rows") == "thread-card", + "The tree view switched to a cards layout" + ); +} + +/** + * Helper method to switch to a table view with classic layout. + */ +async function ensure_table_view() { + const { threadTree, threadPane } = + document.getElementById("tabmail").currentAbout3Pane; + + Services.prefs.setIntPref("mail.pane_config.dynamic", 0); + Services.xulStore.setValue( + "chrome://messenger/content/messenger.xhtml", + "threadPane", + "view", + "table" + ); + threadPane.updateThreadView("table"); + + await BrowserTestUtils.waitForCondition( + () => threadTree.getAttribute("rows") == "thread-row", + "The tree view switched to a table layout" + ); +} + +// Report and remove any remaining accounts/servers. If we register a cleanup +// function here, it will run before any other cleanup function has had a +// chance to run. Instead, when it runs register another cleanup function +// which will run last. +registerCleanupFunction(function () { + registerCleanupFunction(function () { + Services.prefs.clearUserPref("mail.pane_config.dynamic"); + Services.xulStore.removeValue( + "chrome://messenger/content/messenger.xhtml", + "threadPane", + "view" + ); + + let tabmail = document.getElementById("tabmail"); + if (tabmail.tabInfo.length > 1) { + Assert.report( + true, + undefined, + undefined, + "Unexpected tab(s) open at the end of the test run" + ); + tabmail.closeOtherTabs(0); + } + + for (let server of MailServices.accounts.allServers) { + Assert.report( + true, + undefined, + undefined, + `Found ${server} at the end of the test run` + ); + MailServices.accounts.removeIncomingServer(server, false); + } + for (let account of MailServices.accounts.accounts) { + Assert.report( + true, + undefined, + undefined, + `Found ${account} at the end of the test run` + ); + MailServices.accounts.removeAccount(account, false); + } + + resetSmartMailboxes(); + ensure_cards_view(); + + // Some tests that open new windows don't return focus to the main window + // in a way that satisfies mochitest, and the test times out. + Services.focus.focusedWindow = window; + // Focus an element in the main window, then blur it again to avoid it + // hijacking keypresses. + let mainWindowElement = document.getElementById("button-appmenu"); + mainWindowElement.focus(); + mainWindowElement.blur(); + }); +}); diff --git a/comm/mail/base/test/browser/head_spacesToolbar.js b/comm/mail/base/test/browser/head_spacesToolbar.js new file mode 100644 index 0000000000..f08f3deee5 --- /dev/null +++ b/comm/mail/base/test/browser/head_spacesToolbar.js @@ -0,0 +1,34 @@ +/* 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/. */ + +async function sub_test_toolbar_alignment(drawInTitlebar, hideMenu) { + let menubar = document.getElementById("toolbar-menubar"); + let tabsInTitlebar = + document.documentElement.getAttribute("tabsintitlebar") == "true"; + Assert.equal(tabsInTitlebar, drawInTitlebar); + + if (hideMenu) { + menubar.setAttribute("autohide", true); + menubar.setAttribute("inactive", true); + } else { + menubar.removeAttribute("autohide"); + menubar.removeAttribute("inactive"); + } + await new Promise(resolve => requestAnimationFrame(resolve)); + + let size = document + .getElementById("spacesToolbar") + .getBoundingClientRect().width; + + Assert.equal( + document.getElementById("titlebar").getBoundingClientRect().left, + size, + "The correct style was applied to the #titlebar" + ); + Assert.equal( + document.getElementById("toolbar-menubar").getBoundingClientRect().left, + size, + "The correct style was applied to the #toolbar-menubar" + ); +} |