Bug 1217129: Part 5 - [webext] Use CustomizableUI views for BrowserAction popups. r?gijs ui-r?bwinton draft
authorKris Maglione <maglione.k@gmail.com>
Mon, 11 Jan 2016 12:34:07 -0800
changeset 320569 b65d62cf3d7a519084ccb04e4902450ff0350a84
parent 320568 7e413df414cbbb4af7df9aba1bb5733a687509b8
child 320570 33c48e2b9f772e892305ac841642316b658790c4
push id9233
push usermaglione.k@gmail.com
push dateMon, 11 Jan 2016 20:43:12 +0000
reviewersgijs, bwinton
bugs1217129
milestone46.0a1
Bug 1217129: Part 5 - [webext] Use CustomizableUI views for BrowserAction popups. r?gijs ui-r?bwinton This version addresses some popup sizing bugs, and also a few other issues I ran into when debugging Blake's problems: * The standalone popup needs a max width of 800px for Chrome compatibility, which is wider than our default max width. * I added a flex attribute to our browser so that it fills the entire space of the slide-in panel. This is only necessary for browsers with content that is shorter than the height of the panel when it gets its desired width, but becomes longer when it doesn't, so it didn't show up in my initial tests. * I also added an extra pixel to the width calculations, since I noticed that a lot of single lines of text were unexpectedly wrapping without it. I'll look into this more in a follow-up bug. I also added some comments, and renamed a couple of variables, where things seemed unclear. The test changes are mostly just updates to older browser action tests to use newer helpers, rather than ad-hoc events, to open/close/click the widgets. A few tests also needed updates to explicitly close the panel when they were done with it.
browser/components/extensions/.eslintrc
browser/components/extensions/ext-browserAction.js
browser/components/extensions/ext-pageAction.js
browser/components/extensions/ext-utils.js
browser/components/extensions/test/browser/browser_ext_browserAction_popup.js
browser/components/extensions/test/browser/browser_ext_browserAction_simple.js
browser/components/extensions/test/browser/browser_ext_currentWindow.js
browser/components/extensions/test/browser/browser_ext_getViews.js
browser/components/extensions/test/browser/browser_ext_popup_api_injection.js
browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js
browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js
browser/components/extensions/test/browser/head.js
browser/themes/shared/customizableui/panelUIOverlay.inc.css
--- a/browser/components/extensions/.eslintrc
+++ b/browser/components/extensions/.eslintrc
@@ -1,16 +1,17 @@
 {
   "extends": "../../../toolkit/components/extensions/.eslintrc",
 
   "globals": {
+    "AllWindowEvents": true,
     "currentWindow": true,
     "EventEmitter": true,
     "IconDetails": true,
-    "openPanel": true,
     "makeWidgetId": true,
+    "PanelPopup": true,
     "TabContext": true,
-    "AllWindowEvents": true,
+    "ViewPopup": true,
     "WindowEventManager": true,
     "WindowListManager": true,
     "WindowManager": true,
   },
 }
