From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- ...wser_unified_extensions_overflowable_toolbar.js | 1389 ++++++++++++++++++++ 1 file changed, 1389 insertions(+) create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js (limited to 'browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js') diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js b/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js new file mode 100644 index 0000000000..187e1a111f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js @@ -0,0 +1,1389 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests the behaviour of the overflowable nav-bar with Unified + * Extensions enabled and disabled. + */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +requestLongerTimeout(2); + +const NUM_EXTENSIONS = 5; +const OVERFLOW_WINDOW_WIDTH_PX = 450; +const DEFAULT_WIDGET_IDS = [ + "home-button", + "library-button", + "zoom-controls", + "search-container", + "sidebar-button", +]; +const OVERFLOWED_EXTENSIONS_LIST_ID = "overflowed-extensions-list"; + +add_setup(async function () { + // To make it easier to control things that will overflow, we'll start by + // removing that's removable out of the nav-bar and adding just a fixed + // set of items (DEFAULT_WIDGET_IDS) at the end of the nav-bar. + let existingWidgetIDs = CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_NAVBAR + ); + for (let widgetID of existingWidgetIDs) { + if (CustomizableUI.isWidgetRemovable(widgetID)) { + CustomizableUI.removeWidgetFromArea(widgetID); + } + } + for (const widgetID of DEFAULT_WIDGET_IDS) { + CustomizableUI.addWidgetToArea(widgetID, CustomizableUI.AREA_NAVBAR); + } + + registerCleanupFunction(async () => { + await CustomizableUI.reset(); + }); +}); + +/** + * Returns the IDs of the children of parent. + * + * @param {Element} parent + * @returns {string[]} the IDs of the children + */ +function getChildrenIDs(parent) { + return Array.from(parent.children).map(child => child.id); +} + +/** + * Returns a NodeList of all non-hidden menu, menuitem and menuseparators + * that are direct descendants of popup. + * + * @param {Element} popup + * @returns {NodeList} the visible items. + */ +function getVisibleMenuItems(popup) { + return popup.querySelectorAll( + ":scope > :is(menu, menuitem, menuseparator):not([hidden])" + ); +} + +/** + * This helper function does most of the heavy lifting for these tests. + * It does the following in order: + * + * 1. Registers and enables NUM_EXTENSIONS test WebExtensions that add + * browser_action buttons to the nav-bar. + * 2. Resizes the window to force things after the URL bar to overflow. + * 3. Calls an async test function to analyze the overflow lists. + * 4. Restores the window's original width, ensuring that the IDs of the + * nav-bar match the original set. + * 5. Unloads all of the test WebExtensions + * + * @param {DOMWindow} win The browser window to perform the test on. + * @param {object} options Additional options when running this test. + * @param {Function} options.beforeOverflowed This optional async function will + * be run after the extensions are created and added to the toolbar, but + * before the toolbar overflows. The function is called with the following + * arguments: + * + * {string[]} extensionIDs: The IDs of the test WebExtensions. + * + * The return value of the function is ignored. + * @param {Function} options.whenOverflowed This optional async function will + * run once the window is in the overflow state. The function is called + * with the following arguments: + * + * {Element} defaultList: The DOM element that holds overflowed default + * items. + * {Element} unifiedExtensionList: The DOM element that holds overflowed + * WebExtension browser_actions when Unified Extensions is enabled. + * {string[]} extensionIDs: The IDs of the test WebExtensions. + * + * The return value of the function is ignored. + * @param {Function} options.afterUnderflowed This optional async function will + * be run after the window is expanded and the toolbar has underflowed, but + * before the extensions are removed. This function is not passed any + * arguments. The return value of the function is ignored. + * + */ +async function withWindowOverflowed( + win, + { + beforeOverflowed = async () => {}, + whenOverflowed = async () => {}, + afterUnderflowed = async () => {}, + } = {} +) { + const doc = win.document; + doc.documentElement.removeAttribute("persist"); + const navbar = doc.getElementById(CustomizableUI.AREA_NAVBAR); + + await ensureMaximizedWindow(win); + + // The OverflowableToolbar operates asynchronously at times, so we will + // poll a widget's overflowedItem attribute to detect whether or not the + // widgets have finished being moved. We'll use the first widget that + // we added to the nav-bar, as this should be the left-most item in the + // set that we added. + const signpostWidgetID = "home-button"; + // We'll also force the signpost widget to be extra-wide to ensure that it + // overflows after we shrink the window. + CustomizableUI.getWidget(signpostWidgetID).forWindow(win).node.style = + "width: 150px"; + + const extWithMenuBrowserAction = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Extension #0", + browser_specific_settings: { + gecko: { id: "unified-extensions-overflowable-toolbar@ext-0" }, + }, + browser_action: { + default_area: "navbar", + }, + // We pass `activeTab` to have a different permission message when + // hovering the primary/action button. + permissions: ["activeTab", "contextMenus"], + }, + background() { + browser.contextMenus.create( + { + id: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + + const extWithSubMenuBrowserAction = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Extension #1", + browser_specific_settings: { + gecko: { id: "unified-extensions-overflowable-toolbar@ext-1" }, + }, + browser_action: { + default_area: "navbar", + }, + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create({ + id: "some-menu-id", + title: "Open sub-menu", + contexts: ["all"], + }); + browser.contextMenus.create( + { + id: "some-sub-menu-id", + parentId: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + + const manifests = []; + for (let i = 2; i < NUM_EXTENSIONS; ++i) { + manifests.push({ + name: `Extension #${i}`, + browser_action: { + default_area: "navbar", + }, + browser_specific_settings: { + gecko: { id: `unified-extensions-overflowable-toolbar@ext-${i}` }, + }, + }); + } + + const extensions = [ + extWithMenuBrowserAction, + extWithSubMenuBrowserAction, + ...createExtensions(manifests), + ]; + + // Adding browser actions is asynchronous, so this CustomizableUI listener + // is used to make sure that the browser action widgets have finished getting + // added. + let listener = { + _remainingBrowserActions: NUM_EXTENSIONS, + _deferred: PromiseUtils.defer(), + + get promise() { + return this._deferred.promise; + }, + + onWidgetAdded(widgetID, area) { + if (widgetID.endsWith("-browser-action")) { + this._remainingBrowserActions--; + } + if (!this._remainingBrowserActions) { + this._deferred.resolve(); + } + }, + }; + CustomizableUI.addListener(listener); + // Start all the extensions sequentially. + for (const extension of extensions) { + await extension.startup(); + } + await Promise.all([ + extWithMenuBrowserAction.awaitMessage("menu-created"), + extWithSubMenuBrowserAction.awaitMessage("menu-created"), + ]); + await listener.promise; + CustomizableUI.removeListener(listener); + + const extensionIDs = extensions.map(extension => extension.id); + + try { + info("Running beforeOverflowed task"); + await beforeOverflowed(extensionIDs); + } finally { + // The beforeOverflowed task may have moved some items out from the navbar, + // so only listen for overflows for items still in there. + const browserActionIDs = extensionIDs.map(id => + AppUiTestInternals.getBrowserActionWidgetId(id) + ); + const browserActionsInNavBar = browserActionIDs.filter(widgetID => { + let placement = CustomizableUI.getPlacementOfWidget(widgetID); + return placement.area == CustomizableUI.AREA_NAVBAR; + }); + + let widgetOverflowListener = { + _remainingOverflowables: + browserActionsInNavBar.length + DEFAULT_WIDGET_IDS.length, + _deferred: PromiseUtils.defer(), + + get promise() { + return this._deferred.promise; + }, + + onWidgetOverflow(widgetNode, areaNode) { + this._remainingOverflowables--; + if (!this._remainingOverflowables) { + this._deferred.resolve(); + } + }, + }; + CustomizableUI.addListener(widgetOverflowListener); + + win.resizeTo(OVERFLOW_WINDOW_WIDTH_PX, win.outerHeight); + await widgetOverflowListener.promise; + CustomizableUI.removeListener(widgetOverflowListener); + + Assert.ok( + navbar.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + const defaultList = doc.getElementById( + navbar.getAttribute("default-overflowtarget") + ); + + const unifiedExtensionList = doc.getElementById( + navbar.getAttribute("addon-webext-overflowtarget") + ); + + try { + info("Running whenOverflowed task"); + await whenOverflowed(defaultList, unifiedExtensionList, extensionIDs); + } finally { + await ensureMaximizedWindow(win); + + // Notably, we don't wait for the nav-bar to not have the "overflowing" + // attribute. This is because we might be running in an environment + // where the nav-bar was overflowing to begin with. Let's just hope that + // our sign-post widget has stopped overflowing. + await TestUtils.waitForCondition(() => { + return !doc + .getElementById(signpostWidgetID) + .hasAttribute("overflowedItem"); + }); + + try { + info("Running afterUnderflowed task"); + await afterUnderflowed(); + } finally { + await Promise.all(extensions.map(extension => extension.unload())); + } + } + } +} + +async function verifyExtensionWidget(widget, win = window) { + Assert.ok(widget, "expected widget"); + + let actionButton = widget.querySelector( + ".unified-extensions-item-action-button" + ); + Assert.ok( + actionButton.classList.contains("unified-extensions-item-action-button"), + "expected action class on the button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + + let menuButton = widget.lastElementChild; + Assert.ok( + menuButton.classList.contains("unified-extensions-item-menu-button"), + "expected class on the button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + let contents = actionButton.querySelector( + ".unified-extensions-item-contents" + ); + + Assert.ok(contents, "expected contents element"); + // This is needed to correctly position the contents (vbox) element in the + // toolbarbutton. + Assert.equal( + contents.getAttribute("move-after-stack"), + "true", + "expected move-after-stack attribute to be set" + ); + // Make sure the contents element is inserted after the stack one (which is + // automagically created by the toolbarbutton element). + Assert.deepEqual( + Array.from(actionButton.childNodes.values()).map( + child => child.classList[0] + ), + [ + // The stack (which contains the extension icon) should be the first + // child. + "toolbarbutton-badge-stack", + // This is the widget label, which is hidden with CSS. + "toolbarbutton-text", + // This is the contents element, which displays the extension name and + // messages. + "unified-extensions-item-contents", + ], + "expected the correct order for the children of the action button" + ); + + let name = contents.querySelector(".unified-extensions-item-name"); + Assert.ok(name, "expected name element"); + Assert.ok( + name.textContent.startsWith("Extension "), + "expected name to not be empty" + ); + Assert.ok( + contents.querySelector(".unified-extensions-item-message-default"), + "expected message default element" + ); + Assert.ok( + contents.querySelector(".unified-extensions-item-message-hover"), + "expected message hover element" + ); + + Assert.equal( + win.document.l10n.getAttributes(menuButton).id, + "unified-extensions-item-open-menu", + "expected l10n id attribute for the extension" + ); + Assert.deepEqual( + Object.keys(win.document.l10n.getAttributes(menuButton).args), + ["extensionName"], + "expected l10n args attribute for the extension" + ); + Assert.ok( + win.document.l10n + .getAttributes(menuButton) + .args.extensionName.startsWith("Extension "), + "expected l10n args attribute to start with the correct name" + ); + Assert.ok( + menuButton.getAttribute("aria-label") !== "", + "expected menu button to have non-empty localized content" + ); +} + +/** + * Tests that overflowed browser actions go to the Unified Extensions + * panel, and default toolbar items go into the default overflow + * panel. + */ +add_task(async function test_overflowable_toolbar() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let movedNode; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + // Ensure that there are 5 items in the Unified Extensions overflow + // list, and the default widgets should all be in the default overflow + // list (though there might be more items from the nav-bar in there that + // already existed in the nav-bar before we put the default widgets in + // there as well). + let defaultListIDs = getChildrenIDs(defaultList); + for (const widgetID of DEFAULT_WIDGET_IDS) { + Assert.ok( + defaultListIDs.includes(widgetID), + `Default overflow list should have ${widgetID}` + ); + } + + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + for (const child of Array.from(unifiedExtensionList.children)) { + Assert.ok( + extensionIDs.includes(child.dataset.extensionid), + `Unified Extensions overflow list should have ${child.dataset.extensionid}` + ); + await verifyExtensionWidget(child, win); + } + + let extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + Assert.equal(movedNode.getAttribute("cui-areatype"), "toolbar"); + + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_ADDONS + ); + + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "panel", + "The moved browser action button should have the right cui-areatype set." + ); + }, + afterUnderflowed: async () => { + // Ensure that the moved node's parent is still the add-ons panel. + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_ADDONS, + "The browser action should still be in the addons panel" + ); + CustomizableUI.addWidgetToArea(movedNode.id, CustomizableUI.AREA_NAVBAR); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_context_menu() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + // Open the extension panel. + await openExtensionsPanel(win); + + // Let's verify the context menus for the following extensions: + // + // - the first one defines a menu in the background script + // - the second one defines a menu with submenu + // - the third extension has no menu + + info("extension with browser action and a menu"); + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected extension widget"); + let contextMenu = await openUnifiedExtensionsContextMenu( + firstExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + let visibleItems = getVisibleMenuItems(contextMenu); + + // The context menu for the extension that declares a browser action menu + // should have the menu item created by the extension, a menu separator, the control + // for pinning the browser action to the toolbar, a menu separator and the 3 default menu items. + is( + visibleItems.length, + 7, + "expected a custom context menu item, a menu separator, the pin to " + + "toolbar menu item, a menu separator, and the 3 default menu items" + ); + + const [item, separator] = visibleItems; + is( + item.getAttribute("label"), + "Click me!", + "expected menu item as first child" + ); + is( + separator.tagName, + "menuseparator", + "expected separator after last menu item created by the extension" + ); + + await closeChromeContextMenu(contextMenu.id, null, win); + + info("extension with browser action and a menu with submenu"); + const secondExtensionWidget = unifiedExtensionList.children[1]; + Assert.ok(secondExtensionWidget, "expected extension widget"); + contextMenu = await openUnifiedExtensionsContextMenu( + secondExtensionWidget.dataset.extensionid, + win + ); + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 7, "expected 7 menu items"); + const popup = await openSubmenu(visibleItems[0]); + is(popup.children.length, 1, "expected 1 submenu item"); + is( + popup.children[0].getAttribute("label"), + "Click me!", + "expected menu item" + ); + // The number of items in the (main) context menu should remain the same. + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 7, "expected 7 menu items"); + await closeChromeContextMenu(contextMenu.id, null, win); + + info("extension with no browser action and no menu"); + // There is no context menu created by this extension, so there should + // only be 3 menu items corresponding to the default manage/remove/report + // items. + const thirdExtensionWidget = unifiedExtensionList.children[2]; + Assert.ok(thirdExtensionWidget, "expected extension widget"); + contextMenu = await openUnifiedExtensionsContextMenu( + thirdExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 5, "expected 5 menu items"); + + await closeChromeContextMenu(contextMenu.id, null, win); + + // We can close the unified extensions panel now. + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_message_deck() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected extension widget"); + Assert.ok( + firstExtensionWidget.dataset.extensionid, + "expected data attribute for extension ID" + ); + + // Navigate to a page where `activeTab` is useful. + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/" }, + async () => { + // Open the extension panel. + await openExtensionsPanel(win); + + info("verify message when focusing the action button"); + const item = getUnifiedExtensionsItem( + firstExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(item, "expected an item for the extension"); + + const actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + Assert.ok(actionButton, "expected action button"); + + const menuButton = item.querySelector( + ".unified-extensions-item-menu-button" + ); + Assert.ok(menuButton, "expected menu button"); + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + Assert.ok(messageDeck, "expected message deck"); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + const defaultMessage = item.querySelector( + ".unified-extensions-item-message-default" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(defaultMessage), + { id: "origin-controls-state-when-clicked", args: null }, + "expected correct l10n attributes for the default message" + ); + Assert.ok( + defaultMessage.textContent !== "", + "expected default message to not be empty" + ); + + const hoverMessage = item.querySelector( + ".unified-extensions-item-message-hover" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(hoverMessage), + { id: "origin-controls-state-hover-run-visit-only", args: null }, + "expected correct l10n attributes for the hover message" + ); + Assert.ok( + hoverMessage.textContent !== "", + "expected hover message to not be empty" + ); + + const hoverMenuButtonMessage = item.querySelector( + ".unified-extensions-item-message-hover-menu-button" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(hoverMenuButtonMessage), + { id: "unified-extensions-item-message-manage", args: null }, + "expected correct l10n attributes for the message when hovering the menu button" + ); + Assert.ok( + hoverMenuButtonMessage.textContent !== "", + "expected message for when the menu button is hovered to not be empty" + ); + + // 1. Focus the action button of the first extension in the panel. + let focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}, win); + await focused; + is( + actionButton, + win.document.activeElement, + "expected action button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // 2. Focus the menu button, causing the action button to lose focus. + focused = BrowserTestUtils.waitForEvent(menuButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}, win); + await focused; + is( + menuButton, + win.document.activeElement, + "expected menu button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when focusing the menu button" + ); + + await closeExtensionsPanel(win); + + info("verify message when hovering the action button"); + await openExtensionsPanel(win); + + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + // 1. Hover the action button of the first extension in the panel. + let hovered = BrowserTestUtils.waitForEvent( + actionButton, + "mouseover" + ); + EventUtils.synthesizeMouseAtCenter( + actionButton, + { type: "mouseover" }, + win + ); + await hovered; + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // 2. Hover the menu button, causing the action button to no longer + // be hovered. + hovered = BrowserTestUtils.waitForEvent(menuButton, "mouseover"); + EventUtils.synthesizeMouseAtCenter( + menuButton, + { type: "mouseover" }, + win + ); + await hovered; + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + await closeExtensionsPanel(win); + } + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests that if we pin a browser action button listed in the addons panel + * to the toolbar when that button would immediately overflow, that the + * button is put into the addons panel overflow list. + */ +add_task(async function test_pinning_to_toolbar_when_overflowed() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + let movedNode; + let extensionWidgetID; + let actionButton; + let menuButton; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + // Before we overflow the toolbar, let's move the last item to the addons + // panel. + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + + actionButton = movedNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the navbar" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the navbar" + ); + + menuButton = movedNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the navbar" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the navbar" + ); + + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_ADDONS + ); + + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + }, + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + // Now that the window is overflowed, let's move the widget in the addons + // panel back to the navbar. This should cause the widget to overflow back + // into the addons panel. + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_NAVBAR + ); + await TestUtils.waitForCondition(() => { + return movedNode.hasAttribute("overflowedItem"); + }); + Assert.equal( + movedNode.parentElement, + unifiedExtensionList, + "Should have overflowed the extension button to the right list." + ); + + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * This test verifies that, when an extension placed in the toolbar is + * overflowed into the addons panel and context-clicked, it shows the "Pin to + * Toolbar" item as checked, and that unchecking this menu item inserts the + * extension into the dedicated addons area of the panel, and that the item + * then does not underflow. + */ +add_task(async function test_unpin_overflowed_widget() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let extensionID; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected an extension widget"); + extensionID = firstExtensionWidget.dataset.extensionid; + + let movedNode = CustomizableUI.getWidget( + firstExtensionWidget.id + ).forWindow(win).node; + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "toolbar", + "expected extension widget to be in the toolbar" + ); + Assert.ok( + movedNode.hasAttribute("overflowedItem"), + "expected extension widget to be overflowed" + ); + let actionButton = movedNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + let menuButton = movedNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + // Open the panel, then the context menu of the extension widget, verify + // the 'Pin to Toolbar' menu item, then click on this menu item to + // uncheck it (i.e. unpin the extension). + await openExtensionsPanel(win); + const contextMenu = await openUnifiedExtensionsContextMenu( + extensionID, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + + const pinToToolbar = contextMenu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + Assert.ok(pinToToolbar, "expected a 'Pin to Toolbar' menu item"); + Assert.ok( + !pinToToolbar.hidden, + "expected 'Pin to Toolbar' to be visible" + ); + Assert.equal( + pinToToolbar.getAttribute("checked"), + "true", + "expected 'Pin to Toolbar' to be checked" + ); + + // Uncheck "Pin to Toolbar" menu item. Clicking a menu item in the + // context menu closes the unified extensions panel automatically. + const hidden = BrowserTestUtils.waitForEvent( + win.gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(pinToToolbar); + await hidden; + + // We expect the widget to no longer be overflowed. + await TestUtils.waitForCondition(() => { + return !movedNode.hasAttribute("overflowedItem"); + }); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_ADDONS, + "expected extension widget to have been unpinned and placed in the addons area" + ); + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "panel", + "expected extension widget to be in the unified extensions panel" + ); + }, + afterUnderflowed: async () => { + await openExtensionsPanel(win); + + const item = getUnifiedExtensionsItem(extensionID, win); + Assert.ok( + item, + "expected extension widget to be listed in the unified extensions panel" + ); + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + let menuButton = item.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overflow_with_a_second_window() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + // Open a second window that will stay maximized. We want to be sure that + // overflowing a widget in one window isn't going to affect the other window + // since we have an instance (of a CUI widget) per window. + let secondWin = await BrowserTestUtils.openNewBrowserWindow(); + await ensureMaximizedWindow(secondWin); + await BrowserTestUtils.openNewForegroundTab( + secondWin.gBrowser, + "https://example.com/" + ); + + // Make sure the first window is the active window. + let windowActivePromise = new Promise(resolve => { + if (Services.focus.activeWindow == win) { + resolve(); + } else { + win.addEventListener( + "activate", + () => { + resolve(); + }, + { once: true } + ); + } + }); + win.focus(); + await windowActivePromise; + + let extensionWidgetID; + let aNode; + let aNodeInSecondWindow; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + // This is the DOM node for the current window that is overflowed. + aNode = CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + Assert.ok( + !aNode.hasAttribute("overflowedItem"), + "expected extension widget to NOT be overflowed" + ); + + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button" + ); + + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button" + ); + + // This is the DOM node of the same CUI widget but in the maximized + // window opened before. + aNodeInSecondWindow = + CustomizableUI.getWidget(extensionWidgetID).forWindow(secondWin).node; + + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + // The DOM node should have been overflowed. + Assert.ok( + aNode.hasAttribute("overflowedItem"), + "expected extension widget to be overflowed" + ); + Assert.equal( + aNode.getAttribute("widget-id"), + extensionWidgetID, + "expected the CUI widget ID to be set on the DOM node" + ); + + // When the node is overflowed, we swap the CSS class on the action + // and menu buttons since the node is now placed in the extensions panel. + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button" + ); + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button" + ); + + // The DOM node in the other window should not have been overflowed. + Assert.ok( + !aNodeInSecondWindow.hasAttribute("overflowedItem"), + "expected extension widget to NOT be overflowed in the other window" + ); + Assert.equal( + aNodeInSecondWindow.getAttribute("widget-id"), + extensionWidgetID, + "expected the CUI widget ID to be set on the DOM node" + ); + + // We expect no CSS class changes for the node in the other window. + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + afterUnderflowed: async () => { + // After underflow, we expect the CSS class on the action and menu + // buttons of the DOM node of the current window to be updated. + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the panel" + ); + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the panel" + ); + + // The DOM node of the other window should not be changed. + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(secondWin); +}); + +add_task(async function test_overflow_with_extension_in_collapsed_area() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const bookmarksToolbar = win.document.getElementById( + CustomizableUI.AREA_BOOKMARKS + ); + + let movedNode; + let extensionWidgetID; + let extensionWidgetPosition; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + // Before we overflow the toolbar, let's move the last item to the + // (visible) bookmarks toolbar. + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + + // Ensure that the toolbar is currently visible. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + // Move an extension to the bookmarks toolbar. + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_BOOKMARKS + ); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_BOOKMARKS, + "expected extension widget to be in the bookmarks toolbar" + ); + Assert.ok( + !movedNode.hasAttribute("artificallyOverflowed"), + "expected node to not have any artificallyOverflowed prop" + ); + + extensionWidgetPosition = + CustomizableUI.getPlacementOfWidget(extensionWidgetID).position; + + // At this point we have an extension in the bookmarks toolbar, and this + // toolbar is visible. We are going to resize the window (width) AND + // collapse the toolbar to verify that the extension placed in the + // bookmarks toolbar is overflowed in the panel without any side effects. + }, + whenOverflowed: async () => { + // Ensure that the toolbar is currently collapsed. + await promiseSetToolbarVisibility(bookmarksToolbar, false); + + Assert.equal( + movedNode.parentElement.id, + OVERFLOWED_EXTENSIONS_LIST_ID, + "expected extension widget to be in the extensions panel" + ); + Assert.ok( + movedNode.getAttribute("artificallyOverflowed"), + "expected node to be artifically overflowed" + ); + + // At this point the extension is in the panel because it was overflowed + // after the bookmarks toolbar has been collapsed. The window is also + // narrow, but we are going to restore the initial window size. Since the + // visibility of the bookmarks toolbar hasn't changed, the extension + // should still be in the panel. + }, + afterUnderflowed: async () => { + Assert.equal( + movedNode.parentElement.id, + OVERFLOWED_EXTENSIONS_LIST_ID, + "expected extension widget to still be in the extensions panel" + ); + Assert.ok( + movedNode.getAttribute("artificallyOverflowed"), + "expected node to still be artifically overflowed" + ); + + // Ensure that the toolbar is visible again, which should move the + // extension back to where it was initially. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_BOOKMARKS, + "expected extension widget to be in the bookmarks toolbar" + ); + Assert.ok( + !movedNode.hasAttribute("artificallyOverflowed"), + "expected node to not have any artificallyOverflowed prop" + ); + Assert.equal( + CustomizableUI.getPlacementOfWidget(extensionWidgetID).position, + extensionWidgetPosition, + "expected the extension to be back at the same position in the bookmarks toolbar" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overflowed_extension_cannot_be_moved() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let extensionID; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + const secondExtensionWidget = unifiedExtensionList.children[1]; + Assert.ok(secondExtensionWidget, "expected an extension widget"); + extensionID = secondExtensionWidget.dataset.extensionid; + + await openExtensionsPanel(win); + const contextMenu = await openUnifiedExtensionsContextMenu( + extensionID, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + + const moveUp = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-up" + ); + Assert.ok(moveUp, "expected 'move up' item in the context menu"); + Assert.ok(moveUp.hidden, "expected 'move up' item to be hidden"); + + const moveDown = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-down" + ); + Assert.ok(moveDown, "expected 'move down' item in the context menu"); + Assert.ok(moveDown.hidden, "expected 'move down' item to be hidden"); + + await closeChromeContextMenu(contextMenu.id, null, win); + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); -- cgit v1.2.3