Bug 1215376 - Add onShown and onHidden to contextMenus/menus API draft
authorRob Wu <rob@robwu.nl>
Sun, 10 Sep 2017 01:38:45 +0200
changeset 716030 703490b5e551455bee0ebcdecaab2aa2ea5321ba
parent 716029 2c9bedf468eb65f483a49267b4ce142ecc31d086
child 716031 d9a1b8cc345e2295eee62701a2ecc51cf861a3c1
push id94307
push userbmo:rob@robwu.nl
push dateFri, 05 Jan 2018 00:24:53 +0000
bugs1215376
milestone59.0a1
Bug 1215376 - Add onShown and onHidden to contextMenus/menus API This commit adds the bare minimum to support the onShown and onHidden methods in the contextMenus and menus API (plus tests). The onShown event data may be extended in a separate commit, to make it easier to see how the additional properties are reflected in the tests. MozReview-Commit-ID: 20VJS2YLhTN
browser/components/extensions/ext-browser.json
browser/components/extensions/ext-menus.js
browser/components/extensions/schemas/menus.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_menus_events.js
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -97,16 +97,18 @@
       ["identity"]
     ]
   },
   "menusInternal": {
     "url": "chrome://browser/content/ext-menus.js",
     "schema": "chrome://browser/content/schemas/menus.json",
     "scopes": ["addon_parent"],
     "paths": [
+      ["contextMenus"],
+      ["menus"],
       ["menusInternal"]
     ]
   },
   "omnibox": {
     "url": "chrome://browser/content/ext-omnibox.js",
     "schema": "chrome://browser/content/schemas/omnibox.json",
     "scopes": ["addon_parent"],
     "manifest": ["omnibox"],
--- a/browser/components/extensions/ext-menus.js
+++ b/browser/components/extensions/ext-menus.js
@@ -3,16 +3,17 @@
 "use strict";
 
 // The ext-* files are imported into the same scopes.
 /* import-globals-from ext-browser.js */
 
 Cu.import("resource://gre/modules/Services.jsm");
 
 var {
+  DefaultMap,
   ExtensionError,
 } = ExtensionUtils;
 
 Cu.import("resource://gre/modules/ExtensionParent.jsm");
 
 var {
   IconDetails,
 } = ExtensionParent;
@@ -22,16 +23,20 @@ const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
 // Map[Extension -> Map[ID -> MenuItem]]
 // Note: we want to enumerate all the menu items so
 // this cannot be a weak map.
 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(() => []);
+
 // 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.
 var gMaxLabelLength = 64;
@@ -66,16 +71,17 @@ var gMenuBuilder = {
         const separator = xulMenu.ownerDocument.createElement("menuseparator");
         this.itemsToCleanUp.add(separator);
         xulMenu.append(separator);
       }
 
       xulMenu.appendChild(rootElement);
       this.itemsToCleanUp.add(rootElement);
     }
+    this.afterBuildingMenu(contextData);
   },
 
   // Builds a context menu for browserAction and pageAction buttons.
   buildActionContextMenu(contextData) {
     const {menu} = contextData;
 
     contextData.tab = tabTracker.activeTab;
     contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
@@ -96,16 +102,17 @@ var gMenuBuilder = {
       menu.insertBefore(separator, menu.firstChild);
       this.itemsToCleanUp.add(separator);
 
       for (const child of visible) {
         this.itemsToCleanUp.add(child);
         menu.insertBefore(child, separator);
       }
     }
+    this.afterBuildingMenu(contextData);
   },
 
   buildElementWithChildren(item, contextData) {
     const element = this.buildSingleElement(item, contextData);
     const children = this.buildChildren(item, contextData);
     if (children.length) {
       element.firstChild.append(...children);
     }
@@ -274,16 +281,22 @@ var gMenuBuilder = {
       if (actionFor) {
         let win = event.target.ownerGlobal;
         actionFor(item.extension).triggerAction(win);
       }
 
       item.extension.emit("webext-menu-menuitem-click", info, tab);
     });
 
+    // Don't publish the ID of the root because the root element is
+    // auto-generated.
+    if (item.parent) {
+      gShownMenuItems.get(item.extension).push(item.id);
+    }
+
     return element;
   },
 
   setMenuItemIcon(element, extension, contextData, icons) {
     let parentWindow = contextData.menu.ownerGlobal;
 
     let {icon} = IconDetails.getPreferredIcon(icons, extension,
                                               16 * parentWindow.devicePixelRatio);
@@ -297,28 +310,49 @@ var gMenuBuilder = {
       element.setAttribute("class", "menu-iconic");
     } else if (element.localName == "menuitem") {
       element.setAttribute("class", "menuitem-iconic");
     }
 
     element.setAttribute("image", resolvedURL);
   },
 