--- a/browser/components/extensions/ext-browserAction.js
+++ b/browser/components/extensions/ext-browserAction.js
@@ -8,28 +8,31 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 Cu.import("resource://devtools/shared/event-emitter.js");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   EventManager,
   runSafe,
 } = ExtensionUtils;
 
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
 // WeakMap[Extension -> BrowserAction]
 var browserActionMap = new WeakMap();
 
 function browserActionOf(extension) {
   return browserActionMap.get(extension);
 }
 
 // Responsible for the browser_action section of the manifest as well
 // as the associated popup.
 function BrowserAction(options, extension) {
   this.extension = extension;
-  this.id = makeWidgetId(extension.id) + "-browser-action";
+  this.id = `${makeWidgetId(extension.id)}-browser-action`;
+  this.viewId = `PanelUI-webext-${makeWidgetId(extension.id)}-browser-action-view`;
   this.widget = null;
 
   this.tabManager = TabManager.for(extension);
 
   let title = extension.localize(options.default_title || "");
   let popup = extension.localize(options.default_popup || "");
   if (popup) {
     popup = extension.baseURI.resolve(popup);
@@ -50,54 +53,79 @@ function BrowserAction(options, extensio
 
   EventEmitter.decorate(this);
 }
 
 BrowserAction.prototype = {
   build() {
     let widget = CustomizableUI.createWidget({
       id: this.id,
-      type: "custom",
+      viewId: this.viewId,
+      type: "view",
       removable: true,
+      label: this.defaults.title || this.extension.name,
+      tooltiptext: this.defaults.title || "",
       defaultArea: CustomizableUI.AREA_NAVBAR,
-      onBuild: document => {
-        let node = document.createElement("toolbarbutton");
-        node.id = this.id;
-        node.setAttribute("class", "toolbarbutton-1 chromeclass-toolbar-additional badged-button");
+
+      onBeforeCreated: document => {
+        let view = document.createElementNS(XUL_NS, "panelview");
+        view.id = this.viewId;
+        view.setAttribute("flex", "1");
+
+        document.getElementById("PanelUI-multiView").appendChild(view);
+      },
+
+      onDestroyed: document => {
+        let view = document.getElementById(this.viewId);
+        if (view) {
+          view.remove();
+        }
+      },
+
+      onCreated: node => {
+        node.classList.add("badged-button");
         node.setAttribute("constrain-size", "true");
 
         this.updateButton(node, this.defaults);
+      },
 
+      onViewShowing: event => {
+        let document = event.target.ownerDocument;
         let tabbrowser = document.defaultView.gBrowser;
 
-        node.addEventListener("command", event => { // eslint-disable-line mozilla/balanced-listeners
-          let tab = tabbrowser.selectedTab;
-          let popup = this.getProperty(tab, "popup");
-          this.tabManager.addActiveTabPermission(tab);
-          if (popup) {
-            this.togglePopup(node, popup);
-          } else {
-            this.emit("click");
+        let tab = tabbrowser.selectedTab;
+        let popupURL = this.getProperty(tab, "popup");
+        this.tabManager.addActiveTabPermission(tab);
+
+        // If the widget has a popup URL defined, we open a popup, but do not
+        // dispatch a click event to the extension.
+        // If it has no popup URL defined, we dispatch a click event, but do not
+        // open a popup.
+        if (popupURL) {
+          try {
+            new ViewPopup(this.extension, event.target, popupURL);
+          } catch (e) {
+            Cu.reportError(e);
+            event.preventDefault();
           }
-        });
-
-        return node;
+        } else {
+          // This isn't not a hack, but it seems to provide the correct behavior
+          // with the fewest complications.
+          event.preventDefault();
+          this.emit("click");
+        }
       },
     });
 
     this.tabContext.on("tab-select", // eslint-disable-line mozilla/balanced-listeners
                        (evt, tab) => { this.updateWindow(tab.ownerDocument.defaultView); });
 
     this.widget = widget;
   },
 
-  togglePopup(node, popupResource) {
-    openPanel(node, popupResource, this.extension);
-  },
-
   // Update the toolbar button |node| with the tab context data
   // in |tabData|.
   updateButton(node, tabData) {
     let title = tabData.title || this.extension.name;
     node.setAttribute("tooltiptext", title);
     node.setAttribute("label", title);
 
     if (tabData.badgeText) {
--- a/browser/components/extensions/ext-pageAction.js
+++ b/browser/components/extensions/ext-pageAction.js
@@ -133,22 +133,26 @@ PageAction.prototype = {
 
   // Handles a click event on the page action button for the given
   // window.
   // If the page action has a |popup| property, a panel is opened to
   // that URL. Otherwise, a "click" event is emitted, and dispatched to
   // the any click listeners in the add-on.
   handleClick(window) {
     let tab = window.gBrowser.selectedTab;
-    let popup = this.tabContext.get(tab).popup;
+    let popupURL = this.tabContext.get(tab).popup;
 
     this.tabManager.addActiveTabPermission(tab);
 
-    if (popup) {
-      openPanel(this.getButton(window), popup, this.extension);
+    // If the widget has a popup URL defined, we open a popup, but do not
+    // dispatch a click event to the extension.
+    // If it has no popup URL defined, we dispatch a click event, but do not
+    // open a popup.
+    if (popupURL) {
+      new PanelPopup(this.extension, this.getButton(window), popupURL);
     } else {
       this.emit("click", tab);
     }
   },
 
   handleLocationChange(eventType, tab, fromBrowse) {
     if (fromBrowse) {
       this.tabContext.clear(tab);
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -1,18 +1,22 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
+XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
+                                  "resource:///modules/CustomizableUI.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 Cu.import("resource://gre/modules/AddonManager.jsm");
 
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
 const INTEGER = /^[1-9]\d*$/;
 
 var {
   EventManager,
   instanceOf,
 } = ExtensionUtils;
 
 // This file provides some useful code for the |tabs| and |windows|
@@ -121,113 +125,203 @@ global.IconDetails = {
 };
 
 global.makeWidgetId = id => {
   id = id.toLowerCase();
   // FIXME: This allows for collisions.
   return id.replace(/[^a-z0-9_-]/g, "_");
 };
 
-// Open a panel anchored to the given node, containing a browser opened
-// to the given URL, owned by the given extension. If |popupURL| is not
-// an absolute URL, it is resolved relative to the given extension's
-// base URL.
-global.openPanel = (node, popupURL, extension) => {
-  let document = node.ownerDocument;
+class BasePopup {
+  constructor(extension, viewNode, popupURL) {
+    let popupURI = Services.io.newURI(popupURL, null, extension.baseURI);
+
+    Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+      extension.principal, popupURI,
+      Services.scriptSecurityManager.DISALLOW_SCRIPT);
+
+    this.extension = extension;
+    this.popupURI = popupURI;
+    this.viewNode = viewNode;
+    this.window = viewNode.ownerDocument.defaultView;
 
-  let popupURI = Services.io.newURI(popupURL, null, extension.baseURI);
+    this.contentReady = new Promise(resolve => {
+      this._resolveContentReady = resolve;
+    });
+
+    this.viewNode.addEventListener(this.DESTROY_EVENT, this);
 
-  Services.scriptSecurityManager.checkLoadURIWithPrincipal(
-    extension.principal, popupURI,
-    Services.scriptSecurityManager.DISALLOW_SCRIPT);
+    this.browser = null;
+    this.browserReady = this.createBrowser(viewNode, popupURI);
+  }
+
+  destroy() {
+    this.browserReady.then(() => {
+      this.browser.removeEventListener("load", this, true);
+      this.browser.removeEventListener("DOMTitleChanged", this, true);
+      this.browser.removeEventListener("DOMWindowClose", this, true);
+
+      this.viewNode.removeEventListener(this.DESTROY_EVENT, this);
 
-  let panel = document.createElement("panel");
-  panel.setAttribute("id", makeWidgetId(extension.id) + "-panel");
-  panel.setAttribute("class", "browser-extension-panel");
-  panel.setAttribute("type", "arrow");
-  panel.setAttribute("role", "group");
+      this.context.unload();
+      this.browser.remove();
+    });
+  }
+
+  handleEvent(event) {
+    switch (event.type) {
+      case this.DESTROY_EVENT:
+        this.destroy();
+        break;
 
-  let anchor;
-  if (node.localName == "toolbarbutton") {
-    // Toolbar buttons are a special case. The panel becomes a child of
-    // the button, and is anchored to the button's icon.
-    node.appendChild(panel);
-    anchor = document.getAnonymousElementByAttribute(node, "class", "toolbarbutton-icon");
-  } else {
-    // In all other cases, the panel is anchored to the target node
-    // itself, and is a child of a popupset node.
-    document.getElementById("mainPopupSet").appendChild(panel);
-    anchor = node;
+      case "DOMWindowClose":
+        if (event.target === this.browser.contentWindow) {
+          event.preventDefault();
+          this.closePopup();
+        }
+        break;
+
+      case "DOMTitleChanged":
+        this.viewNode.setAttribute("aria-label", this.browser.contentTitle);
+        break;
+
+      case "load":
+        // We use a capturing listener, so we get this event earlier than any
+        // load listeners in the content page. Resizing after a timeout ensures
+        // that we calculate the size after the entire event cycle has completed
+        // (unless someone spins the event loop, anyway), and hopefully after
+        // the content has made any modifications.
+        //
+        // In the future, to match Chrome's behavior, we'll need to update this
+        // dynamically, probably in response to MozScrolledAreaChanged events.
+        this.window.setTimeout(() => this.resizeBrowser(), 0);
+        break;
+    }
   }
 
-  const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
-  let browser = document.createElementNS(XUL_NS, "browser");
-  browser.setAttribute("type", "content");
-  browser.setAttribute("disableglobalhistory", "true");
-  panel.appendChild(browser);
+  createBrowser(viewNode, popupURI) {
+    let document = viewNode.ownerDocument;
+
+    this.browser = document.createElementNS(XUL_NS, "browser");
+    this.browser.setAttribute("type", "content");
+    this.browser.setAttribute("disableglobalhistory", "true");
+
+    // Note: When using noautohide panels, the popup manager will add width and
+    // height attributes to the panel, breaking our resize code, if the browser
+    // starts out smaller than 30px by 10px. This isn't an issue now, but it
+    // will be if and when we popup debugging.
 
-  let titleChangedListener = () => {
-    panel.setAttribute("aria-label", browser.contentTitle);
-  };
+    // This overrides the content's preferred size when displayed in a
+    // fixed-size, slide-in panel.
+    this.browser.setAttribute("flex", "1");
+
+    viewNode.appendChild(this.browser);
+
+    return new Promise(resolve => {
+      // The first load event is for about:blank.
+      // We can't finish setting up the browser until the binding has fully
+      // initialized. Waiting for the first load event guarantees that it has.
+      let loadListener = event => {
+        this.browser.removeEventListener("load", loadListener, true);
+        resolve();
+      };
+      this.browser.addEventListener("load", loadListener, true);
+    }).then(() => {
+      let { contentWindow } = this.browser;
 
-  let context;
-  let popuphidden = () => {
-    panel.removeEventListener("popuphidden", popuphidden);
-    browser.removeEventListener("DOMTitleChanged", titleChangedListener, true);
-    context.unload();
-    panel.remove();
-  };
-  panel.addEventListener("popuphidden", popuphidden);
+      contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+                   .getInterface(Ci.nsIDOMWindowUtils)
+                   .allowScriptsToClose();
+
+      this.context = new ExtensionPage(this.extension, {
+        type: "popup",
+        contentWindow,
+        uri: popupURI,
+        docShell: this.browser.docShell,
+      });
+
+      GlobalManager.injectInDocShell(this.browser.docShell, this.extension, this.context);
+      this.browser.setAttribute("src", this.context.uri.spec);
 
-  let loadListener = () => {
-    panel.removeEventListener("load", loadListener);
+      this.browser.addEventListener("load", this, true);
+      this.browser.addEventListener("DOMTitleChanged", this, true);
+      this.browser.addEventListener("DOMWindowClose", this, true);
+    });
+  }
 
-    context = new ExtensionPage(extension, {
-      type: "popup",
-      contentWindow: browser.contentWindow,
-      uri: popupURI,
-      docShell: browser.docShell,
-    });
-    GlobalManager.injectInDocShell(browser.docShell, extension, context);
-    browser.setAttribute("src", context.uri.spec);
+  // Resizes the browser to match the preferred size of the content.
+  resizeBrowser() {
+    let width, height;
+    try {
+      let w = {}, h = {};
+      this.browser.docShell.contentViewer.getContentSize(w, h);
+
+      width = w.value / this.window.devicePixelRatio;
+      height = h.value / this.window.devicePixelRatio;
 
-    let contentLoadListener = event => {
-      if (event.target != browser.contentDocument) {
-        return;
-      }
-      browser.removeEventListener("load", contentLoadListener, true);
+      // The width calculation is imperfect, and is often a fraction of a pixel
+      // too narrow, even after taking the ceiling, which causes lines of text
+      // to wrap.
+      width += 1;
+    } catch (e) {
+      // getContentSize can throw
+      [width, height] = [400, 400];
+    }
+
+    width = Math.ceil(Math.min(width, 800));
+    height = Math.ceil(Math.min(height, 600));
 
-      let contentViewer = browser.docShell.contentViewer;
-      let width = {}, height = {};
-      try {
-        contentViewer.getContentSize(width, height);
-        [width, height] = [width.value, height.value];
-      } catch (e) {
-        // getContentSize can throw
-        [width, height] = [400, 400];
-      }
+    this.browser.style.width = `${width}px`;
+    this.browser.style.height = `${height}px`;
+
+    this._resolveContentReady();
+  }
+}
+
+global.PanelPopup = class PanelPopup extends BasePopup {
+  constructor(extension, button, popupURL) {
+    let document = button.ownerDocument;
+
+    let panel = document.createElement("panel");
+    panel.setAttribute("id", makeWidgetId(extension.id) + "-panel");
+    panel.setAttribute("class", "browser-extension-panel");
+    panel.setAttribute("type", "arrow");
+    panel.setAttribute("role", "group");
 
-      let window = document.defaultView;
-      width /= window.devicePixelRatio;
-      height /= window.devicePixelRatio;
-      width = Math.min(width, 800);
-      height = Math.min(height, 800);
+    document.getElementById("mainPopupSet").appendChild(panel);
+
+    super(extension, panel, popupURL);
 
-      browser.setAttribute("width", width);
-      browser.setAttribute("height", height);
+    this.contentReady.then(() => {
+      panel.openPopup(button, "bottomcenter topright", 0, 0, false, false);
+    });
+  }
+
+  get DESTROY_EVENT() {
+    return "popuphidden";
+  }
 
-      panel.openPopup(anchor, "bottomcenter topright", 0, 0, false, false);
-    };
-    browser.addEventListener("load", contentLoadListener, true);
+  destroy() {
+    super.destroy();
+    this.viewNode.remove();
+  }
 
-    browser.addEventListener("DOMTitleChanged", titleChangedListener, true);
-  };
-  panel.addEventListener("load", loadListener);
+  closePopup() {
+    this.viewNode.hidePopup();
+  }
+};
 
-  return panel;
+global.ViewPopup = class ViewPopup extends BasePopup {
+  get DESTROY_EVENT() {
+    return "ViewHiding";
+  }
+
+  closePopup() {
+    CustomizableUI.hidePanelForNode(this.viewNode);
+  }
 };
 
 // Manages tab-specific context data, and dispatching tab select events
 // across all windows.
 global.TabContext = function TabContext(getDefaults, extension) {
   this.extension = extension;
   this.getDefaults = getDefaults;
 
--- a/browser/components/extensions/test/browser/browser_ext_browserAction_popup.js
+++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup.js
@@ -1,15 +1,15 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 function promisePopupShown(popup) {
   return new Promise(resolve => {
-    if (popup.popupOpen) {
+    if (popup.state == "open") {
       resolve();
     } else {
       let onPopupShown = event => {
         popup.removeEventListener("popupshown", onPopupShown);
         resolve();
       };
       popup.addEventListener("popupshown", onPopupShown);
     }
@@ -111,35 +111,35 @@ add_task(function* testPageActionPopup()
           }
         });
 
         browser.test.sendMessage("next-test");
       },
     },
   });
 
-  let panelId = makeWidgetId(extension.id) + "-panel";
+  let viewId = `PanelUI-webext-${makeWidgetId(extension.id)}-browser-action-view`;
 
   extension.onMessage("send-click", () => {
     clickBrowserAction(extension);
   });
 
   extension.onMessage("next-test", Task.async(function* () {
-    let panel = document.getElementById(panelId);
+    let panel = getBrowserActionPopup(extension);
     if (panel) {
       yield promisePopupShown(panel);
       panel.hidePopup();
 
-      panel = document.getElementById(panelId);
+      panel = getBrowserActionPopup(extension);
       is(panel, null, "panel successfully removed from document after hiding");
     }
 
     extension.sendMessage("next-test");
   }));
 
 
   yield Promise.all([extension.startup(), extension.awaitFinish("browseraction-tests-done")]);
 
   yield extension.unload();
 
-  let panel = document.getElementById(panelId);
-  is(panel, null, "browserAction panel removed from document");
+  let view = document.getElementById(viewId);
+  is(view, null, "browserAction view removed from document");
 });
--- a/browser/components/extensions/test/browser/browser_ext_browserAction_simple.js
+++ b/browser/components/extensions/test/browser/browser_ext_browserAction_simple.js
@@ -28,29 +28,19 @@ add_task(function* () {
         browser.test.assertEq(msg, "from-popup", "correct message received");
         browser.test.sendMessage("popup");
       });
     },
   });
 
   yield extension.startup();
 
-  let widgetId = makeWidgetId(extension.id) + "-browser-action";
-  let node = CustomizableUI.getWidget(widgetId).forWindow(window).node;
-
   // Do this a few times to make sure the pop-up is reloaded each time.
   for (let i = 0; i < 3; i++) {
-    let evt = new CustomEvent("command", {
-      bubbles: true,
-      cancelable: true,
-    });
-    node.dispatchEvent(evt);
+    clickBrowserAction(extension);
 
     yield extension.awaitMessage("popup");
 
-    let panel = node.querySelector("panel");
-    if (panel) {
-      panel.hidePopup();
-    }
+    closeBrowserAction(extension);
   }
 
   yield extension.unload();
 });
--- a/browser/components/extensions/test/browser/browser_ext_currentWindow.js
+++ b/browser/components/extensions/test/browser/browser_ext_currentWindow.js
@@ -105,30 +105,23 @@ add_task(function* () {
   }
 
   yield focusWindow(win1);
   yield checkWindow("background", winId1, "win1");
   yield focusWindow(win2);
   yield checkWindow("background", winId2, "win2");
 
   function* triggerPopup(win, callback) {
-    let widgetId = makeWidgetId(extension.id) + "-browser-action";
-    let node = CustomizableUI.getWidget(widgetId).forWindow(win).node;
-
-    let evt = new CustomEvent("command", {
-      bubbles: true,
-      cancelable: true,
-    });
-    node.dispatchEvent(evt);
+    yield clickBrowserAction(extension, win);
 
     yield extension.awaitMessage("popup-ready");
 
     yield callback();
 
-    let panel = node.querySelector("panel");
+    let panel = getBrowserActionPopup(extension, win);
     if (panel) {
       panel.hidePopup();
     }
   }
 
   // Set focus to some other window.
   yield focusWindow(window);
 
--- a/browser/components/extensions/test/browser/browser_ext_getViews.js
+++ b/browser/components/extensions/test/browser/browser_ext_getViews.js
@@ -111,35 +111,34 @@ add_task(function* () {
   yield checkViews("background", 1, 0);
   yield checkViews("tab", 1, 0);
 
   yield openTab(winId2);
 
   yield checkViews("background", 2, 0);
 
   function* triggerPopup(win, callback) {
-    let widgetId = makeWidgetId(extension.id) + "-browser-action";
-    let node = CustomizableUI.getWidget(widgetId).forWindow(win).node;
-
-    let evt = new CustomEvent("command", {
-      bubbles: true,
-      cancelable: true,
-    });
-    node.dispatchEvent(evt);
+    yield clickBrowserAction(extension, win);
 
     yield extension.awaitMessage("popup-ready");
 
     yield callback();
 
-    let panel = node.querySelector("panel");
+    let panel = getBrowserActionPopup(extension, win);
     if (panel) {
       panel.hidePopup();
     }
   }
 
+  // The popup occasionally closes prematurely if we open it immediately here.
+  // I'm not sure what causes it to close (it's something internal, and seems to
+  // be focus-related, but it's not caused by JS calling hidePopup), but even a
+  // short timeout seems to consistently fix it.
+  yield new Promise(resolve => win1.setTimeout(resolve, 10));
+
   yield triggerPopup(win1, function*() {
     yield checkViews("background", 2, 1);
     yield checkViews("popup", 2, 1);
   });
 
   yield triggerPopup(win2, function*() {
     yield checkViews("background", 2, 1);
     yield checkViews("popup", 2, 1);
--- a/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js
+++ b/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js
@@ -37,29 +37,16 @@ add_task(function* testPageActionPopup()
         browser.browserAction.setPopup({ popup: "/popup-a.html" });
         browser.pageAction.setPopup({ tabId, popup: "popup-b.html" });
 
         browser.test.sendMessage("ok");
       });
     },
   });
 
-  let browserActionId = makeWidgetId(extension.id) + "-browser-action";
-  let pageActionId = makeWidgetId(extension.id) + "-page-action";
-
-  function openPopup(buttonId) {
-    let button = document.getElementById(buttonId);
-    if (buttonId == pageActionId) {
-      // TODO: I don't know why a proper synthesized event doesn't work here.
-      button.dispatchEvent(new MouseEvent("click", {}));
-    } else {
-      EventUtils.synthesizeMouseAtCenter(button, {}, window);
-    }
-  }
-
   let promiseConsoleMessage = pattern => new Promise(resolve => {
     Services.console.registerListener(function listener(msg) {
       if (pattern.test(msg.message)) {
         resolve(msg.message);
         Services.console.unregisterListener(listener);
       }
     });
   });
@@ -67,40 +54,45 @@ add_task(function* testPageActionPopup()
   yield extension.startup();
   yield extension.awaitMessage("ready");
 
 
   // Check that unprivileged documents don't get the API.
   // BrowserAction:
   let awaitMessage = promiseConsoleMessage(/WebExt Privilege Escalation: BrowserAction/);
   SimpleTest.expectUncaughtException();
-  openPopup(browserActionId);
+  yield clickBrowserAction(extension);
 
   let message = yield awaitMessage;
   ok(message.includes("WebExt Privilege Escalation: BrowserAction: typeof(browser) = undefined"),
      `No BrowserAction API injection`);
 
+  yield closeBrowserAction(extension);
+
   // PageAction
   awaitMessage = promiseConsoleMessage(/WebExt Privilege Escalation: PageAction/);
   SimpleTest.expectUncaughtException();
-  openPopup(pageActionId);
+  yield clickPageAction(extension);
 
   message = yield awaitMessage;
   ok(message.includes("WebExt Privilege Escalation: PageAction: typeof(browser) = undefined"),
      `No PageAction API injection: ${message}`);
 
+  yield closePageAction(extension);
+
   SimpleTest.expectUncaughtException(false);
 
 
   // Check that privileged documents *do* get the API.
   extension.sendMessage("next");
   yield extension.awaitMessage("ok");
 
 
-  // Check that unprivileged documents don't get the API.
-  openPopup(browserActionId);
+  yield clickBrowserAction(extension);
   yield extension.awaitMessage("from-popup-a");
+  yield closeBrowserAction(extension);
 
-  openPopup(pageActionId);
+  yield clickPageAction(extension);
   yield extension.awaitMessage("from-popup-b");
+  yield closePageAction(extension);
 
   yield extension.unload();
 });
--- a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js
@@ -43,16 +43,21 @@ function* testHasPermission(params) {
 
   if (params.setup) {
     yield params.setup(extension);
   }
 
   extension.sendMessage("execute-script");
 
   yield extension.awaitFinish("executeScript");
+
+  if (params.tearDown) {
+    yield params.tearDown(extension);
+  }
+
   yield extension.unload();
 }
 
 add_task(function* testGoodPermissions() {
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/", true);
 
   info("Test explicit host permission");
   yield testHasPermission({
@@ -77,16 +82,17 @@ add_task(function* testGoodPermissions()
     },
     contentSetup() {
       browser.browserAction.onClicked.addListener(() => {
         browser.test.log("Clicked.");
       });
       return Promise.resolve();
     },
     setup: clickBrowserAction,
+    tearDown: closeBrowserAction,
   });
 
   info("Test activeTab permission with a page action click");
   yield testHasPermission({
     manifest: {
       "permissions": ["activeTab"],
       "page_action": {},
     },
@@ -94,25 +100,27 @@ add_task(function* testGoodPermissions()
       return new Promise(resolve => {
         browser.tabs.query({ active: true, currentWindow: true }, tabs => {
           browser.pageAction.show(tabs[0].id);
           resolve();
         });
       });
     },
     setup: clickPageAction,
+    tearDown: closePageAction,
   });
 
   info("Test activeTab permission with a browser action w/popup click");
   yield testHasPermission({
     manifest: {
       "permissions": ["activeTab"],
       "browser_action": { "default_popup": "_blank.html" },
     },
     setup: clickBrowserAction,
+    tearDown: closeBrowserAction,
   });
 
   info("Test activeTab permission with a page action w/popup click");
   yield testHasPermission({
     manifest: {
       "permissions": ["activeTab"],
       "page_action": { "default_popup": "_blank.html" },
     },
@@ -120,16 +128,17 @@ add_task(function* testGoodPermissions()
       return new Promise(resolve => {
         browser.tabs.query({ active: true, currentWindow: true }, tabs => {
           browser.pageAction.show(tabs[0].id);
           resolve();
         });
       });
     },
     setup: clickPageAction,
+    tearDown: closePageAction,
   });
 
   info("Test activeTab permission with a context menu click");
   yield testHasPermission({
     manifest: {
       "permissions": ["activeTab", "contextMenus"],
     },
     contentSetup() {
--- a/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js
@@ -58,11 +58,12 @@ add_task(function* () {
 
   yield extension.startup();
 
   yield extension.awaitMessage("background-finished");
   yield extension.awaitMessage("tab-finished");
 
   clickBrowserAction(extension);
   yield extension.awaitMessage("popup-finished");
+  yield closeBrowserAction(extension);
 
   yield extension.unload();
 });
--- a/browser/components/extensions/test/browser/head.js
+++ b/browser/components/extensions/test/browser/head.js
@@ -1,13 +1,17 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
-/* exported CustomizableUI makeWidgetId focusWindow clickBrowserAction clickPageAction */
+/* exported CustomizableUI makeWidgetId focusWindow
+ *          clickBrowserAction clickPageAction
+ *          getBrowserActionPopup getPageActionPopup
+ *          closeBrowserAction closePageAction
+ */
 
 var {CustomizableUI} = Cu.import("resource:///modules/CustomizableUI.jsm");
 
 function makeWidgetId(id) {
   id = id.toLowerCase();
   return id.replace(/[^a-z0-9_-]/g, "_");
 }
 
@@ -23,32 +27,60 @@ var focusWindow = Task.async(function* f
       resolve();
     }, true);
   });
 
   win.focus();
   yield promise;
 });
 
+function getBrowserActionPopup(extension, win = window) {
+  return win.document.getElementById("customizationui-widget-panel");
+}
+
 function clickBrowserAction(extension, win = window) {
   let browserActionId = makeWidgetId(extension.id) + "-browser-action";
   let elem = win.document.getElementById(browserActionId);
 
   EventUtils.synthesizeMouseAtCenter(elem, {}, win);
   return new Promise(SimpleTest.executeSoon);
 }
 
+function closeBrowserAction(extension, win = window) {
+  let node = getBrowserActionPopup(extension, win);
+  if (node) {
+    node.hidePopup();
+  }
+
+  return Promise.resolve();
+}
+
+function getPageActionPopup(extension, win = window) {
+  let panelId = makeWidgetId(extension.id) + "-panel";
+  return win.document.getElementById(panelId);
+}
+
 function clickPageAction(extension, win = window) {
   // This would normally be set automatically on navigation, and cleared
   // when the user types a value into the URL bar, to show and hide page
   // identity info and icons such as page action buttons.
   //
   // Unfortunately, that doesn't happen automatically in browser chrome
   // tests.
   /* globals SetPageProxyState */
   SetPageProxyState("valid");
 
   let pageActionId = makeWidgetId(extension.id) + "-page-action";
   let elem = win.document.getElementById(pageActionId);
 
   EventUtils.synthesizeMouseAtCenter(elem, {}, win);
   return new Promise(SimpleTest.executeSoon);
 }
+
+function closePageAction(extension, win = window) {
+  let node = getPageActionPopup(extension, win);
+  if (node) {
+    node.hidePopup();
+  }
+
+  return Promise.resolve();
+}
+
--- a/browser/themes/shared/customizableui/panelUIOverlay.inc.css
+++ b/browser/themes/shared/customizableui/panelUIOverlay.inc.css
@@ -245,16 +245,21 @@ panelmultiview[nosubviews=true] > .panel
   max-width: @menuPanelWidth@;
 }
 
 #BMB_bookmarksPopup,
 .panel-mainview:not([panelid="PanelUI-popup"]) {
   max-width: @standaloneSubviewWidth@;
 }
 
+/* Give WebExtension stand-alone panels extra width for Chrome compatibility */
+.cui-widget-panel[viewId^=PanelUI-webext-] .panel-mainview {
+  max-width: 800px;
+}
+
 panelview:not([mainview]) .toolbarbutton-text,
 .cui-widget-panel toolbarbutton > .toolbarbutton-text {
   text-align: start;
   display: -moz-box;
 }
 
 .cui-widget-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 4px 0;