Bug 1608626 - Fix browser.menus.overrideContext and add tests. r=mkmelin a=jorgk
authorGeoff Lankow <geoff@darktrojan.net>
Fri, 14 Feb 2020 14:25:37 +1300
changeset 38188 db119ad339d16caedefb445ae11a5ffb5714de24
parent 38187 a3c12a0d6d10cd3e7b74ae982cb3048d3448eec1
child 38189 fdc806bd568ced6bb94bc8324a27cecaadaf2c94
push id398
push userclokep@gmail.com
push dateMon, 09 Mar 2020 19:10:28 +0000
reviewersmkmelin, jorgk
bugs1608626
Bug 1608626 - Fix browser.menus.overrideContext and add tests. r=mkmelin a=jorgk
mail/base/content/nsContextMenu.js
mail/base/content/specialTabs.js
mail/components/extensions/parent/.eslintrc.js
mail/components/extensions/parent/ext-mail.js
mail/components/extensions/parent/ext-menus.js
mail/components/extensions/parent/ext-tabs.js
mail/components/extensions/test/browser/browser.ini
mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js
mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js
mail/components/extensions/test/browser/head.js
--- a/mail/base/content/nsContextMenu.js
+++ b/mail/base/content/nsContextMenu.js
@@ -101,39 +101,46 @@ nsContextMenu.prototype = {
       this.hasPageMenu = PageMenuParent.addToPopup(menuObject, null, aPopup);
 
       let subject = {
         menu: aPopup,
         tab: document.getElementById("tabmail")
           ? document.getElementById("tabmail").currentTabInfo
           : undefined,
         isContentSelected: this.isContentSelected,
-        inFrame: this.inFrame,
         isTextSelected: this.isTextSelected,
         onTextInput: this.onTextInput,
         onLink: this.onLink,
         onImage: this.onImage,
         onVideo: this.onVideo,
         onAudio: this.onAudio,
         onCanvas: this.onCanvas,
         onEditable: this.onEditable,
         srcUrl: this.mediaURL,
-        pageUrl: this.browser ? this.browser.currentURI.spec : undefined,
         linkText: this.onLink ? this.linkText() : undefined,
         linkUrl: this.linkURL,
         selectionText: this.isTextSelected
           ? this.selectionInfo.fullText
           : undefined,
       };
+      if (this.target) {
+        subject.inFrame =
+          this.target.ownerGlobal != this.target.ownerGlobal.top;
+        subject.frameUrl = this.target.ownerGlobal.location.href;
+        subject.pageUrl = this.target.ownerGlobal.top.location.href;
+        subject.principal = this.target.ownerDocument.nodePrincipal;
+      }
       if (target.closest("tree") == gFolderDisplay.tree) {
         subject.displayedFolder = gFolderDisplay.view.displayedFolder;
         subject.selectedMessages = gFolderDisplay.selectedMessages;
       }
+      subject.context = subject;
       subject.wrappedJSObject = subject;
 
+      Services.obs.notifyObservers(subject, "on-prepare-contextmenu");
       Services.obs.notifyObservers(subject, "on-build-contextmenu");
     }
 
     this.initItems();
 
     // If all items in the menu are hidden, set this.shouldDisplay to false
     // so that the callers know to not even display the empty menu.
     let contextPopup = document.getElementById("mailContext");
--- a/mail/base/content/specialTabs.js
+++ b/mail/base/content/specialTabs.js
@@ -859,17 +859,17 @@ var specialTabs = {
       if (aArgs.skipLoad) {
         clone.querySelector("browser").setAttribute("nodefaultsrc", "true");
       }
       aTab.panel.setAttribute("id", "contentTabWrapper" + this.lastBrowserId);
       aTab.panel.appendChild(clone);
       aTab.root = clone;
 
       // Start setting up the browser.
-      aTab.browser = aTab.panel.querySelector("browser");
+      aTab.linkedBrowser = aTab.browser = aTab.panel.querySelector("browser");
       aTab.toolbar = aTab.panel.querySelector(".contentTabToolbar");
       aTab.backButton = aTab.toolbar.querySelector(".back-btn");
       aTab.backButton.addEventListener("command", () => aTab.browser.goBack());
       aTab.forwardButton = aTab.toolbar.querySelector(".forward-btn");
       aTab.forwardButton.addEventListener("command", () =>
         aTab.browser.goForward()
       );
       aTab.security = aTab.toolbar.querySelector(".contentTabSecurity");
--- a/mail/components/extensions/parent/.eslintrc.js
+++ b/mail/components/extensions/parent/.eslintrc.js
@@ -33,16 +33,17 @@ module.exports = {
     isValidCookieStoreId: true,
 
     // These are defined in ext-mail.js.
     ExtensionError: true,
     Tab: true,
     TabmailTab: true,
     Window: true,
     TabmailWindow: true,
+    clickModifiersFromEvent: true,
     convertFolder: true,
     convertMessage: true,
     folderPathToURI: true,
     folderURIToPath: true,
     getTabBrowser: true,
     makeWidgetId: true,
     messageListTracker: true,
     messageTracker: true,
--- a/mail/components/extensions/parent/ext-mail.js
+++ b/mail/components/extensions/parent/ext-mail.js
@@ -17,16 +17,17 @@ XPCOMUtils.defineLazyServiceGetter(
   "nsIUUIDGenerator"
 );
 
 var { ExtensionError, getInnerWindowID } = ExtensionUtils;
 
 var { defineLazyGetter } = ExtensionCommon;
 
 XPCOMUtils.defineLazyModuleGetters(this, {
+  AppConstants: "resource://gre/modules/AppConstants.jsm",
   ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.jsm",
   ExtensionProcessScript: "resource://gre/modules/ExtensionProcessScript.jsm",
   ExtensionContent: "resource://gre/modules/ExtensionContent.jsm",
   Schemas: "resource://gre/modules/Schemas.jsm",
 });
 
 // Inject the |messenger| object as an alias to |browser| in all known contexts. This is a bit
 // fragile since it uses monkeypatching. If a test fails, the best way to debug is to search for
@@ -105,16 +106,34 @@ const getSender = (extension, target, se
       sender.tab = tab.convert();
     }
   }
 };
 
 // Used by Extension.jsm.
 global.tabGetSender = getSender;
 
