summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/test/browser
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/test/browser')
-rw-r--r--comm/mail/base/test/browser/browser-detachedWindows.ini15
-rw-r--r--comm/mail/base/test/browser/browser-drawBelowTitlebar.ini17
-rw-r--r--comm/mail/base/test/browser/browser-drawInTitlebar.ini17
-rw-r--r--comm/mail/base/test/browser/browser.ini66
-rw-r--r--comm/mail/base/test/browser/browser_3paneTelemetry.js163
-rw-r--r--comm/mail/base/test/browser/browser_archive.js98
-rw-r--r--comm/mail/base/test/browser/browser_browserContext.js398
-rw-r--r--comm/mail/base/test/browser/browser_browserRequestWindow.js74
-rw-r--r--comm/mail/base/test/browser/browser_cardsView.js248
-rw-r--r--comm/mail/base/test/browser/browser_detachedWindows.js223
-rw-r--r--comm/mail/base/test/browser/browser_editMenu.js511
-rw-r--r--comm/mail/base/test/browser/browser_fileMenu.js137
-rw-r--r--comm/mail/base/test/browser/browser_folderPaneContext.js198
-rw-r--r--comm/mail/base/test/browser/browser_folderTreeProperties.js236
-rw-r--r--comm/mail/base/test/browser/browser_folderTreeQuirks.js1450
-rw-r--r--comm/mail/base/test/browser/browser_formPickers.js352
-rw-r--r--comm/mail/base/test/browser/browser_goMenu.js35
-rw-r--r--comm/mail/base/test/browser/browser_interactionTelemetry.js67
-rw-r--r--comm/mail/base/test/browser/browser_linkHandler.js294
-rw-r--r--comm/mail/base/test/browser/browser_mailContext.js950
-rw-r--r--comm/mail/base/test/browser/browser_mailTabsAndWindows.js355
-rw-r--r--comm/mail/base/test/browser/browser_markAsRead.js204
-rw-r--r--comm/mail/base/test/browser/browser_menulist.js183
-rw-r--r--comm/mail/base/test/browser/browser_messageMenu.js355
-rw-r--r--comm/mail/base/test/browser/browser_navigation.js1035
-rw-r--r--comm/mail/base/test/browser/browser_orderableTreeListbox.js481
-rw-r--r--comm/mail/base/test/browser/browser_paneFocus.js375
-rw-r--r--comm/mail/base/test/browser/browser_paneSplitter.js572
-rw-r--r--comm/mail/base/test/browser/browser_preferDisplayName.js456
-rw-r--r--comm/mail/base/test/browser/browser_searchMessages.js460
-rw-r--r--comm/mail/base/test/browser/browser_selectionWidgetController.js6196
-rw-r--r--comm/mail/base/test/browser/browser_smartFolderDelete.js75
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar.js1173
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbarCustomize.js119
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js24
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js24
-rw-r--r--comm/mail/base/test/browser/browser_statusFeedback.js71
-rw-r--r--comm/mail/base/test/browser/browser_tabIcon.js99
-rw-r--r--comm/mail/base/test/browser/browser_tagsMode.js214
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeDeleting.js572
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeQuirks.js669
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeSorting.js344
-rw-r--r--comm/mail/base/test/browser/browser_threads.js385
-rw-r--r--comm/mail/base/test/browser/browser_toolsMenu.js109
-rw-r--r--comm/mail/base/test/browser/browser_treeListbox.js1313
-rw-r--r--comm/mail/base/test/browser/browser_treeView.js1941
-rw-r--r--comm/mail/base/test/browser/browser_viewMenu.js218
-rw-r--r--comm/mail/base/test/browser/browser_webSearchTelemetry.js50
-rw-r--r--comm/mail/base/test/browser/browser_zoom.js110
-rw-r--r--comm/mail/base/test/browser/files/formContent.html36
-rw-r--r--comm/mail/base/test/browser/files/links.html38
-rw-r--r--comm/mail/base/test/browser/files/menulist.xhtml30
-rw-r--r--comm/mail/base/test/browser/files/orderableTreeListbox.xhtml171
-rw-r--r--comm/mail/base/test/browser/files/paneSplitter.xhtml122
-rw-r--r--comm/mail/base/test/browser/files/rss.xml16
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.eml160
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.html16
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.js225
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.xhtml57
-rw-r--r--comm/mail/base/test/browser/files/tb-logo.pngbin0 -> 6462 bytes
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-common.js73
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.js64
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.xhtml61
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.js118
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.xhtml65
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.js58
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml54
-rw-r--r--comm/mail/base/test/browser/files/treeListbox.xhtml390
-rw-r--r--comm/mail/base/test/browser/head.js371
-rw-r--r--comm/mail/base/test/browser/head_spacesToolbar.js34
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">&pi;</option>
+ <option value="6.283185308">&tau;</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
new file mode 100644
index 0000000000..aac56e2546
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tb-logo.png
Binary files differ
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"
+ );
+}