/* 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);
});