+  afterBuildingMenu(contextData) {
+    if (gShownMenuItems.size === 0) {
+      return;
+    }
+    let commonMenuInfo = {
+      contexts: Array.from(getMenuContexts(contextData)),
+    };
+    // TODO(robwu): Add more contextual information.
+    // The menus.onShown event is fired before the user has consciously
+    // interacted with an extension, so beware of privacy implications of
+    // sharing event data without permission checks.
+    for (let [extension, menuIds] of gShownMenuItems.entries()) {
+      let info = Object.assign({menuIds}, commonMenuInfo);
+      extension.emit("webext-menu-shown", info);
+    }
+  },
+
   handleEvent(event) {
     if (this.xulMenu != event.target || event.type != "popuphidden") {
       return;
     }
 
     delete this.xulMenu;
     let target = event.target;
     target.removeEventListener("popuphidden", this);
     for (let item of this.itemsToCleanUp) {
       item.remove();
     }
     this.itemsToCleanUp.clear();
+    for (let extension of gShownMenuItems.keys()) {
+      extension.emit("webext-menu-hidden");
+    }
+    gShownMenuItems.clear();
   },
 
   itemsToCleanUp: new Set(),
 };
 
 // Called from pageAction or browserAction popup.
 global.actionContextMenu = function(contextData) {
   gMenuBuilder.buildActionContextMenu(contextData);
@@ -661,26 +695,50 @@ this.menusInternal = class extends Exten
   }
 
   onShutdown(reason) {
     let {extension} = this;
 
     if (gMenuMap.has(extension)) {
       gMenuMap.delete(extension);
       gRootItems.delete(extension);
+      gShownMenuItems.delete(extension);
       if (!gMenuMap.size) {
         menuTracker.unregister();
       }
     }
   }
 
   getAPI(context) {
     let {extension} = context;
 
+    const menus = {
+      onShown: new EventManager(context, "menus.onShown", fire => {
+        let listener = (event, data) => {
+          fire.sync(data);
+        };
+        extension.on("webext-menu-shown", listener);
+        return () => {
+          extension.off("webext-menu-shown", listener);
+        };
+      }).api(),
+      onHidden: new EventManager(context, "menus.onHidden", fire => {
+        let listener = () => {
+          fire.sync();
+        };
+        extension.on("webext-menu-hidden", listener);
+        return () => {
+          extension.off("webext-menu-hidden", listener);
+        };
+      }).api(),
+    };
+
     return {
+      contextMenus: menus,
+      menus,
       menusInternal: {
         create: function(createProperties) {
           // Note that the id is required by the schema. If the addon did not set
           // it, the implementation of menus.create in the child should
           // have added it.
           let menuItem = new MenuItem(extension, createProperties);
           gMenuMap.get(extension).set(menuItem.id, menuItem);
         },
--- a/browser/components/extensions/schemas/menus.json
+++ b/browser/components/extensions/schemas/menus.json
@@ -389,12 +389,47 @@
           },
           {
             "name": "tab",
             "$ref": "tabs.Tab",
             "description": "The details of the tab where the click took place. If the click did not take place in a tab, this parameter will be missing.",
             "optional": true
           }
         ]
+      },
+      {
+        "name": "onShown",
+        "type": "function",
+        "description": "Fired when a menu is shown that contains a menu item that was created by the extension.",
+        "parameters": [
+          {
+            "name": "info",
+            "type": "object",
+            "description": "Information about the context of the menu action and the created menu items",
+            "properties": {
+              "menuIds": {
+                "description": "A list of IDs of the menu items that were shown.",
+                "type": "array",
+                "items": {
+                  "choices": [
+                    { "type": "integer" },
+                    { "type": "string" }
+                  ]
+                }
+              },
+              "contexts": {
+                "description": "A list of all contexts that apply to the menu.",
+                "type": "array",
+                "items": {"$ref": "ContextType"}
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "onHidden",
+        "type": "function",
+        "description": "Fired when a menu is hidden. This event is only fired if the menu contained a menu item that was created by the extension.",
+        "parameters": []
       }
     ]
   }
 ]
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -89,16 +89,17 @@ skip-if = (os == 'win' && ccov) # Bug 14
 [browser_ext_geckoProfiler_symbolicate.js]
 [browser_ext_getViews.js]
 [browser_ext_history_redirect.js]
 [browser_ext_identity_indication.js]
 [browser_ext_incognito_views.js]
 [browser_ext_incognito_popup.js]
 [browser_ext_lastError.js]
 [browser_ext_menus.js]
+[browser_ext_menus_events.js]
 [browser_ext_omnibox.js]
 [browser_ext_openPanel.js]
 [browser_ext_optionsPage_browser_style.js]
 [browser_ext_optionsPage_modals.js]
 [browser_ext_optionsPage_privileges.js]
 [browser_ext_pageAction_context.js]
 [browser_ext_pageAction_contextMenu.js]
 [browser_ext_pageAction_popup.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_menus_events.js
@@ -0,0 +1,308 @@
+/* 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";
+
+const PAGE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html";
+
+// Registers a context menu using menus.create(menuCreateParams) and checks
+// whether the menus.onShown and menus.onHidden events are fired as expected.
+// doOpenMenu must open the menu and its returned promise must resolve after the
+// menu is shown. Similarly, doCloseMenu must hide the menu.
+async function testShowHideEvent({menuCreateParams, doOpenMenu, doCloseMenu,
+                                  expectedShownEvent}) {
+  async function background() {
+    function awaitMessage(expectedId) {
+      return new Promise(resolve => {
+        browser.test.log(`Waiting for message: ${expectedId}`);
+        browser.test.onMessage.addListener(function listener(id, msg) {
+          browser.test.assertEq(expectedId, id, "Expected message");
+          browser.test.onMessage.removeListener(listener);
+          resolve(msg);
+        });
+      });
+    }
+
+    let menuCreateParams = await awaitMessage("create-params");
+
+    let shownEvents = [];
+    let hiddenEvents = [];
+
+    browser.menus.onShown.addListener(event => shownEvents.push(event));
+    browser.menus.onHidden.addListener(event => hiddenEvents.push(event));
+
+    const [tab] = await browser.tabs.query({active: true});
+    await browser.pageAction.show(tab.id);
+
+    let menuId;
+    await new Promise(resolve => {
+      menuId = browser.menus.create(menuCreateParams, resolve);
+    });
+    browser.test.assertEq(0, shownEvents.length, "no onShown before menu");
+    browser.test.assertEq(0, hiddenEvents.length, "no onHidden before menu");
+    browser.test.sendMessage("menu-registered", menuId);
+
+    await awaitMessage("assert-menu-shown");
+    browser.test.assertEq(1, shownEvents.length, "expected onShown");
+    browser.test.assertEq(0, hiddenEvents.length, "no onHidden before closing");
+    browser.test.sendMessage("onShown-event-data", shownEvents[0]);
+
+    await awaitMessage("assert-menu-hidden");
+    browser.test.assertEq(1, shownEvents.length, "expected no more onShown");
+    browser.test.assertEq(1, hiddenEvents.length, "expected onHidden");
+    browser.test.sendMessage("onHidden-event-data", hiddenEvents[0]);
+  }
+
+  const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      page_action: {},
+      browser_action: {},
+      permissions: ["menus"],
+    },
+  });
+  await extension.startup();
+  extension.sendMessage("create-params", menuCreateParams);
+  let menuId = await extension.awaitMessage("menu-registered");
+
+  await doOpenMenu(extension);
+  extension.sendMessage("assert-menu-shown");
+  let shownEvent = await extension.awaitMessage("onShown-event-data");
+
+  // menuCreateParams.id is not set, therefore a numeric ID is generated.
+  expectedShownEvent.menuIds = [menuId];
+  Assert.deepEqual(shownEvent, expectedShownEvent, "expected onShown info");
+
+  await doCloseMenu();
+  extension.sendMessage("assert-menu-hidden");
+  let hiddenEvent = await extension.awaitMessage("onHidden-event-data");
+  is(hiddenEvent, undefined, "expected no event data for onHidden event");
+
+  await extension.unload();
+  await BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function test_no_show_hide_without_menu_item() {
+  let extension = ExtensionTestUtils.loadExtension({
+    background() {
+      let events = [];
+      browser.menus.onShown.addListener(data => events.push(data));
+      browser.menus.onHidden.addListener(() => events.push("onHidden"));
+      browser.test.onMessage.addListener(() => {
+        browser.test.assertEq("[]", JSON.stringify(events),
+          "Should not have any events when the context menu does not match");
+        browser.test.notifyPass("done listening to menu events");
+      });
+
+      browser.menus.create({
+        title: "never shown",
+        documentUrlPatterns: ["*://url-pattern-that-never-matches/*"],
+        contexts: ["all"],
+      });
+    },
+    manifest: {
+      permissions: ["menus"],
+    },
+  });
+
+  await extension.startup();
+
+  // Run another context menu test where onShown/onHidden will fire.
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "any menu item",
+      contexts: ["all"],
+    },
+    expectedShownEvent: {
+      contexts: ["page", "all"],
+    },
+    async doOpenMenu() {
+      await openContextMenu("body");
+    },
+    async doCloseMenu() {
+      await closeExtensionContextMenu();
+    },
+  });
+
+  // Now the menu has been shown and hidden, and in another extension the
+  // onShown/onHidden events have been dispatched.
+  // If the the first extension has not received any events by now, we can be
+  // confident that the onShown/onHidden events are not unexpectedly triggered.
+  extension.sendMessage("check menu events");
+  await extension.awaitFinish("done listening to menu events");
+  await extension.unload();
+});
+
+add_task(async function test_show_hide_pageAction() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "pageAction item",
+      contexts: ["page_action"],
+    },
+    expectedShownEvent: {
+      contexts: ["page_action", "all"],
+    },
+    async doOpenMenu(extension) {
+      await openActionContextMenu(extension, "page");
+    },
+    async doCloseMenu() {
+      await closeActionContextMenu(null, "page");
+    },
+  });
+});
+
+add_task(async function test_show_hide_browserAction() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "browserAction item",
+      contexts: ["browser_action"],
+    },
+    expectedShownEvent: {
+      contexts: ["browser_action", "all"],
+    },
+    async doOpenMenu(extension) {
+      await openActionContextMenu(extension, "browser");
+    },
+    async doCloseMenu() {
+      await closeActionContextMenu();
+    },
+  });
+});
+
+add_task(async function test_show_hide_tab() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "tab menu item",
+      contexts: ["tab"],
+    },
+    expectedShownEvent: {
+      contexts: ["tab"],
+    },
+    async doOpenMenu() {
+      await openTabContextMenu();
+    },
+    async doCloseMenu() {
+      await closeTabContextMenu();
+    },
+  });
+});
+
+add_task(async function test_show_hide_tools_menu() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "menu item",
+      contexts: ["tools_menu"],
+    },
+    expectedShownEvent: {
+      contexts: ["tools_menu"],
+    },
+    async doOpenMenu() {
+      await openToolsMenu();
+    },
+    async doCloseMenu() {
+      await closeToolsMenu();
+    },
+  });
+});
+
+add_task(async function test_show_hide_page() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "page menu item",
+      contexts: ["page"],
+    },
+    expectedShownEvent: {
+      contexts: ["page", "all"],
+    },
+    async doOpenMenu() {
+      await openContextMenu("body");
+    },
+    async doCloseMenu() {
+      await closeExtensionContextMenu();
+    },
+  });
+});
+
+add_task(async function test_show_hide_frame() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "subframe menu item",
+      contexts: ["frame"],
+    },
+    expectedShownEvent: {
+      contexts: ["frame", "all"],
+    },
+    async doOpenMenu() {
+      await openContextMenuInFrame("frame");
+    },
+    async doCloseMenu() {
+      await closeExtensionContextMenu();
+    },
+  });
+});
+
+add_task(async function test_show_hide_password() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "password item",
+      contexts: ["password"],
+    },
+    expectedShownEvent: {
+      contexts: ["password", "all"],
+    },
+    async doOpenMenu() {
+      await openContextMenu("#password");
+    },
+    async doCloseMenu() {
+      await closeExtensionContextMenu();
+    },
+  });
+});
+
+add_task(async function test_show_hide_image_link() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "image item",
+      contexts: ["image"],
+    },
+    expectedShownEvent: {
+      contexts: ["image", "link", "all"],
+    },
+    async doOpenMenu() {
+      await openContextMenu("#img-wrapped-in-link");
+    },
+    async doCloseMenu() {
+      await closeExtensionContextMenu();
+    },
+  });
+});
+
+add_task(async function test_show_hide_editable_selection() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "editable item",
+      contexts: ["editable"],
+    },
+    expectedShownEvent: {
+      contexts: ["editable", "selection", "all"],
+    },
+    async doOpenMenu() {
+      // Select lots of text in the test page before opening the menu.
+      await ContentTask.spawn(gBrowser.selectedBrowser, {}, function() {
+        let node = content.document.getElementById("editabletext");
+        node.select();
+        node.focus();
+      });
+
+      await openContextMenu("#editabletext");
+    },
+    async doCloseMenu() {
+      await closeExtensionContextMenu();
+    },
+  });
+});
+
+// TODO(robwu): Add test coverage for contexts audio, video (bug 1398542).
+