+global.clickModifiersFromEvent = event => {
+  const map = {
+    shiftKey: "Shift",
+    altKey: "Alt",
+    metaKey: "Command",
+    ctrlKey: "Ctrl",
+  };
+  let modifiers = Object.keys(map)
+    .filter(key => event[key])
+    .map(key => map[key]);
+
+  if (event.ctrlKey && AppConstants.platform === "macosx") {
+    modifiers.push("MacCtrl");
+  }
+
+  return modifiers;
+};
+
 global.makeWidgetId = id => {
   id = id.toLowerCase();
   // FIXME: This allows for collisions.
   return id.replace(/[^a-z0-9_-]/g, "_");
 };
 
 /**
  * Gets the tab browser for the tabmail tabInfo.
@@ -168,28 +187,32 @@ class WindowTracker extends WindowTracke
   /**
    * Adds a tab progress listener to the given mail window.
    *
    * @param {DOMWindow} window      The mail window to which to add the listener.
    * @param {Object} listener       The listener to add
    */
   addProgressListener(window, listener) {
     let tabmail = window.document.getElementById("tabmail");
-    tabmail.addTabsProgressListener(listener);
+    if (tabmail) {
+      tabmail.addTabsProgressListener(listener);
+    }
   }
 
   /**
    * Removes a tab progress listener from the given mail window.
    *
    * @param {DOMWindow} window      The mail window from which to remove the listener.
    * @param {Object} listener       The listener to remove
    */
   removeProgressListener(window, listener) {
     let tabmail = window.document.getElementById("tabmail");
-    tabmail.removeTabsProgressListener(listener);
+    if (tabmail) {
+      tabmail.removeTabsProgressListener(listener);
+    }
   }
 
   /**
    * Determines if the passed window object is a mail window. The function name is for base class
    * compatibility with gecko.
    *
    * @param {DOMWindow} window      The window to check
    * @return {Boolean}              True, if the window is a mail window
--- a/mail/components/extensions/parent/ext-menus.js
+++ b/mail/components/extensions/parent/ext-menus.js
@@ -24,18 +24,20 @@ var gMenuMap = new Map();
 
 // Map[Extension -> MenuItem]
 var gRootItems = new Map();
 
 // Map[Extension -> ID[]]
 // Menu IDs that were eligible for being shown in the current menu.
 var gShownMenuItems = new DefaultMap(() => []);
 
-// Set of extensions that are listening to onShown.
-var gOnShownSubscribers = new Set();
+// Map[Extension -> Set[Contexts]]
+// A DefaultMap (keyed by extension) which keeps track of the
+// contexts with a subscribed onShown event listener.
+var gOnShownSubscribers = new DefaultMap(() => new Set());
 
 // If id is not specified for an item we use an integer.
 var gNextMenuItemID = 0;
 
 // Used to assign unique names to radio groups.
 var gNextRadioGroupID = 0;
 
 // The max length of a menu item's label.
@@ -90,32 +92,32 @@ var gMenuBuilder = {
         onTab: true,
       };
     }
     throw new Error(
       `Unexpected overrideContext: ${webExtContextData.overrideContext}`
     );
   },
 
-  createAndInsertTopLevelElements(root, contextData, nextElementSibling) {
+  createAndInsertTopLevelElements(root, contextData, nextSibling) {
     let rootElements;
     if (contextData.onBrowserAction || contextData.onPageAction) {
       if (contextData.extension.id !== root.extension.id) {
         return;
       }
       rootElements = this.buildTopLevelElements(
         root,
         contextData,
         ACTION_MENU_TOP_LEVEL_LIMIT,
         false
       );
 
       // Action menu items are prepended to the menu, followed by a separator.
-      nextElementSibling = nextElementSibling || this.xulMenu.firstElementChild;
-      if (rootElements.length && !this.itemsToCleanUp.has(nextElementSibling)) {
+      nextSibling = nextSibling || this.xulMenu.firstElementChild;
+      if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
         rootElements.push(
           this.xulMenu.ownerDocument.createXULElement("menuseparator")
         );
       }
     } else if (contextData.webExtContextData) {
       let {
         extensionId,
         showDefaults,
@@ -124,23 +126,22 @@ var gMenuBuilder = {
       if (extensionId === root.extension.id) {
         rootElements = this.buildTopLevelElements(
           root,
           contextData,
           Infinity,
           false
         );
         // The extension menu should be rendered at the top, but after the navigation buttons.
-        nextElementSibling =
-          nextElementSibling ||
-          this.xulMenu.querySelector(":scope > #context-sep-navigation + *");
+        nextSibling =
+          nextSibling || this.xulMenu.querySelector(":scope > :first-child");
         if (
           rootElements.length &&
           showDefaults &&
-          !this.itemsToCleanUp.has(nextElementSibling)
+          !this.itemsToCleanUp.has(nextSibling)
         ) {
           rootElements.push(
             this.xulMenu.ownerDocument.createXULElement("menuseparator")
           );
         }
       } else if (!showDefaults && !overrideContext) {
         // When the default menu items should be hidden, menu items from other
         // extensions should be hidden too.
@@ -161,18 +162,18 @@ var gMenuBuilder = {
         );
       }
     }
 
     if (!rootElements.length) {
       return;
     }
 
-    if (nextElementSibling) {
-      nextElementSibling.before(...rootElements);
+    if (nextSibling) {
+      nextSibling.before(...rootElements);
     } else {
       this.xulMenu.append(...rootElements);
     }
     for (let item of rootElements) {
       this.itemsToCleanUp.add(item);
     }
   },
 
@@ -268,17 +269,17 @@ var gMenuBuilder = {
         }
       }
     }
   },
 
   buildSingleElement(item, contextData) {
     let doc = contextData.menu.ownerDocument;
     let element;
-    if (item.children.length > 0) {
+    if (item.children.length) {
       element = this.createMenuElement(doc, item);
     } else if (item.type == "separator") {
       element = doc.createXULElement("menuseparator");
     } else {
       element = doc.createXULElement("menuitem");
     }
 
     return this.customizeElement(element, item, contextData);
@@ -401,29 +402,17 @@ var gMenuBuilder = {
           // activeTab since the extension also controls the tabId.
           (!webExtContextData ||
             webExtContextData.extensionId !== item.extension.id)
         ) {
           item.tabManager.addActiveTabPermission(contextData.tab);
         }
 
         let info = item.getClickInfo(contextData, wasChecked);
-
-        const map = {
-          shiftKey: "Shift",
-          altKey: "Alt",
-          metaKey: "Command",
-          ctrlKey: "Ctrl",
-        };
-        info.modifiers = Object.keys(map)
-          .filter(key => event[key])
-          .map(key => map[key]);
-        if (event.ctrlKey && AppConstants.platform === "macosx") {
-          info.modifiers.push("MacCtrl");
-        }
+        info.modifiers = clickModifiersFromEvent(event);
 
         info.button = button;
 
         // Allow menus to open various actions supported in webext prior
         // to notifying onclicked.
         let actionFor = {
           _execute_browser_action: global.browserActionFor,
         }[item.command];
@@ -436,18 +425,18 @@ var gMenuBuilder = {
           "webext-menu-menuitem-click",
           info,
           contextData.tab
         );
       },
       { once: true }
     );
 
+    // eslint-disable-next-line mozilla/balanced-listeners
     element.addEventListener("click", event => {
-      // eslint-disable-line mozilla/balanced-listeners
       if (
         event.target !== event.currentTarget ||
         // Ignore menu items that are usually not clickeable,
         // such as separators and parents of submenus and disabled items.
         element.localName !== "menuitem" ||
         element.disabled
       ) {
         return;
@@ -503,32 +492,28 @@ var gMenuBuilder = {
     if (!contextData) {
       // This happens if the menu is not visible.
       return;
     }
 
     // Find the group of existing top-level items (usually 0 or 1 items)
     // and remember its position for when the new items are inserted.
     let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
-    let nextElementSibling = null;
+    let nextSibling = null;
     for (let item of this.itemsToCleanUp) {
       if (item.id && item.id.startsWith(elementIdPrefix)) {
-        nextElementSibling = item.nextElementSibling;
+        nextSibling = item.nextSibling;
         item.remove();
         this.itemsToCleanUp.delete(item);
       }
     }
 
     let root = gRootItems.get(extension);
     if (root) {
-      this.createAndInsertTopLevelElements(
-        root,
-        contextData,
-        nextElementSibling
-      );
+      this.createAndInsertTopLevelElements(root, contextData, nextSibling);
     }
     this.removeSeparatorIfNoTopLevelItems();
   },
 
   // This should be called once, after constructing the top-level menus, if any.
   afterBuildingMenu(contextData) {
     function dispatchOnShownEvent(extension) {
       // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
@@ -537,17 +522,19 @@ var gMenuBuilder = {
       // when the menu is closed.
       let menuIds = gShownMenuItems.get(extension);
       extension.emit("webext-menu-shown", menuIds, contextData);
     }
 
     if (contextData.onBrowserAction || contextData.onPageAction) {
       dispatchOnShownEvent(contextData.extension);
     } else {
-      gOnShownSubscribers.forEach(dispatchOnShownEvent);
+      for (const extension of gOnShownSubscribers.keys()) {
+        dispatchOnShownEvent(extension);
+      }
     }
 
     this.contextData = contextData;
   },
 
   hideDefaultMenuItems() {
     for (let item of this.xulMenu.children) {
       if (!this.itemsToCleanUp.has(item)) {
@@ -629,17 +616,17 @@ function getContextViewType(contextData)
     return contextData.originalViewType;
   }
   if (
     contextData.webExtBrowserType === "popup" ||
     contextData.webExtBrowserType === "sidebar"
   ) {
     return contextData.webExtBrowserType;
   }
-  if (contextData.tab && contextData.menu.id === "tabContextMenu") {
+  if (contextData.tab && contextData.menu.id === "mailContext") {
     return "tab";
   }
   return undefined;
 }
 
 function addMenuEventInfo(info, contextData, extension, includeSensitiveData) {
   info.viewType = getContextViewType(contextData);
   if (contextData.onVideo) {
@@ -660,16 +647,17 @@ function addMenuEventInfo(info, contextD
     }
     if (contextData.onLink) {
       info.linkText = contextData.linkText;
       info.linkUrl = contextData.linkUrl;
     }
     if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
       info.srcUrl = contextData.srcUrl;
     }
+    info.pageUrl = contextData.pageUrl;
     if (contextData.inFrame) {
       info.frameUrl = contextData.frameUrl;
     }
     if (contextData.isTextSelected) {
       info.selectionText = contextData.selectionText;
     }
   }
   // If the context was overridden, then frameUrl should be the URL of the
@@ -962,22 +950,17 @@ const menuTracker = {
       this.onWindowOpen(window);
     }
     windowTracker.addOpenListener(this.onWindowOpen);
   },
 
   unregister() {
     Services.obs.removeObserver(this, "on-build-contextmenu");
     for (const window of windowTracker.browserWindows()) {
-      for (const id of this.menuIds) {
-        const menu = window.document.getElementById(id);
-        if (menu) {
-          menu.removeEventListener("popupshowing", this);
-        }
-      }
+      this.cleanupWindow(window);
     }
     windowTracker.removeOpenListener(this.onWindowOpen);
   },
 
   observe(subject, topic, data) {
     subject = subject.wrappedJSObject;
     gMenuBuilder.build(subject);
   },
@@ -986,16 +969,25 @@ const menuTracker = {
     for (const id of menuTracker.menuIds) {
       const menu = window.document.getElementById(id);
       if (menu) {
         menu.addEventListener("popupshowing", menuTracker);
       }
     }
   },
 
+  cleanupWindow(window) {
+    for (const id of this.menuIds) {
+      const menu = window.document.getElementById(id);
+      if (menu) {
+        menu.removeEventListener("popupshowing", this);
+      }
+    }
+  },
+
   handleEvent(event) {
     const menu = event.target;
     if (menu.id === "tabContextMenu") {
       let trigger = menu.triggerNode;
       while (trigger && trigger.localName != "tab") {
         trigger = trigger.parentNode;
       }
       const tab = trigger || tabTracker.activeTab;
@@ -1021,17 +1013,17 @@ this.menus = class extends ExtensionAPI 
     super(extension);
 
     if (!gMenuMap.size) {
       menuTracker.register();
     }
     gMenuMap.set(extension, new Map());
   }
 
-  onShutdown(reason) {
+  onShutdown() {
     let { extension } = this;
 
     if (gMenuMap.has(extension)) {
       gMenuMap.delete(extension);
       gRootItems.delete(extension);
       gShownMenuItems.delete(extension);
       gOnShownSubscribers.delete(extension);
       if (!gMenuMap.size) {
@@ -1077,20 +1069,24 @@ this.menus = class extends ExtensionAPI 
                 contextData,
                 extension,
                 includeSensitiveData
               );
 
               let tab = nativeTab && extension.tabManager.convert(nativeTab);
               fire.sync(info, tab);
             };
-            gOnShownSubscribers.add(extension);
+            gOnShownSubscribers.get(extension).add(context);
             extension.on("webext-menu-shown", listener);
             return () => {
-              gOnShownSubscribers.delete(extension);
+              const contexts = gOnShownSubscribers.get(extension);
+              contexts.delete(context);
+              if (contexts.size === 0) {
+                gOnShownSubscribers.delete(extension);
+              }
               extension.off("webext-menu-shown", listener);
             };
           },
         }).api(),
         onHidden: new EventManager({
           context,
           name: "menus.onHidden",
           register: fire => {
--- a/mail/components/extensions/parent/ext-tabs.js
+++ b/mail/components/extensions/parent/ext-tabs.js
@@ -33,17 +33,17 @@ let tabListener = {
    *
    * @param {Element} browser               The browser element that caused the change
    * @param {nsIWebProgress} webProgress    The web progress for the location change
    * @param {nsIRequest} request            The xpcom request for this change
    * @param {nsIURI} locationURI            The target uri
    * @param {Integer} flags                 The web progress flags for this change
    */
   onLocationChange(browser, webProgress, request, locationURI, flags) {
-    if (webProgress.isTopLevel) {
+    if (webProgress && webProgress.isTopLevel) {
       let tabmail = browser.ownerDocument.getElementById("tabmail");
       let nativeTabInfo = tabmail.getTabForBrowser(browser);
 
       // Now we are certain that the first page in the tab was loaded.
       this.initializingTabs.delete(nativeTabInfo);
 
       // browser.innerWindowID is now set, resolve the promises if any.
       let deferred = this.tabReadyPromises.get(nativeTabInfo);
--- a/mail/components/extensions/test/browser/browser.ini
+++ b/mail/components/extensions/test/browser/browser.ini
@@ -22,13 +22,15 @@ tags = webextensions
 [browser_ext_commands_update.js]
 [browser_ext_compose_begin.js]
 [browser_ext_compose_details.js]
 [browser_ext_compose_onBeforeSend.js]
 [browser_ext_composeAction.js]
 [browser_ext_mailTabs.js]
 [browser_ext_menus.js]
 support-files = data/content.html
+[browser_ext_menus_replace_menu.js]
+[browser_ext_menus_replace_menu_context.js]
 [browser_ext_messageDisplay.js]
 [browser_ext_messageDisplayAction.js]
 [browser_ext_quickFilter.js]
 [browser_ext_windows.js]
 [browser_ext_windows_types.js]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js
@@ -0,0 +1,276 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+function getVisibleChildrenIds(menuElem) {
+  return Array.from(menuElem.children)
+    .filter(elem => !elem.hidden)
+    .map(elem => elem.id || elem.tagName);
+}
+
+function checkIsDefaultMenuItemVisible(visibleMenuItemIds) {
+  // In this whole test file, we open a menu on a link. Assume that all
+  // default menu items are shown if one link-specific menu item is shown.
+  ok(
+    visibleMenuItemIds.includes("mailContext-copylink"),
+    `The default 'Copy Link Location' menu item should be in ${visibleMenuItemIds}.`
+  );
+}
+
+// Tests the following:
+// - Calling overrideContext({}) during oncontextmenu forces the menu to only
+//   show an extension's own items.
+// - These menu items all appear in the root menu.
+// - The usual extension filtering behavior (e.g. documentUrlPatterns and
+//   targetUrlPatterns) is still applied; some menu items are therefore hidden.
+// - Calling overrideContext({showDefaults:true}) causes the default menu items
+//   to be shown, but only after the extension's.
+// - overrideContext expires after the menu is opened once.
+// - overrideContext can be called from shadow DOM.
+add_task(async function overrideContext_in_extension_tab() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["security.allow_eval_with_system_principal", true]],
+  });
+
+  function extensionTabScript() {
+    document.addEventListener(
+      "contextmenu",
+      () => {
+        browser.menus.overrideContext({});
+        browser.test.sendMessage("oncontextmenu_in_dom_part_1");
+      },
+      { once: true }
+    );
+
+    let shadowRoot = document
+      .getElementById("shadowHost")
+      .attachShadow({ mode: "open" });
+    shadowRoot.innerHTML = `<a href="http://example.com/">Link</a>`;
+    shadowRoot.firstChild.addEventListener(
+      "contextmenu",
+      () => {
+        browser.menus.overrideContext({});
+        browser.test.sendMessage("oncontextmenu_in_shadow_dom");
+      },
+      { once: true }
+    );
+
+    browser.menus.create({
+      id: "tab_1",
+      title: "tab_1",
+      documentUrlPatterns: [document.URL],
+      onclick() {
+        document.addEventListener(
+          "contextmenu",
+          () => {
+            // Verifies that last call takes precedence.
+            browser.menus.overrideContext({ showDefaults: false });
+            browser.menus.overrideContext({ showDefaults: true });
+            browser.test.sendMessage("oncontextmenu_in_dom_part_2");
+          },
+          { once: true }
+        );
+        browser.test.sendMessage("onClicked_tab_1");
+      },
+    });
+    browser.menus.create(
+      {
+        id: "tab_2",
+        title: "tab_2",
+        onclick() {
+          browser.test.sendMessage("onClicked_tab_2");
+        },
+      },
+      () => {
+        browser.test.sendMessage("menu-registered");
+      }
+    );
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["menus", "menus.overrideContext"],
+    },
+    files: {
+      "tab.html": `
+        <!DOCTYPE html><meta charset="utf-8">
+        <a href="http://example.com/">Link</a>
+        <div id="shadowHost"></div>
+        <script src="tab.js"></script>
+      `,
+      "tab.js": extensionTabScript,
+    },
+    background() {
+      // Expected to match and thus be visible.
+      browser.menus.create({ id: "bg_1", title: "bg_1" });
+      browser.menus.create({
+        id: "bg_2",
+        title: "bg_2",
+        targetUrlPatterns: ["*://example.com/*"],
+      });
+
+      // Expected to not match and be hidden.
+      browser.menus.create({
+        id: "bg_3",
+        title: "bg_3",
+        targetUrlPatterns: ["*://nomatch/*"],
+      });
+      browser.menus.create({
+        id: "bg_4",
+        title: "bg_4",
+        documentUrlPatterns: [document.URL],
+      });
+
+      browser.menus.onShown.addListener(info => {
+        browser.test.assertEq("tab", info.viewType, "Expected viewType");
+        browser.test.assertEq(
+          "bg_1,bg_2,tab_1,tab_2",
+          info.menuIds.join(","),
+          "Expected menu items."
+        );
+        browser.test.assertEq(
+          "all,link",
+          info.contexts.sort().join(","),
+          "Expected menu contexts"
+        );
+        browser.test.sendMessage("onShown");
+      });
+
+      browser.tabs.create({ url: "tab.html" });
+    },
+  });
+
+  let otherExtension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["menus"],
+    },
+    background() {
+      browser.menus.create(
+        { id: "other_extension_item", title: "other_extension_item" },
+        () => {
+          browser.test.sendMessage("other_extension_item_created");
+        }
+      );
+    },
+  });
+  await otherExtension.startup();
+  await otherExtension.awaitMessage("other_extension_item_created");
+
+  await extension.startup();
+  await extension.awaitMessage("menu-registered");
+
+  const EXPECTED_EXTENSION_MENU_IDS = [
+    `${makeWidgetId(extension.id)}-menuitem-_bg_1`,
+    `${makeWidgetId(extension.id)}-menuitem-_bg_2`,
+    `${makeWidgetId(extension.id)}-menuitem-_tab_1`,
+    `${makeWidgetId(extension.id)}-menuitem-_tab_2`,
+  ];
+  const OTHER_EXTENSION_MENU_ID = `${makeWidgetId(
+    otherExtension.id
+  )}-menuitem-_other_extension_item`;
+
+  {
+    // Tests overrideContext({})
+    info("Expecting the menu to be replaced by overrideContext.");
+    let menu = await openContextMenu("a");
+    await extension.awaitMessage("oncontextmenu_in_dom_part_1");
+    await extension.awaitMessage("onShown");
+
+    Assert.deepEqual(
+      getVisibleChildrenIds(menu),
+      EXPECTED_EXTENSION_MENU_IDS,
+      "Expected only extension menu items"
+    );
+
+    let menuItems = menu.getElementsByAttribute("label", "tab_1");
+    await closeExtensionContextMenu(menuItems[0]);
+    await extension.awaitMessage("onClicked_tab_1");
+  }
+
+  {
+    // Tests overrideContext({showDefaults:true}))
+    info(
+      "Expecting the menu to be replaced by overrideContext, including default menu items."
+    );
+    let menu = await openContextMenu("a");
+    await extension.awaitMessage("oncontextmenu_in_dom_part_2");
+    await extension.awaitMessage("onShown");
+
+    let visibleMenuItemIds = getVisibleChildrenIds(menu);
+    Assert.deepEqual(
+      visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS.length),
+      EXPECTED_EXTENSION_MENU_IDS,
+      "Expected extension menu items at the start."
+    );
+
+    checkIsDefaultMenuItemVisible(visibleMenuItemIds);
+
+    is(
+      visibleMenuItemIds[visibleMenuItemIds.length - 1],
+      OTHER_EXTENSION_MENU_ID,
+      "Other extension menu item should be at the end."
+    );
+
+    let menuItems = menu.getElementsByAttribute("label", "tab_2");
+    await closeExtensionContextMenu(menuItems[0]);
+    await extension.awaitMessage("onClicked_tab_2");
+  }
+
+  {
+    // Tests that previous overrideContext call has been forgotten,
+    // so the default behavior should occur (=move items into submenu).
+    info(
+      "Expecting the default menu to be used when overrideContext is not called."
+    );
+    let menu = await openContextMenu("a");
+    await extension.awaitMessage("onShown");
+
+    checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+
+    let menuItems = menu.getElementsByAttribute("ext-type", "top-level-menu");
+    is(menuItems.length, 1, "Expected top-level menu element for extension.");
+    let topLevelExtensionMenuItem = menuItems[0];
+    is(
+      topLevelExtensionMenuItem.nextSibling,
+      null,
+      "Extension menu should be the last element."
+    );
+
+    const submenu = await openSubmenu(topLevelExtensionMenuItem);
+    is(submenu, topLevelExtensionMenuItem.menupopup, "Correct submenu opened");
+
+    Assert.deepEqual(
+      getVisibleChildrenIds(submenu),
+      EXPECTED_EXTENSION_MENU_IDS,
+      "Extension menu items should be in the submenu by default."
+    );
+
+    await closeContextMenu();
+  }
+
+  {
+    info("the other thing");
+    // Tests that overrideContext({}) can be used from a listener inside shadow DOM.
+    let menu = await openContextMenu(
+      () => this.document.getElementById("shadowHost").shadowRoot.firstChild
+    );
+    await extension.awaitMessage("oncontextmenu_in_shadow_dom");
+    await extension.awaitMessage("onShown");
+
+    Assert.deepEqual(
+      getVisibleChildrenIds(menu),
+      EXPECTED_EXTENSION_MENU_IDS,
+      "Expected only extension menu items after overrideContext({}) in shadow DOM"
+    );
+
+    await closeContextMenu();
+  }
+
+  // Unloading the extension will automatically close the extension's tab.html
+  await extension.unload();
+  await otherExtension.unload();
+
+  let tabmail = document.getElementById("tabmail");
+  tabmail.closeTab(tabmail.currentTabInfo);
+});
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js
@@ -0,0 +1,374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+function getVisibleChildrenIds(menuElem) {
+  return Array.from(menuElem.children)
+    .filter(elem => !elem.hidden)
+    .map(elem => elem.id || elem.tagName);
+}
+
+function checkIsDefaultMenuItemVisible(visibleMenuItemIds) {
+  // In this whole test file, we open a menu on a link. Assume that all
+  // default menu items are shown if one link-specific menu item is shown.
+  ok(
+    visibleMenuItemIds.includes("mailContext-copylink"),
+    `The default 'Copy Link Location' menu item should be in ${visibleMenuItemIds}.`
+  );
+}
+
+// Tests that the context of an extension menu can be changed to:
+// - tab
+add_task(async function overrideContext_with_context() {
+  // Background script of the main test extension and the auxilary other extension.
+  function background() {
+    const HTTP_URL = "http://example.com/?SomeTab";
+    browser.test.onMessage.addListener(async (msg, tabId) => {
+      browser.test.assertEq(
+        "testTabAccess",
+        msg,
+        `Expected message in ${browser.runtime.id}`
+      );
+      let tab = await browser.tabs.get(tabId);
+      if (!tab.url) {
+        // tabs or activeTab not active.
+        browser.test.sendMessage("testTabAccessDone", "tab_no_url");
+        return;
+      }
+      try {
+        let [url] = await browser.tabs.executeScript(tabId, {
+          code: "document.URL",
+        });
+        browser.test.assertEq(
+          HTTP_URL,
+          url,
+          "Expected successful executeScript"
+        );
+        browser.test.sendMessage("testTabAccessDone", "executeScript_ok");
+        return;
+      } catch (e) {
+        browser.test.assertEq(
+          "Missing host permission for the tab",
+          e.message,
+          "Expected error message"
+        );
+        browser.test.sendMessage("testTabAccessDone", "executeScript_failed");
+      }
+    });
+    browser.menus.onShown.addListener((info, tab) => {
+      browser.test.assertEq(
+        "tab",
+        info.viewType,
+        "Expected viewType at onShown"
+      );
+      browser.test.assertEq(
+        undefined,
+        info.linkUrl,
+        "Expected linkUrl at onShown"
+      );
+      browser.test.assertEq(
+        undefined,
+        info.srckUrl,
+        "Expected srcUrl at onShown"
+      );
+      browser.test.sendMessage("onShown", {
+        menuIds: info.menuIds.sort(),
+        contexts: info.contexts,
+        bookmarkId: info.bookmarkId,
+        pageUrl: info.pageUrl,
+        frameUrl: info.frameUrl,
+        tabId: tab && tab.id,
+      });
+    });
+    browser.menus.onClicked.addListener((info, tab) => {
+      browser.test.assertEq(
+        "tab",
+        info.viewType,
+        "Expected viewType at onClicked"
+      );
+      browser.test.assertEq(
+        undefined,
+        info.linkUrl,
+        "Expected linkUrl at onClicked"
+      );
+      browser.test.assertEq(
+        undefined,
+        info.srckUrl,
+        "Expected srcUrl at onClicked"
+      );
+      browser.test.sendMessage("onClicked", {
+        menuItemId: info.menuItemId,
+        bookmarkId: info.bookmarkId,
+        pageUrl: info.pageUrl,
+        frameUrl: info.frameUrl,
+        tabId: tab && tab.id,
+      });
+    });
+
+    // Minimal properties to define menu items for a specific context.
+    browser.menus.create({
+      id: "tab_context",
+      title: "tab_context",
+      contexts: ["tab"],
+    });
+
+    // documentUrlPatterns in the tab context applies to the tab's URL.
+    browser.menus.create({
+      id: "tab_context_http",
+      title: "tab_context_http",
+      contexts: ["tab"],
+      documentUrlPatterns: [HTTP_URL],
+    });
+    browser.menus.create({
+      id: "tab_context_moz_unexpected",
+      title: "tab_context_moz",
+      contexts: ["tab"],
+      documentUrlPatterns: ["moz-extension://*/tab.html"],
+    });
+    // When viewTypes is present, the document's URL is matched instead.
+    browser.menus.create({
+      id: "tab_context_viewType_http_unexpected",
+      title: "tab_context_viewType_http",
+      contexts: ["tab"],
+      viewTypes: ["tab"],
+      documentUrlPatterns: [HTTP_URL],
+    });
+    browser.menus.create({
+      id: "tab_context_viewType_moz",
+      title: "tab_context_viewType_moz",
+      contexts: ["tab"],
+      viewTypes: ["tab"],
+      documentUrlPatterns: ["moz-extension://*/tab.html"],
+    });
+
+    browser.menus.create({ id: "link_context", title: "link_context" }, () => {
+      browser.test.sendMessage("menu_items_registered");
+    });
+
+    if (browser.runtime.id === "@menu-test-extension") {
+      browser.tabs.create({ url: "tab.html" });
+    }
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: { gecko: { id: "@menu-test-extension" } },
+      permissions: ["menus", "menus.overrideContext", "tabs"],
+    },
+    files: {
+      "tab.html": `
+        <!DOCTYPE html><meta charset="utf-8">
+        <a href="http://example.com/">Link</a>
+        <script src="tab.js"></script>
+      `,
+      "tab.js": async () => {
+        let [tab] = await browser.tabs.query({
+          url: "http://example.com/?SomeTab",
+        });
+        let testCases = [
+          {
+            context: "tab",
+            tabId: tab.id,
+          },
+          {
+            context: "tab",
+            tabId: tab.id,
+          },
+          {
+            context: "tab",
+            tabId: 123456789, // Some invalid tabId.
+          },
+        ];
+
+        // eslint-disable-next-line mozilla/balanced-listeners
+        document.addEventListener("contextmenu", () => {
+          browser.menus.overrideContext(testCases.shift());
+          setTimeout(() => browser.test.sendMessage("oncontextmenu_in_dom"));
+        });
+
+        browser.test.sendMessage("setup_ready", {
+          tabId: tab.id,
+          httpUrl: tab.url,
+          extensionUrl: document.URL,
+        });
+      },
+    },
+    background,
+  });
+
+  window.openContentTab("http://example.com/?SomeTab");
+
+  let otherExtension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: { gecko: { id: "@other-test-extension" } },
+      permissions: ["menus", "activeTab"],
+    },
+    background,
+  });
+  await otherExtension.startup();
+  await otherExtension.awaitMessage("menu_items_registered");
+
+  await extension.startup();
+  await extension.awaitMessage("menu_items_registered");
+
+  let { tabId, httpUrl, extensionUrl } = await extension.awaitMessage(
+    "setup_ready"
+  );
+  info(`Set up test with tabId=${tabId}.`);
+
+  {
+    // Test case 1: context=tab
+    let menu = await openContextMenu("a");
+    await extension.awaitMessage("oncontextmenu_in_dom");
+    for (let ext of [extension, otherExtension]) {
+      info(`Testing menu from ${ext.id} after changing context to tab`);
+      Assert.deepEqual(
+        await ext.awaitMessage("onShown"),
+        {
+          menuIds: [
+            "tab_context",
+            "tab_context_http",
+            "tab_context_viewType_moz",
+          ],
+          contexts: ["tab"],
+          bookmarkId: undefined,
+          pageUrl: undefined, // because extension has no host permissions.
+          frameUrl: extensionUrl,
+          tabId,
+        },
+        "Expected onShown details after changing context to tab"
+      );
+    }
+    let topLevels = menu.getElementsByAttribute("ext-type", "top-level-menu");
+    is(topLevels.length, 1, "Expected top-level menu for otherExtension");
+
+    Assert.deepEqual(
+      getVisibleChildrenIds(menu),
+      [
+        `${makeWidgetId(extension.id)}-menuitem-_tab_context`,
+        `${makeWidgetId(extension.id)}-menuitem-_tab_context_http`,
+        `${makeWidgetId(extension.id)}-menuitem-_tab_context_viewType_moz`,
+        `menuseparator`,
+        topLevels[0].id,
+      ],
+      "Expected menu items after changing context to tab"
+    );
+
+    let submenu = await openSubmenu(topLevels[0]);
+    is(submenu, topLevels[0].menupopup, "Correct submenu opened");
+
+    Assert.deepEqual(
+      getVisibleChildrenIds(submenu),
+      [
+        `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context`,
+        `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_http`,
+        `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_viewType_moz`,
+      ],
+      "Expected menu items in submenu after changing context to tab"
+    );
+
+    extension.sendMessage("testTabAccess", tabId);
+    is(
+      await extension.awaitMessage("testTabAccessDone"),
+      "executeScript_failed",
+      "executeScript should fail due to the lack of permissions."
+    );
+
+    otherExtension.sendMessage("testTabAccess", tabId);
+    is(
+      await otherExtension.awaitMessage("testTabAccessDone"),
+      "tab_no_url",
+      "Other extension should not have activeTab permissions yet."
+    );
+
+    // Click on the menu item of the other extension to unlock host permissions.
+    let menuItems = menu.getElementsByAttribute("label", "tab_context");
+    is(
+      menuItems.length,
+      2,
+      "There are two menu items with label 'tab_context'"
+    );
+    await closeExtensionContextMenu(menuItems[1]);
+
+    Assert.deepEqual(
+      await otherExtension.awaitMessage("onClicked"),
+      {
+        menuItemId: "tab_context",
+        bookmarkId: undefined,
+        pageUrl: httpUrl,
+        frameUrl: extensionUrl,
+        tabId,
+      },
+      "Expected onClicked details after changing context to tab"
+    );
+
+    extension.sendMessage("testTabAccess", tabId);
+    is(
+      await extension.awaitMessage("testTabAccessDone"),
+      "executeScript_failed",
+      "executeScript of extension that created the menu should still fail."
+    );
+
+    otherExtension.sendMessage("testTabAccess", tabId);
+    is(
+      await otherExtension.awaitMessage("testTabAccessDone"),
+      "executeScript_ok",
+      "Other extension should have activeTab permissions."
+    );
+  }
+
+  {
+    // Test case 2: context=tab, click on menu item of extension..
+    let menu = await openContextMenu("a");
+    await extension.awaitMessage("oncontextmenu_in_dom");
+
+    // The previous test has already verified the visible menu items,
+    // so we skip checking the onShown result and only test clicking.
+    await extension.awaitMessage("onShown");
+    await otherExtension.awaitMessage("onShown");
+    let menuItems = menu.getElementsByAttribute("label", "tab_context");
+    is(
+      menuItems.length,
+      2,
+      "There are two menu items with label 'tab_context'"
+    );
+    await closeExtensionContextMenu(menuItems[0]);
+
+    Assert.deepEqual(
+      await extension.awaitMessage("onClicked"),
+      {
+        menuItemId: "tab_context",
+        bookmarkId: undefined,
+        pageUrl: httpUrl,
+        frameUrl: extensionUrl,
+        tabId,
+      },
+      "Expected onClicked details after changing context to tab"
+    );
+
+    extension.sendMessage("testTabAccess", tabId);
+    is(
+      await extension.awaitMessage("testTabAccessDone"),
+      "executeScript_failed",
+      "activeTab permission should not be available to the extension that created the menu."
+    );
+  }
+
+  {
+    // Test case 4: context=tab, invalid tabId.
+    let menu = await openContextMenu("a");
+    await extension.awaitMessage("oncontextmenu_in_dom");
+    // When an invalid tabId is used, all extension menu logic is skipped and
+    // the default menu is shown.
+    checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+    await closeContextMenu(menu);
+  }
+
+  await extension.unload();
+  await otherExtension.unload();
+
+  let tabmail = document.getElementById("tabmail");
+  tabmail.closeTab(tabmail.currentTabInfo);
+  tabmail.closeTab(tabmail.currentTabInfo);
+});
--- a/mail/components/extensions/test/browser/head.js
+++ b/mail/components/extensions/test/browser/head.js
@@ -17,16 +17,25 @@ var { toXPCOMArray } = ChromeUtils.impor
 //       should use "expectUncaughtRejection" to flag individual failures.
 const { PromiseTestUtils } = ChromeUtils.import(
   "resource://testing-common/PromiseTestUtils.jsm"
 );
 PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
 PromiseTestUtils.whitelistRejectionsGlobally(/No matching message handler/);
 PromiseTestUtils.whitelistRejectionsGlobally(/Receiving end does not exist/);
 
