Bug 1215375 - Add support for checkboxes, radio groups, and icons. r=kmag
authorMatthew Wein <mwein@mozilla.com>
Wed, 02 Mar 2016 15:10:01 -0800
changeset 336818 428778584d516aa656cf50ea49e7455ee3d6c1c7
parent 336817 09ada320af0b49f54dbec6b678b3f7fd52af4193
child 336819 9e2e521cbd139851f9ca09ec7f0397cca22cae3d
push id12189
push usercku@mozilla.com
push dateFri, 04 Mar 2016 07:52:22 +0000
reviewerskmag
bugs1215375
milestone47.0a1
Bug 1215375 - Add support for checkboxes, radio groups, and icons. r=kmag
browser/components/extensions/ext-contextMenus.js
browser/components/extensions/ext-utils.js
browser/components/extensions/test/browser/browser_ext_contextMenus.js
--- a/browser/components/extensions/ext-contextMenus.js
+++ b/browser/components/extensions/ext-contextMenus.js
@@ -10,55 +10,86 @@ Cu.import("resource://gre/modules/XPCOMU
 var {
   EventManager,
   runSafe,
 } = ExtensionUtils;
 
 // Map[Extension -> Map[ID -> MenuItem]]
 // Note: we want to enumerate all the menu items so
 // this cannot be a weak map.
-var contextMenuMap = new Map();
+var gContextMenuMap = new Map();
 
 // Map[Extension -> MenuItem]
-var rootItems = new Map();
+var gRootItems = new Map();
 
 // Not really used yet, will be used for event pages.
-var onClickedCallbacksMap = new WeakMap();
+var gOnClickedCallbacksMap = new WeakMap();
 
 // If id is not specified for an item we use an integer.
-var nextID = 0;
+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;
 
 // When a new contextMenu is opened, this function is called and
 // we populate the |xulMenu| with all the items from extensions
 // to be displayed. We always clear all the items again when
 // popuphidden fires.
-var menuBuilder = {
+var gMenuBuilder = {
   build: function(contextData) {
     let xulMenu = contextData.menu;
     xulMenu.addEventListener("popuphidden", this);
     this.xulMenu = xulMenu;
-    for (let [, root] of rootItems) {
+    for (let [, root] of gRootItems) {
       let rootElement = this.buildElementWithChildren(root, contextData);
       if (!rootElement.childNodes.length) {
         // If the root has no visible children, there is no reason to show
         // the root menu item itself either.
         continue;
       }
       rootElement.setAttribute("ext-type", "top-level-menu");
       rootElement = this.removeTopLevelMenuIfNeeded(rootElement);
+
+      // Display the extension icon on the root element.
+      if (root.extension.manifest.icons) {
+        let parentWindow = contextData.menu.ownerDocument.defaultView;
+        let extension = root.extension;
+
+        let url = IconDetails.getURL(extension.manifest.icons, parentWindow, extension, 16 /* size */);
+        let resolvedURL = root.extension.baseURI.resolve(url);
+
+        if (rootElement.localName == "menu") {
+          rootElement.setAttribute("class", "menu-iconic");
+        } else if (rootElement.localName == "menuitem") {
+          rootElement.setAttribute("class", "menuitem-iconic");
+        }
+        rootElement.setAttribute("image", resolvedURL);
+      }
+
       xulMenu.appendChild(rootElement);
       this.itemsToCleanUp.add(rootElement);
     }
   },
 
   buildElementWithChildren(item, contextData) {
     let element = this.buildSingleElement(item, contextData);
+    let groupName;
     for (let child of item.children) {
+      if (child.type == "radio" && !child.groupName) {
+        if (!groupName) {
+          groupName = `webext-radio-group-${gNextRadioGroupID++}`;
+        }
+        child.groupName = groupName;
+      } else {
+        groupName = null;
+      }
+
       if (child.enabledForContext(contextData)) {
         let childElement = this.buildElementWithChildren(child, contextData);
         // Here element must be a menu element and its first child
         // is a menupopup, we have to append its children to this
         // menupopup.
         element.firstChild.appendChild(childElement);
       }
     }
@@ -90,18 +121,17 @@ var menuBuilder = {
       element = doc.createElement("menuitem");
     }
 
     return this.customizeElement(element, item, contextData);
   },
 
   createMenuElement(doc, item) {
     let element = doc.createElement("menu");
-    // Menu elements need to have a menupopup child for
-    // its menu items.
+    // Menu elements need to have a menupopup child for its menu items.
     let menupopup = doc.createElement("menupopup");
     element.appendChild(menupopup);
     return element;
   },
 
   customizeElement(element, item, contextData) {
     let label = item.title;
     if (label) {
@@ -115,21 +145,47 @@ var menuBuilder = {
           selection = selection.substring(0, maxSelectionLength - 3) + "...";
         }
         label = label.replace(/%s/g, selection);
       }
 
       element.setAttribute("label", label);
     }
 
+    if (item.type == "checkbox") {
+      element.setAttribute("type", "checkbox");
+      if (item.checked) {
+        element.setAttribute("checked", "true");
+      }
+    } else if (item.type == "radio") {
+      element.setAttribute("type", "radio");
+      element.setAttribute("name", item.groupName);
+      if (item.checked) {
+        element.setAttribute("checked", "true");
+      }
+    }
+
     if (!item.enabled) {
-      element.setAttribute("disabled", true);
+      element.setAttribute("disabled", "true");
     }
 
     element.addEventListener("command", event => {  // eslint-disable-line mozilla/balanced-listeners
+      if (item.type == "checkbox") {
+        item.checked = !item.checked;
+      } else if (item.type == "radio") {
+        // Deselect all radio items in the current radio group.
+        for (let child of item.parent.children) {
+          if (child.type == "radio" && child.groupName == item.groupName) {
+            child.checked = false;
+          }
+        }
+        // Select the clicked radio item.
+        item.checked = true;
+      }
+
       item.tabManager.addActiveTabPermission();
       if (item.onclick) {
         let clickData = item.getClickData(contextData, event);
         runSafe(item.extContext, item.onclick, clickData);
       }
     });
 
     return element;
@@ -149,17 +205,17 @@ var menuBuilder = {
     this.itemsToCleanUp.clear();
   },
 
   itemsToCleanUp: new Set(),
 };
 
 function contextMenuObserver(subject, topic, data) {
   subject = subject.wrappedJSObject;
-  menuBuilder.build(subject);
+  gMenuBuilder.build(subject);
 }
 
 function getContexts(contextData) {
   let contexts = new Set(["all"]);
 
   contexts.add("page");
 
   if (contextData.inFrame) {
@@ -198,17 +254,17 @@ function MenuItem(extension, extContext,
   this.extContext = extContext;
   this.children = [];
   this.parent = null;
   this.tabManager = TabManager.for(extension);
 
   this.setDefaults();
   this.setProps(createProperties);
   if (!this.hasOwnProperty("_id")) {
-    this.id = nextID++;
+    this.id = gNextMenuItemID++;
   }
   // If the item is not the root and has no parent
   // it must be a child of the root.
   if (!isRoot && !this.parent) {
     this.root.addChild(this);
   }
 }
 
@@ -229,42 +285,42 @@ MenuItem.prototype = {
     if (createProperties.targetUrlPatterns != null) {
       this.targetUrlMatchPattern = new MatchPattern(this.targetUrlPatterns);
     }
   },
 
   setDefaults() {
     this.setProps({
       type: "normal",
-      checked: "false",
+      checked: false,
       contexts: ["all"],
-      enabled: "true",
+      enabled: true,
     });
   },
 
   set id(id) {
     if (this.hasOwnProperty("_id")) {
       throw new Error("Id of a MenuItem cannot be changed");
     }
-    let isIdUsed = contextMenuMap.get(this.extension).has(id);
+    let isIdUsed = gContextMenuMap.get(this.extension).has(id);
     if (isIdUsed) {
       throw new Error("Id already exists");
     }
     this._id = id;
   },
 
   get id() {
     return this._id;
   },
 
   ensureValidParentId(parentId) {
     if (parentId === undefined) {
       return;
     }
-    let menuMap = contextMenuMap.get(this.extension);
+    let menuMap = gContextMenuMap.get(this.extension);
     if (!menuMap.has(parentId)) {
       throw new Error("Could not find any MenuItem with id: " + parentId);
     }
     for (let item = menuMap.get(parentId); item; item = item.parent) {
       if (item === this) {
         throw new Error("MenuItem cannot be an ancestor (or self) of its new parent.");
       }
     }
@@ -275,17 +331,17 @@ MenuItem.prototype = {
 
     if (this.parent) {
       this.parent.detachChild(this);
     }
 
     if (parentId === undefined) {
       this.root.addChild(this);
     } else {
-      let menuMap = contextMenuMap.get(this.extension);
+      let menuMap = gContextMenuMap.get(this.extension);
       menuMap.get(parentId).addChild(this);
     }
   },
 
   get parentId() {
     return this.parent ? this.parent.id : undefined;
   },
 
@@ -303,39 +359,39 @@ MenuItem.prototype = {
       throw new Error("Child MenuItem not found, it cannot be removed.");
     }
     this.children.splice(idx, 1);
     child.parent = null;
   },
 
   get root() {
     let extension = this.extension;
-    if (!rootItems.has(extension)) {
+    if (!gRootItems.has(extension)) {
       let root = new MenuItem(extension, this.context,
                               {title: extension.name},
                               /* isRoot = */ true);
-      rootItems.set(extension, root);
+      gRootItems.set(extension, root);
     }
 
-    return rootItems.get(extension);
+    return gRootItems.get(extension);
   },
 
   remove() {
     if (this.parent) {
       this.parent.detachChild(this);
     }
     let children = this.children.slice(0);
     for (let child of children) {
       child.remove();
     }
 
-    let menuMap = contextMenuMap.get(this.extension);
+    let menuMap = gContextMenuMap.get(this.extension);
     menuMap.delete(this.id);
     if (this.root == this) {
-      rootItems.delete(this.extension);
+      gRootItems.delete(this.extension);
     }
   },
 
   getClickData(contextData, event) {
     let mediaType;
     if (contextData.onVideo) {
       mediaType = "video";
     }
@@ -389,79 +445,79 @@ MenuItem.prototype = {
       // TODO: double check if mediaURL is always set when we need it
       return false;
     }
 
     return true;
   },
 };
 
-var extCount = 0;
+var gExtensionCount = 0;
 /* eslint-disable mozilla/balanced-listeners */
 extensions.on("startup", (type, extension) => {
-  contextMenuMap.set(extension, new Map());
-  rootItems.delete(extension);
-  if (++extCount == 1) {
+  gContextMenuMap.set(extension, new Map());
+  gRootItems.delete(extension);
+  if (++gExtensionCount == 1) {
     Services.obs.addObserver(contextMenuObserver,
                              "on-build-contextmenu",
                              false);
   }
 });
 
 extensions.on("shutdown", (type, extension) => {
-  contextMenuMap.delete(extension);
-  if (--extCount == 0) {
+  gContextMenuMap.delete(extension);
+  if (--gExtensionCount == 0) {
     Services.obs.removeObserver(contextMenuObserver,
                                 "on-build-contextmenu");
   }
 });
 /* eslint-enable mozilla/balanced-listeners */
 
 extensions.registerSchemaAPI("contextMenus", "contextMenus", (extension, context) => {
   return {
     contextMenus: {
       create: function(createProperties, callback) {
         let menuItem = new MenuItem(extension, context, createProperties);
-        contextMenuMap.get(extension).set(menuItem.id, menuItem);
+        gContextMenuMap.get(extension).set(menuItem.id, menuItem);
         if (callback) {
           runSafe(context, callback);
         }
         return menuItem.id;
       },
 
       update: function(id, updateProperties) {
-        let menuItem = contextMenuMap.get(extension).get(id);
+        let menuItem = gContextMenuMap.get(extension).get(id);
         if (menuItem) {
           menuItem.setProps(updateProperties);
         }
         return Promise.resolve();
       },
 
       remove: function(id) {
-        let menuItem = contextMenuMap.get(extension).get(id);
+        let menuItem = gContextMenuMap.get(extension).get(id);
         if (menuItem) {
           menuItem.remove();
         }
         return Promise.resolve();
       },
 
       removeAll: function() {
-        let root = rootItems.get(extension);
+        let root = gRootItems.get(extension);
         if (root) {
           root.remove();
         }
         return Promise.resolve();
       },
 
       // TODO: implement this once event pages are ready.
       onClicked: new EventManager(context, "contextMenus.onClicked", fire => {
         let callback = menuItem => {
           fire(menuItem.data);
         };
 
-        onClickedCallbacksMap.set(extension, callback);
+        gOnClickedCallbacksMap.set(extension, callback);
         return () => {
-          onClickedCallbacksMap.delete(extension);
+          gOnClickedCallbacksMap.delete(extension);
         };
       }).api(),
     },
   };
 });
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -97,20 +97,20 @@ global.IconDetails = {
       extension.manifestError(`Invalid icon data: ${e}`);
     }
 
     return result;
   },
 
   // Returns the appropriate icon URL for the given icons object and the
   // screen resolution of the given window.
-  getURL(icons, window, extension) {
+  getURL(icons, window, extension, size = 18) {
     const DEFAULT = "chrome://browser/content/extension.svg";
 
-    return AddonManager.getPreferredIconURL({icons: icons}, 18, window) || DEFAULT;
+    return AddonManager.getPreferredIconURL({icons: icons}, size, window) || DEFAULT;
   },
 
   convertImageDataToPNG(imageData, context) {
     let document = context.contentWindow.document;
     let canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
     canvas.width = imageData.width;
     canvas.height = imageData.height;
     canvas.getContext("2d").putImageData(imageData, 0, 0);
--- a/browser/components/extensions/test/browser/browser_ext_contextMenus.js
+++ b/browser/components/extensions/test/browser/browser_ext_contextMenus.js
@@ -1,175 +1,310 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 /* globals content */
 /* eslint-disable mozilla/no-cpows-in-tests */
-
 add_task(function* () {
   let tab1 = yield BrowserTestUtils.openNewForegroundTab(gBrowser,
     "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html");
 
   gBrowser.selectedTab = tab1;
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       "permissions": ["contextMenus"],
     },
 
     background: function() {
       // A generic onclick callback function.
       function genericOnClick(info) {
-        browser.test.sendMessage("menuItemClick", JSON.stringify(info));
+        browser.test.sendMessage("menuItemClick", info);
       }
 
-      browser.contextMenus.create({contexts: ["all"], type: "separator"});
+      browser.contextMenus.create({
+        contexts: ["all"],
+        type: "separator",
+      });
 
       let contexts = ["page", "selection", "image"];
       for (let i = 0; i < contexts.length; i++) {
         let context = contexts[i];
         let title = context;
-        browser.contextMenus.create({title: title, contexts: [context], id: "ext-" + context,
-                                     onclick: genericOnClick});
+        browser.contextMenus.create({
+          title: title,
+          contexts: [context],
+          id: "ext-" + context,
+          onclick: genericOnClick,
+        });
         if (context == "selection") {
           browser.contextMenus.update("ext-selection", {
             title: "selection is: '%s'",
             onclick: (info) => {
               browser.contextMenus.removeAll();
               genericOnClick(info);
             },
           });
         }
       }
 
-      let parent = browser.contextMenus.create({title: "parent"});
-      browser.contextMenus.create(
-        {title: "child1", parentId: parent, onclick: genericOnClick});
-      let child2 = browser.contextMenus.create(
-        {title: "child2", parentId: parent, onclick: genericOnClick});
+      let parent = browser.contextMenus.create({
+        title: "parent",
+      });
+      browser.contextMenus.create({
+        title: "child1",
+        parentId: parent,
+        onclick: genericOnClick,
+      });
+      let child2 = browser.contextMenus.create({
+        title: "child2",
+        parentId: parent,
+        onclick: genericOnClick,
+      });
+
+      let parentToDel = browser.contextMenus.create({
+        title: "parentToDel",
+      });
+      browser.contextMenus.create({
+        title: "child1",
+        parentId: parentToDel,
+        onclick: genericOnClick,
+      });
+      browser.contextMenus.create({
+        title: "child2",
+        parentId: parentToDel,
+        onclick: genericOnClick,
+      });
+      browser.contextMenus.remove(parentToDel);
+
+      browser.contextMenus.create({
+        title: "radio-group-1",
+        type: "radio",
+        checked: true,
+        contexts: ["page"],
+        onclick: genericOnClick,
+      });
 
-      let parentToDel = browser.contextMenus.create({title: "parentToDel"});
-      browser.contextMenus.create(
-        {title: "child1", parentId: parentToDel, onclick: genericOnClick});
-      browser.contextMenus.create(
-        {title: "child2", parentId: parentToDel, onclick: genericOnClick});
-      browser.contextMenus.remove(parentToDel);
+      browser.contextMenus.create({
+        title: "Checkbox",
+        type: "checkbox",
+        contexts: ["page"],
+        onclick: genericOnClick,
+      });
+
+      browser.contextMenus.create({
+        title: "radio-group-2",
+        type: "radio",
+        contexts: ["page"],
+        onclick: genericOnClick,
+      });
+
+      browser.contextMenus.create({
+        title: "radio-group-2",
+        type: "radio",
+        contexts: ["page"],
+        onclick: genericOnClick,
+      });
+
+      browser.contextMenus.create({
+        type: "separator",
+      });
+
+      browser.contextMenus.create({
+        title: "Checkbox",
+        type: "checkbox",
+        checked: true,
+        contexts: ["page"],
+        onclick: genericOnClick,
+      });
+
+      browser.contextMenus.create({
+        title: "Checkbox",
+        type: "checkbox",
+        contexts: ["page"],
+        onclick: genericOnClick,
+      });
 
       browser.contextMenus.update(parent, {parentId: child2}).then(
         () => {
           browser.test.notifyFail();
         },
         () => {
           browser.test.notifyPass();
-        });
+        }
+      );
     },
   });
 
-  let expectedClickInfo;
-  function checkClickInfo(info) {
-    info = JSON.parse(info);
-    for (let i in expectedClickInfo) {
-      is(info[i], expectedClickInfo[i],
-         "click info " + i + " expected to be: " + expectedClickInfo[i] + " but was: " + info[i]);
-    }
-    is(expectedClickInfo.pageSrc, info.tab.url);
-  }
-
   yield extension.startup();
   yield extension.awaitFinish();
 
-  // Bring up context menu
-  let contentAreaContextMenu = document.getElementById("contentAreaContextMenu");
-  let popupShownPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popupshown");
-  yield BrowserTestUtils.synthesizeMouseAtCenter("#img1",
-    {type: "contextmenu", button: 2}, gBrowser.selectedBrowser);
-  yield popupShownPromise;
+  let contentAreaContextMenu;
+
+  function getTop() {
+    contentAreaContextMenu = document.getElementById("contentAreaContextMenu");
+    let items = contentAreaContextMenu.getElementsByAttribute("ext-type", "top-level-menu");
+    is(items.length, 1, "top level item was found (context=selection)");
+    let topItem = items[0];
+    return topItem.childNodes[0];
+  }
+
+  function* openExtensionMenu() {
+    contentAreaContextMenu = document.getElementById("contentAreaContextMenu");
+    let popupShownPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popupshown");
+    yield BrowserTestUtils.synthesizeMouseAtCenter("#img1", {
+      type: "contextmenu",
+      button: 2,
+    }, gBrowser.selectedBrowser);
+    yield popupShownPromise;
+
+    popupShownPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popupshown");
+    EventUtils.synthesizeMouseAtCenter(getTop(), {});
+    yield popupShownPromise;
+  }
+
+  function* closeContextMenu(itemToSelect, expectedClickInfo) {
+    function checkClickInfo(info) {
+      for (let i of Object.keys(expectedClickInfo)) {
+        is(info[i], expectedClickInfo[i],
+           "click info " + i + " expected to be: " + expectedClickInfo[i] + " but was: " + info[i]);
+      }
+      is(expectedClickInfo.pageSrc, info.tab.url);
+    }
+    let popupHiddenPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popuphidden");
+    EventUtils.synthesizeMouseAtCenter(itemToSelect, {});
+    let clickInfo = yield extension.awaitMessage("menuItemClick");
+    if (expectedClickInfo) {
+      checkClickInfo(clickInfo);
+    }
+    yield popupHiddenPromise;
+  }
+
+  function confirmRadioGroupStates(expectedStates) {
+    let top = getTop();
+
+    let radioItems = top.getElementsByAttribute("type", "radio");
+    let radioGroup1 = top.getElementsByAttribute("label", "radio-group-1");
+    let radioGroup2 = top.getElementsByAttribute("label", "radio-group-2");
+
+    is(radioItems.length, 3, "there should be 3 radio items in the context menu");
+    is(radioGroup1.length, 1, "the first radio group should only have 1 radio item");
+    is(radioGroup2.length, 2, "the second radio group should only have 2 radio items");
+
+    is(radioGroup1[0].hasAttribute("checked"), expectedStates[0], `radio item 1 has state (checked=${expectedStates[0]})`);
+    is(radioGroup2[0].hasAttribute("checked"), expectedStates[1], `radio item 2 has state (checked=${expectedStates[1]})`);
+    is(radioGroup2[1].hasAttribute("checked"), expectedStates[2], `radio item 3 has state (checked=${expectedStates[2]})`);
+  }
+
+  function confirmCheckboxStates(expectedStates) {
+    let checkboxItems = getTop().getElementsByAttribute("type", "checkbox");
+
+    is(checkboxItems.length, 3, "there should be 3 checkbox items in the context menu");
+
+    is(checkboxItems[0].hasAttribute("checked"), expectedStates[0], `checkbox item 1 has state (checked=${expectedStates[0]})`);
+    is(checkboxItems[1].hasAttribute("checked"), expectedStates[1], `checkbox item 2 has state (checked=${expectedStates[1]})`);
+    is(checkboxItems[2].hasAttribute("checked"), expectedStates[2], `checkbox item 3 has state (checked=${expectedStates[2]})`);
+  }
+
+  yield openExtensionMenu();
 
   // Check some menu items
-  let items = contentAreaContextMenu.getElementsByAttribute("ext-type", "top-level-menu");
-  is(items.length, 1, "top level item was found (context=image)");
-  let topItem = items.item(0);
-  let top = topItem.childNodes[0];
-
-  items = top.getElementsByAttribute("label", "image");
+  let top = getTop();
+  let items = top.getElementsByAttribute("label", "image");
   is(items.length, 1, "contextMenu item for image was found (context=image)");
-  let image = items.item(0);
+  let image = items[0];
 
   items = top.getElementsByAttribute("label", "selection-edited");
   is(items.length, 0, "contextMenu item for selection was not found (context=image)");
 
   items = top.getElementsByAttribute("label", "parentToDel");
   is(items.length, 0, "contextMenu item for removed parent was not found (context=image)");
 
   items = top.getElementsByAttribute("label", "parent");
   is(items.length, 1, "contextMenu item for parent was found (context=image)");
 
-  is(items.item(0).childNodes[0].childNodes.length, 2, "child items for parent were found (context=image)");
+  is(items[0].childNodes[0].childNodes.length, 2, "child items for parent were found (context=image)");
 
-  // Click on ext-image item and check the results
-  let popupHiddenPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popuphidden");
-  expectedClickInfo = {
+  // Click on ext-image item and check the click results
+  yield closeContextMenu(image, {
     menuItemId: "ext-image",
     mediaType: "image",
     srcUrl: "http://mochi.test:8888/browser/browser/components/extensions/test/browser/ctxmenu-image.png",
     pageUrl: "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html",
-  };
-  top.openPopup(topItem, "end_before", 0, 0, true, false);
-  EventUtils.synthesizeMouseAtCenter(image, {});
-  let clickInfo = yield extension.awaitMessage("menuItemClick");
-  checkClickInfo(clickInfo);
-  yield popupHiddenPromise;
+  });
+
+  // Test radio groups
+  yield openExtensionMenu();
+  confirmRadioGroupStates([true, false, false]);
+  items = getTop().getElementsByAttribute("type", "radio");
+  yield closeContextMenu(items[1]);
+
+  yield openExtensionMenu();
+  confirmRadioGroupStates([true, true, false]);
+  items = getTop().getElementsByAttribute("type", "radio");
+  yield closeContextMenu(items[2]);
+
+  yield openExtensionMenu();
+  confirmRadioGroupStates([true, false, true]);
+  items = getTop().getElementsByAttribute("type", "radio");
+  yield closeContextMenu(items[0]);
+
+  yield openExtensionMenu();
+  confirmRadioGroupStates([true, false, true]);
+
+  // Test checkboxes
+  items = getTop().getElementsByAttribute("type", "checkbox");
+  confirmCheckboxStates([false, true, false]);
+  yield closeContextMenu(items[0]);
+
+  yield openExtensionMenu();
+  confirmCheckboxStates([true, true, false]);
+  items = getTop().getElementsByAttribute("type", "checkbox");
+  yield closeContextMenu(items[2]);
+
+  yield openExtensionMenu();
+  confirmCheckboxStates([true, true, true]);
+  items = getTop().getElementsByAttribute("type", "checkbox");
+  yield closeContextMenu(items[0]);
+
+  yield openExtensionMenu();
+  confirmCheckboxStates([false, true, true]);
+  items = getTop().getElementsByAttribute("type", "checkbox");
+  yield closeContextMenu(items[2]);
 
   // Select some text
   yield ContentTask.spawn(gBrowser.selectedBrowser, { }, function* (arg) {
     let doc = content.document;
     let range = doc.createRange();
     let selection = content.getSelection();
     selection.removeAllRanges();
     let textNode = doc.getElementById("img1").previousSibling;
     range.setStart(textNode, 0);
     range.setEnd(textNode, 100);
     selection.addRange(range);
   });
 
   // Bring up context menu again
-  popupShownPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popupshown");
-  yield BrowserTestUtils.synthesizeMouse(null, 1, 1,
-    {type: "contextmenu", button: 2}, gBrowser.selectedBrowser);
-  yield popupShownPromise;
-
-  items = contentAreaContextMenu.getElementsByAttribute("ext-type", "top-level-menu");
-  is(items.length, 1, "top level item was found (context=selection)");
-  top = items.item(0).childNodes[0];
+  yield openExtensionMenu();
 
   // Check some menu items
+  top = getTop();
   items = top.getElementsByAttribute("label", "selection is: 'just some text 123456789012345678901234567890...'");
   is(items.length, 1, "contextMenu item for selection was found (context=selection)");
-  let selectionItem = items.item(0);
+  let selectionItem = items[0];
 
   items = top.getElementsByAttribute("label", "selection");
   is(items.length, 0, "contextMenu item label update worked (context=selection)");
 
-  popupHiddenPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popuphidden");
-  expectedClickInfo = {
+  yield closeContextMenu(selectionItem, {
     menuItemId: "ext-selection",
     pageUrl: "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html",
     selectionText: "just some text 1234567890123456789012345678901234567890123456789012345678901234567890123456789012",
-  };
-  top.openPopup(topItem, "end_before", 0, 0, true, false);
-  EventUtils.synthesizeMouseAtCenter(selectionItem, {});
-  clickInfo = yield extension.awaitMessage("menuItemClick");
-  checkClickInfo(clickInfo);
-  yield popupHiddenPromise;
-
-  popupShownPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popupshown");
-  yield BrowserTestUtils.synthesizeMouseAtCenter("#img1",
-    {type: "contextmenu", button: 2}, gBrowser.selectedBrowser);
-  yield popupShownPromise;
+  });
 
   items = contentAreaContextMenu.getElementsByAttribute("ext-type", "top-level-menu");
   is(items.length, 0, "top level item was not found (after removeAll()");
 
   yield extension.unload();
 
   yield BrowserTestUtils.removeTab(tab1);
 });