+registerCleanupFunction(() => {
+  let tabmail = document.getElementById("tabmail");
+  is(tabmail.tabInfo.length, 1);
+
+  while (tabmail.tabInfo.length > 1) {
+    tabmail.closeTab(tabmail.tabInfo[1]);
+  }
+});
+
 function createAccount() {
   registerCleanupFunction(() => {
     [...MailServices.accounts.accounts.enumerate()].forEach(cleanUpAccount);
   });
 
   MailServices.accounts.createLocalMailAccount();
   let account = MailServices.accounts.accounts.enumerate().getNext();
   info(`Created account ${account.toString()}`);
@@ -221,8 +230,65 @@ async function checkComposeHeaders(expec
 
   let subject = composeDocument.getElementById("msgSubject").value;
   if ("subject" in expected) {
     is(subject, expected.subject, "subject is correct");
   } else {
     is(subject, "", "subject is empty");
   }
 }
+
+async function openContextMenu(selector = "#img1", win = window) {
+  let contentAreaContextMenu = win.document.getElementById("mailContext");
+  let popupShownPromise = BrowserTestUtils.waitForEvent(
+    contentAreaContextMenu,
+    "popupshown"
+  );
+  let tabmail = document.getElementById("tabmail");
+  await BrowserTestUtils.synthesizeMouseAtCenter(
+    selector,
+    { type: "mousedown", button: 2 },
+    tabmail.selectedBrowser
+  );
+  await BrowserTestUtils.synthesizeMouseAtCenter(
+    selector,
+    { type: "contextmenu" },
+    tabmail.selectedBrowser
+  );
+  await popupShownPromise;
+  return contentAreaContextMenu;
+}
+
+async function closeExtensionContextMenu(itemToSelect, modifiers = {}) {
+  let contentAreaContextMenu = document.getElementById("mailContext");
+  let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+    contentAreaContextMenu,
+    "popuphidden"
+  );
+  if (itemToSelect) {
+    EventUtils.synthesizeMouseAtCenter(itemToSelect, modifiers);
+  } else {
+    contentAreaContextMenu.hidePopup();
+  }
+  await popupHiddenPromise;
+
+  // Bug 1351638: parent menu fails to close intermittently, make sure it does.
+  contentAreaContextMenu.hidePopup();
+}
+
+async function openSubmenu(submenuItem, win = window) {
+  const submenu = submenuItem.menupopup;
+  const shown = BrowserTestUtils.waitForEvent(submenu, "popupshown");
+  EventUtils.synthesizeMouseAtCenter(submenuItem, {}, win);
+  await shown;
+  return submenu;
+}
+
+async function closeContextMenu(contextMenu) {
+  let contentAreaContextMenu =
+    contextMenu || document.getElementById("mailContext");
+  let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+    contentAreaContextMenu,
+    "popuphidden"
+  );
+  contentAreaContextMenu.hidePopup();
+  await popupHiddenPromise;
+}