Bug 1374477 - Add a new test for Photon page actions, along with some related code changes. r?Gijs draft
authorDrew Willcoxon <adw@mozilla.com>
Sat, 29 Jul 2017 20:25:14 -0700
changeset 618106 e0d653659b1464954174f266ea409e5c7c86115f
parent 618105 de637e1b0dc361c1590cca0a051cf1adcf4784a7
child 639968 63cda47ff2086ae3db182932730817d8c250f471
push id71221
push userdwillcoxon@mozilla.com
push dateSun, 30 Jul 2017 03:26:37 +0000
reviewersGijs
bugs1374477
milestone56.0a1
Bug 1374477 - Add a new test for Photon page actions, along with some related code changes. r?Gijs MozReview-Commit-ID: ADaEnEiGFvX
browser/base/content/browser-pageActions.js
browser/base/content/browser.xul
browser/components/customizableui/PanelMultiView.jsm
browser/modules/PageActions.jsm
browser/modules/test/browser/browser.ini
browser/modules/test/browser/browser_PageActions.js
browser/themes/shared/urlbar-searchbar.inc.css
--- a/browser/base/content/browser-pageActions.js
+++ b/browser/base/content/browser-pageActions.js
@@ -119,16 +119,17 @@ var BrowserPageActions = {
       for (let name in action.nodeAttributes) {
         buttonNode.setAttribute(name, action.nodeAttributes[name]);
       }
     }
     let panelViewNode = null;
     if (action.subview) {
       buttonNode.classList.add("subviewbutton-nav");
       panelViewNode = this._makePanelViewNodeForAction(action, false);
+      this.multiViewNode._panelViews = null;
       this.multiViewNode.appendChild(panelViewNode);
     }
     buttonNode.addEventListener("command", event => {
       if (panelViewNode) {
         action.subview.onShowing(panelViewNode);
         this.multiViewNode.showSubView(panelViewNode, buttonNode);
         return;
       }
@@ -139,30 +140,26 @@ var BrowserPageActions = {
       this.panelNode.hidePopup();
       action.onCommand(event, buttonNode);
     });
     return [buttonNode, panelViewNode];
   },
 
   _makePanelViewNodeForAction(action, forUrlbar) {
     let panelViewNode = document.createElement("panelview");
-    let placementID = forUrlbar ? "urlbar" : "panel";
-    panelViewNode.id = `pageAction-${placementID}-${action.id}-subview`;
+    panelViewNode.id = this._panelViewNodeIDForActionID(action.id, forUrlbar);
     panelViewNode.classList.add("PanelUI-subView");
     let bodyNode = document.createElement("vbox");
     bodyNode.id = panelViewNode.id + "-body";
     bodyNode.classList.add("panel-subview-body");
     panelViewNode.appendChild(bodyNode);
     for (let button of action.subview.buttons) {
       let buttonNode = document.createElement("toolbarbutton");
-      let buttonNodeID =
-        forUrlbar ? this._urlbarButtonNodeIDForActionID(action.id) :
-        this._panelButtonNodeIDForActionID(action.id);
-      buttonNodeID += "-" + button.id;
-      buttonNode.id = buttonNodeID;
+      buttonNode.id =
+        this._panelViewButtonNodeIDForActionID(action.id, button.id, forUrlbar);
       buttonNode.classList.add("subviewbutton", "subviewbutton-iconic");
       buttonNode.setAttribute("label", button.title);
       if (button.shortcut) {
         buttonNode.setAttribute("shortcut", button.shortcut);
       }
       if (button.disabled) {
         buttonNode.setAttribute("disabled", "true");
       }
@@ -170,30 +167,31 @@ var BrowserPageActions = {
         button.onCommand(event, buttonNode);
       });
       bodyNode.appendChild(buttonNode);
     }
     return panelViewNode;
   },
 
   _toggleTempPanelForAction(action) {
-    let panelNodeID = "pageActionTempPanel";
+    let panelNodeID = this._tempPanelID;
     let panelNode = document.getElementById(panelNodeID);
     if (panelNode) {
       panelNode.hidePopup();
       return;
     }
 
     panelNode = document.createElement("panel");
     panelNode.id = panelNodeID;
     panelNode.classList.add("cui-widget-panel");
     panelNode.setAttribute("role", "group");
     panelNode.setAttribute("type", "arrow");
     panelNode.setAttribute("flip", "slide");
     panelNode.setAttribute("noautofocus", "true");
+    panelNode.setAttribute("tabspecific", "true");
 
     let panelViewNode = null;
     let iframeNode = null;
 
     if (action.subview) {
       let multiViewNode = document.createElement("photonpanelmultiview");
       panelViewNode = this._makePanelViewNodeForAction(action, true);
       multiViewNode.appendChild(panelViewNode);
@@ -222,16 +220,20 @@ var BrowserPageActions = {
       document.getElementById(urlbarNodeID) || this.mainButtonNode;
     panelNode.openPopup(anchorNode, "bottomcenter topright");
 
     if (iframeNode) {
       action.onIframeShown(iframeNode, panelNode);
     }
   },
 
+  get _tempPanelID() {
+    return "pageActionTempPanel";
+  },
+
   /**
    * Adds or removes as necessary a DOM node for the given action in the urlbar.
    *
    * @param  action (PageActions.Action, required)
    *         The action to place.
    * @param  insertBeforeID (string, required)
    *         If the action is shown in the urlbar, then this is ID of the action
    *         in the urlbar before which the given action should be inserted.
@@ -334,22 +336,34 @@ var BrowserPageActions = {
 
   _removeActionFromPanel(action) {
     let id = this._panelButtonNodeIDForActionID(action.id);
     let node = document.getElementById(id);
     if (node) {
       node.remove();
     }
     if (action.subview) {
-      let panelViewNodeID = this._panelViewNodeIDFromActionID(action.id);
+      let panelViewNodeID = this._panelViewNodeIDForActionID(action.id, false);
       let panelViewNode = document.getElementById(panelViewNodeID);
       if (panelViewNode) {
         panelViewNode.remove();
       }
     }
+    // If there are now no more non-built-in actions, remove the separator
+    // between the built-ins and non-built-ins.
+    if (!PageActions.nonBuiltInActions.length) {
+      let separator = document.getElementById(
+        this._panelButtonNodeIDForActionID(
+          PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+        )
+      );
+      if (separator) {
+        separator.remove();
+      }
+    }
   },
 
   _removeActionFromUrlbar(action) {
     let id = this._urlbarButtonNodeIDForActionID(action.id);
     let node = document.getElementById(id);
     if (node) {
       node.remove();
     }
@@ -404,51 +418,73 @@ var BrowserPageActions = {
   actionForNode(node) {
     if (!node) {
       return null;
     }
     let actionID = this._actionIDForNodeID(node.id);
     return PageActions.actionForID(actionID);
   },
 
+  // The ID of the given action's top-level button in the panel.
   _panelButtonNodeIDForActionID(actionID) {
-    return "pageAction-panel-" + actionID;
+    return `pageAction-panel-${actionID}`;
   },
 
+  // The ID of the given action's button in the urlbar.
   _urlbarButtonNodeIDForActionID(actionID) {
     let action = PageActions.actionForID(actionID);
     if (action && action.urlbarIDOverride) {
       return action.urlbarIDOverride;
     }
-    return "pageAction-urlbar-" + actionID;
+    return `pageAction-urlbar-${actionID}`;
+  },
+
+  // The ID of the given action's panelview.
+  _panelViewNodeIDForActionID(actionID, forUrlbar) {
+    let placementID = forUrlbar ? "urlbar" : "panel";
+    return `pageAction-${placementID}-${actionID}-subview`;
   },
 
+  // The ID of the given button in the given action's panelview.
+  _panelViewButtonNodeIDForActionID(actionID, buttonID, forUrlbar) {
+    let placementID = forUrlbar ? "urlbar" : "panel";
+    return `pageAction-${placementID}-${actionID}-${buttonID}`;
+  },
+
+  // The ID of the action corresponding to the given top-level button in the
+  // panel or button in the urlbar.
   _actionIDForNodeID(nodeID) {
     if (!nodeID) {
       return null;
     }
     let match = nodeID.match(/^pageAction-(?:panel|urlbar)-(.+)$/);
     return match ? match[1] : null;
   },
 
   /**
    * Call this when the main page action button in the urlbar is activated.
    *
    * @param  event (DOM event, required)
    *         The click or whatever event.
    */
   mainButtonClicked(event) {
     event.stopPropagation();
-
     if ((event.type == "click" && event.button != 0) ||
         (event.type == "keypress" && event.charCode != KeyEvent.DOM_VK_SPACE &&
          event.keyCode != KeyEvent.DOM_VK_RETURN)) {
       return;
     }
 
+    // If the temp panel is open and anchored to the main button, close it.
+    let tempPanel = document.getElementById(this._tempPanelID);
+    if (tempPanel && tempPanel.anchorNode.id == this.mainButtonNode.id) {
+      tempPanel.hidePopup();
+      return;
+    }
+
     for (let action of PageActions.actions) {
       let buttonNodeID = this._panelButtonNodeIDForActionID(action.id);
       let buttonNode = document.getElementById(buttonNodeID);
       action.onShowingInPanel(buttonNode);
     }
 
     this.panelNode.hidden = false;
     this.panelNode.openPopup(this.mainButtonNode, {
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -896,17 +896,17 @@
                        onclick="FullZoom.reset();"
                        tooltip="dynamic-shortcut-tooltip"
                        hidden="true"/>
 #ifdef MOZ_PHOTON_THEME
                 <image id="pageActionButton"
                        class="urlbar-icon"
                        tooltiptext="&pageActionButton.tooltip;"
                        onclick="BrowserPageActions.mainButtonClicked(event);"/>
-                <hbox id="star-button-box">
+                <hbox id="star-button-box" hidden="true">
                   <image id="star-button"
                          class="urlbar-icon"
                          onclick="BookmarkingUI.onStarCommand(event);">
                     <observes element="bookmarkThisPageBroadcaster" attribute="starred"/>
                     <observes element="bookmarkThisPageBroadcaster" attribute="tooltiptext"/>
                   </image>
                   <hbox id="star-button-animatable-box">
                     <image id="star-button-animatable-image"
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -287,17 +287,18 @@ this.PanelMultiView = class {
         this.setMainView(this._mainView);
       }
     }
 
     this.node.setAttribute("viewtype", "main");
 
     // Proxy these public properties and methods, as used elsewhere by various
     // parts of the browser, to this instance.
-    ["_mainView", "ignoreMutations", "showingSubView"].forEach(property => {
+    ["_mainView", "ignoreMutations", "showingSubView",
+     "_panelViews"].forEach(property => {
       Object.defineProperty(this.node, property, {
         enumerable: true,
         get: () => this[property],
         set: (val) => this[property] = val
       });
     });
     ["goBack", "descriptionHeightWorkaround", "setMainView", "showMainView",
      "showSubView"].forEach(method => {
--- a/browser/modules/PageActions.jsm
+++ b/browser/modules/PageActions.jsm
@@ -5,89 +5,96 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = [
   "PageActions",
   // PageActions.Action
   // PageActions.Button
   // PageActions.Subview
   // PageActions.ACTION_ID_BOOKMARK_SEPARATOR
+  // PageActions.ACTION_ID_BUILT_IN_SEPARATOR
 ];
 
 const { utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "BinarySearch",
   "resource://gre/modules/BinarySearch.jsm");
 
 
 const ACTION_ID_BOOKMARK_SEPARATOR = "bookmarkSeparator";
 const ACTION_ID_BUILT_IN_SEPARATOR = "builtInSeparator";
 
-const PREF_ACTION_IDS_IN_URLBAR = "browser.pageActions.actionIDsInUrlbar";
+const PREF_PERSISTED_ACTIONS = "browser.pageActions.persistedActions";
 
 
 this.PageActions = {
   /**
    * Inits.  Call to init.
    */
   init() {
     let callbacks = this._deferredAddActionCalls;
     delete this._deferredAddActionCalls;
 
-    let actionIDsInUrlbar = this._loadActionIDsInUrlbar();
-    this._actionIDsInUrlbar = actionIDsInUrlbar || [];
+    this._loadPersistedActions();
 
     // Add the built-in actions, which are defined below in this file.
     for (let options of gBuiltInActions) {
       if (options._isSeparator || !this.actionForID(options.id)) {
         this.addAction(new Action(options));
       }
     }
 
     // These callbacks are deferred until init happens and all built-in actions
     // are added.
     while (callbacks && callbacks.length) {
       callbacks.shift()();
     }
-
-    if (!actionIDsInUrlbar) {
-      // The action IDs in the urlbar haven't been stored yet.  Compute them and
-      // store them now.  From now on, these stored IDs will determine the
-      // actions that are shown in the urlbar and the order they're shown in.
-      this._actionIDsInUrlbar =
-        this.actions.filter(a => a.shownInUrlbar).map(a => a.id);
-      this._storeActionIDsInUrlbar();
-    }
   },
 
   _deferredAddActionCalls: [],
 
   /**
    * The list of Action objects, sorted in the order in which they should be
-   * placed in the page action panel.  Not live.  (array of Action objects)
+   * placed in the page action panel.  If there are both built-in and non-built-
+   * in actions, then the list will include the separator between the two.  The
+   * list is not live.  (array of Action objects)
    */
   get actions() {
-    let actions = this._builtInActions.slice();
-    if (this._nonBuiltInActions.length) {
+    let actions = this.builtInActions;
+    if (this.nonBuiltInActions.length) {
       // There are non-built-in actions, so include them too.  Add a separator
       // between the built-ins and non-built-ins so that the returned array
       // looks like: [...built-ins, separator, ...non-built-ins]
       actions.push(new Action({
         id: ACTION_ID_BUILT_IN_SEPARATOR,
         _isSeparator: true,
       }));
-      actions.push(...this._nonBuiltInActions);
+      actions.push(...this.nonBuiltInActions);
     }
     return actions;
   },
 
   /**
+   * The list of built-in actions.  Not live.  (array of Action objects)
+   */
+  get builtInActions() {
+    return this._builtInActions.slice();
+  },
+
+  /**
+   * The list of non-built-in actions.  Not live.  (array of Action objects)
+   */
+  get nonBuiltInActions() {
+    return this._nonBuiltInActions.slice();
+  },
+
+  /**
    * Gets an action.
    *
    * @param  id (string, required)
    *         The ID of the action to get.
    * @return The Action object, or null if none.
    */
   actionForID(id) {
     return this._actionsByID.get(id);
@@ -174,21 +181,33 @@ this.PageActions = {
         // If this is the first non-built-in, then the built-in separator must
         // be placed between the built-ins and non-built-ins.
         if (!this._nonBuiltInActions.length) {
           placeBuiltInSeparator = true;
         }
         this._nonBuiltInActions.splice(index, 0, action);
       }
 
-      if (this._actionIDsInUrlbar.includes(action.id)) {
-        // The action should be shown in the urlbar.  Set its shownInUrlbar to
-        // true, but set the private version so that onActionToggledShownInUrlbar
-        // isn't called, which happens when the public version is set.
-        action._shownInUrlbar = true;
+      if (this._persistedActions.ids[action.id]) {
+        // The action has been seen before.  Override its shownInUrlbar value
+        // with the persisted value.  Set the private version of that property
+        // so that onActionToggledShownInUrlbar isn't called, which happens when
+        // the public version is set.
+        action._shownInUrlbar =
+          this._persistedActions.idsInUrlbar.includes(action.id);
+      } else {
+        // The action is new.  Store it in the persisted actions.
+        this._persistedActions.ids[action.id] = true;
+        if (action.shownInUrlbar) {
+          this._persistedActions.idsInUrlbar.push(action.id);
+        }
+        this._storePersistedActions();
+      }
+
+      if (action.shownInUrlbar) {
         urlbarInsertBeforeID = this.insertBeforeActionIDInUrlbar(action);
       }
     }
 
     for (let win of browserWindows()) {
       if (placeBuiltInSeparator) {
         let sep = new Action({
           id: ACTION_ID_BUILT_IN_SEPARATOR,
@@ -213,25 +232,26 @@ this.PageActions = {
    * the given action's shownInUrlbar is true or false.
    *
    * @return The ID of the action before which the given action should be
    *         inserted.  If the given action should be inserted last or it should
    *         not be inserted at all, returns null.
    */
   insertBeforeActionIDInUrlbar(action) {
     // First, find the index of the given action.
-    let index = this._actionIDsInUrlbar.findIndex(a => a.id == action.id);
+    let idsInUrlbar = this._persistedActions.idsInUrlbar;
+    let index = idsInUrlbar.indexOf(action.id);
     if (index < 0) {
       return null;
     }
     // Now start at the next index and find the ID of the first action that's
-    // currently registered.  Remember that IDs in _actionIDsInUrlbar may belong
-    // to actions that aren't currently registered.
-    for (let i = index + 1; i < this._actionIDsInUrlbar.length; i++) {
-      let id = this._actionIDsInUrlbar[i];
+    // currently registered.  Remember that IDs in idsInUrlbar may belong to
+    // actions that aren't currently registered.
+    for (let i = index + 1; i < idsInUrlbar.length; i++) {
+      let id = idsInUrlbar[i];
       if (this.actionForID(id)) {
         return id;
       }
     }
     return null;
   },
 
   /**
@@ -240,25 +260,34 @@ this.PageActions = {
    * @param  action (Action object, required)
    *         The action that was removed.
    */
   onActionRemoved(action) {
     if (!this.actionForID(action.id)) {
       // The action isn't present.  Not an error.
       return;
     }
+
     this._actionsByID.delete(action.id);
     for (let list of [this._nonBuiltInActions, this._builtInActions]) {
       let index = list.findIndex(a => a.id == action.id);
       if (index >= 0) {
         list.splice(index, 1);
         break;
       }
     }
-    this._updateActionIDsInUrlbar(action.id, false);
+
+    // Remove the action from persisted storage.
+    delete this._persistedActions.ids[action.id];
+    let index = this._persistedActions.idsInUrlbar.indexOf(action.id);
+    if (index >= 0) {
+      this._persistedActions.idsInUrlbar.splice(index, 1);
+    }
+    this._storePersistedActions();
+
     for (let win of browserWindows()) {
       browserPageActions(win).removeAction(action);
     }
   },
 
   /**
    * Call this when an action's iconURL changes.
    *
@@ -297,61 +326,52 @@ this.PageActions = {
    * @param  action (Action object, required)
    *         The action whose shownInUrlbar property changed.
    */
   onActionToggledShownInUrlbar(action) {
     if (!this.actionForID(action.id)) {
       // This may be called before the action has been added.
       return;
     }
-    this._updateActionIDsInUrlbar(action.id, action.shownInUrlbar);
+
+    // Update persisted storage.
+    let index = this._persistedActions.idsInUrlbar.indexOf(action.id);
+    if (action.shownInUrlbar) {
+      if (index < 0) {
+        this._persistedActions.idsInUrlbar.push(action.id);
+      }
+    } else if (index >= 0) {
+      this._persistedActions.idsInUrlbar.splice(index, 1);
+    }
+    this._storePersistedActions();
+
     let insertBeforeID = this.insertBeforeActionIDInUrlbar(action);
     for (let win of browserWindows()) {
       browserPageActions(win).placeActionInUrlbar(action, insertBeforeID);
     }
   },
 
-  /**
-   * Adds or removes the given action ID to or from _actionIDsInUrlbar.
-   *
-   * @param  actionID (string, required)
-   *         The action ID to add or remove.
-   * @param  shownInUrlbar (bool, required)
-   *         If true, the ID is added if it's not already present.  If false,
-   *         the ID is removed if it's not already absent.
-   */
-  _updateActionIDsInUrlbar(actionID, shownInUrlbar) {
-    let index = this._actionIDsInUrlbar.indexOf(actionID);
-    if (shownInUrlbar) {
-      if (index < 0) {
-        this._actionIDsInUrlbar.push(actionID);
-      }
-    } else if (index >= 0) {
-      this._actionIDsInUrlbar.splice(index, 1);
-    }
-    this._storeActionIDsInUrlbar();
+  _storePersistedActions() {
+    let json = JSON.stringify(this._persistedActions);
+    Services.prefs.setStringPref(PREF_PERSISTED_ACTIONS, json);
   },
 
-  _storeActionIDsInUrlbar() {
-    let json = JSON.stringify(this._actionIDsInUrlbar);
-    Services.prefs.setStringPref(PREF_ACTION_IDS_IN_URLBAR, json);
+  _loadPersistedActions() {
+    try {
+      let json = Services.prefs.getStringPref(PREF_PERSISTED_ACTIONS);
+      this._persistedActions = JSON.parse(json);
+    } catch (ex) {}
   },
 
-  _loadActionIDsInUrlbar() {
-    try {
-      let json = Services.prefs.getStringPref(PREF_ACTION_IDS_IN_URLBAR);
-      let obj = JSON.parse(json);
-      if (Array.isArray(obj)) {
-        return obj;
-      }
-    } catch (ex) {}
-    return null;
+  _persistedActions: {
+    // action ID => true, for actions that have ever been seen and not removed
+    ids: {},
+    // action IDs ordered by position in urlbar
+    idsInUrlbar: [],
   },
-
-  _actionIDsInUrlbar: []
 };
 
 /**
  * A single page action.
  *
  * @param  options (object, required)
  *         An object with the following properties:
  *         @param id (string, required)
@@ -590,19 +610,22 @@ Action.prototype = {
    */
   onShowingInPanel(buttonNode) {
     if (this._onShowingInPanel) {
       this._onShowingInPanel(buttonNode);
     }
   },
 
   /**
-   * Unregisters the action and removes its DOM nodes from all browser windows.
-   * Call this when your action lives in an extension and your extension is
-   * unloaded, for example.
+   * Makes PageActions forget about this action and removes its DOM nodes from
+   * all browser windows.  Call this when the user removes your action, like
+   * when your extension is uninstalled.  You probably don't want to call it
+   * simply when your extension is disabled or the app quits, because then
+   * PageActions won't remember it the next time your extension is enabled or
+   * the app starts.
    */
   remove() {
     PageActions.onActionRemoved(this);
   }
 };
 
 this.PageActions.Action = Action;
 
@@ -749,20 +772,23 @@ Button.prototype = {
       this._onCommand(event, buttonNode);
     }
   }
 };
 
 this.PageActions.Button = Button;
 
 
-// This is only necessary so that Pocket can specify it for
+// This is only necessary so that Pocket and the test can specify it for
 // action._insertBeforeActionID.
 this.PageActions.ACTION_ID_BOOKMARK_SEPARATOR = ACTION_ID_BOOKMARK_SEPARATOR;
 
+// This is only necessary so that the test can access it.
+this.PageActions.ACTION_ID_BUILT_IN_SEPARATOR = ACTION_ID_BUILT_IN_SEPARATOR;
+
 
 // Sorted in the order in which they should appear in the page action panel.
 // Does not include the page actions of extensions bundled with the browser.
 // They're added by the relevant extension code.
 var gBuiltInActions = [
 
   // bookmark
   {
--- a/browser/modules/test/browser/browser.ini
+++ b/browser/modules/test/browser/browser.ini
@@ -13,16 +13,17 @@ skip-if = !e10s # Bug 1373549
 [browser_ContentSearch.js]
 support-files =
   contentSearch.js
   contentSearchBadImage.xml
   contentSearchSuggestions.sjs
   contentSearchSuggestions.xml
   !/browser/components/search/test/head.js
   !/browser/components/search/test/testEngine.xml
+[browser_PageActions.js]
 [browser_PermissionUI.js]
 [browser_PermissionUI_prompts.js]
 [browser_ProcessHangNotifications.js]
 skip-if = !e10s
 [browser_SitePermissions.js]
 [browser_SitePermissions_combinations.js]
 [browser_SitePermissions_expiry.js]
 [browser_SitePermissions_tab_urls.js]
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser/browser_PageActions.js
@@ -0,0 +1,703 @@
+"use strict";
+
+// This is a test for PageActions.jsm, specifically the generalized parts that
+// add and remove page actions and toggle them in the urlbar.  This does not
+// test the built-in page actions; browser_page_action_menu.js does that.
+
+// Initialization.  Must run first.
+add_task(async function init() {
+  // The page action urlbar button, and therefore the panel, is only shown when
+  // the current tab is actionable -- i.e., a normal web page.  about:blank is
+  // not, so open a new tab first thing, and close it when this test is done.
+  let tab = await BrowserTestUtils.openNewForegroundTab({
+    gBrowser,
+    url: "http://example.com/",
+  });
+  registerCleanupFunction(async () => {
+    await BrowserTestUtils.removeTab(tab);
+  });
+});
+
+
+// Tests a simple non-built-in action without an iframe or subview.  Also
+// thoroughly checks most of the action's properties, methods, and DOM nodes, so
+// it's not necessary to do that in general in other test tasks.
+add_task(async function simple() {
+  let iconURL = "chrome://browser/skin/email-link.svg";
+  let id = "test-simple";
+  let nodeAttributes = {
+    "test-attr": "test attr value",
+  };
+  let title = "Test simple";
+  let tooltip = "Test simple tooltip";
+
+  let onCommandCallCount = 0;
+  let onPlacedInPanelCallCount = 0;
+  let onPlacedInUrlbarCallCount = 0;
+  let onShowingInPanelCallCount = 0;
+  let onCommandExpectedButtonID;
+
+  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
+  let urlbarButtonID = BrowserPageActions._urlbarButtonNodeIDForActionID(id);
+
+  let initialActions = PageActions.actions;
+
+  let action = PageActions.addAction(new PageActions.Action({
+    iconURL,
+    id,
+    nodeAttributes,
+    title,
+    tooltip,
+    onCommand(event, buttonNode) {
+      onCommandCallCount++;
+      Assert.ok(event, "event should be non-null: " + event);
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, onCommandExpectedButtonID, "buttonNode.id");
+    },
+    onPlacedInPanel(buttonNode) {
+      onPlacedInPanelCallCount++;
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+    },
+    onPlacedInUrlbar(buttonNode) {
+      onPlacedInUrlbarCallCount++;
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+    },
+    onShowingInPanel(buttonNode) {
+      onShowingInPanelCallCount++;
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+    },
+  }));
+
+  Assert.equal(action.iconURL, iconURL, "iconURL");
+  Assert.equal(action.id, id, "id");
+  Assert.deepEqual(action.nodeAttributes, nodeAttributes, "nodeAttributes");
+  Assert.equal(action.shownInUrlbar, false, "shownInUrlbar");
+  Assert.equal(action.subview, null, "subview");
+  Assert.equal(action.title, title, "title");
+  Assert.equal(action.tooltip, tooltip, "tooltip");
+  Assert.equal(action.urlbarIDOverride, null, "urlbarIDOverride");
+  Assert.equal(action.wantsIframe, false, "wantsIframe");
+
+  Assert.ok(!("__insertBeforeActionID" in action), "__insertBeforeActionID");
+  Assert.ok(!("__isSeparator" in action), "__isSeparator");
+  Assert.ok(!("__urlbarNodeInMarkup" in action), "__urlbarNodeInMarkup");
+
+  Assert.equal(onPlacedInPanelCallCount, 1,
+               "onPlacedInPanelCallCount should be inc'ed");
+  Assert.equal(onPlacedInUrlbarCallCount, 0,
+               "onPlacedInUrlbarCallCount should remain 0");
+  Assert.equal(onShowingInPanelCallCount, 0,
+               "onShowingInPanelCallCount should remain 0");
+
+  // The separator between the built-in and non-built-in actions should have
+  // been created and included in PageActions.actions, which is why the new
+  // count should be the initial count + 2, not + 1.
+  Assert.equal(PageActions.actions.length, initialActions.length + 2,
+               "PageActions.actions.length should be updated");
+  Assert.deepEqual(PageActions.actions[PageActions.actions.length - 1], action,
+                   "Last page action should be action");
+  Assert.equal(PageActions.actions[PageActions.actions.length - 2].id,
+               PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+               "2nd-to-last page action should be separator");
+
+  Assert.deepEqual(PageActions.actionForID(action.id), action,
+                   "actionForID should be action");
+
+  // The action's panel button should have been created.
+  let panelButtonNode = document.getElementById(panelButtonID);
+  Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+  Assert.equal(panelButtonNode.getAttribute("label"), action.title, "label");
+  for (let name in action.nodeAttributes) {
+    Assert.ok(panelButtonNode.hasAttribute(name), "Has attribute: " + name);
+    Assert.equal(panelButtonNode.getAttribute(name),
+                 action.nodeAttributes[name],
+                 "Equal attribute: " + name);
+  }
+
+  // The panel button should be the last node in the panel, and its previous
+  // sibling should be the separator between the built-in actions and non-built-
+  // in actions.
+  Assert.equal(panelButtonNode.nextSibling, null, "nextSibling");
+  Assert.notEqual(panelButtonNode.previousSibling, null, "previousSibling");
+  Assert.equal(
+    panelButtonNode.previousSibling.id,
+    BrowserPageActions._panelButtonNodeIDForActionID(
+      PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+    ),
+    "previousSibling.id"
+  );
+
+  // The action's urlbar button should not have been created.
+  let urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+
+  // Open the panel, click the action's button.
+  await promisePageActionPanelOpen();
+  Assert.equal(onShowingInPanelCallCount, 1,
+               "onShowingInPanelCallCount should be inc'ed");
+  onCommandExpectedButtonID = panelButtonID;
+  EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+  await promisePageActionPanelHidden();
+  Assert.equal(onCommandCallCount, 1, "onCommandCallCount should be inc'ed");
+
+  // Show the action's button in the urlbar.
+  action.shownInUrlbar = true;
+  Assert.equal(onPlacedInUrlbarCallCount, 1,
+               "onPlacedInUrlbarCallCount should be inc'ed");
+  urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+  for (let name in action.nodeAttributes) {
+    Assert.ok(urlbarButtonNode.hasAttribute(name), name,
+              "Has attribute: " + name);
+    Assert.equal(urlbarButtonNode.getAttribute(name),
+                 action.nodeAttributes[name],
+                 "Equal attribute: " + name);
+  }
+  onCommandExpectedButtonID = urlbarButtonID;
+  EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+  Assert.equal(onCommandCallCount, 2, "onCommandCallCount should be inc'ed");
+
+  // Set a new title.
+  let newTitle = title + " new title";
+  action.title = newTitle;
+  Assert.equal(action.title, newTitle, "New title");
+  Assert.equal(panelButtonNode.getAttribute("label"), action.title, "New label");
+
+  // Remove the action.
+  action.remove();
+  panelButtonNode = document.getElementById(panelButtonID);
+  Assert.equal(panelButtonNode, null, "panelButtonNode");
+  urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+
+  Assert.deepEqual(PageActions.actions, initialActions,
+                   "Actions should go back to initial");
+  Assert.equal(PageActions.actionForID(action.id), null,
+               "actionForID should be null");
+
+  // The separator between the built-in actions and non-built-in actions should
+  // be gone now, too.
+  let separatorNode = document.getElementById(
+    BrowserPageActions._panelButtonNodeIDForActionID(
+      PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+    )
+  );
+  Assert.equal(separatorNode, null, "No separator");
+  Assert.ok(!BrowserPageActions.mainViewBodyNode
+            .lastChild.localName.includes("separator"),
+            "Last child should not be separator");
+});
+
+
+// Tests a non-built-in action with a subview.
+add_task(async function withSubview() {
+  let id = "test-subview";
+
+  let onActionCommandCallCount = 0;
+  let onActionPlacedInPanelCallCount = 0;
+  let onActionPlacedInUrlbarCallCount = 0;
+  let onSubviewPlacedCount = 0;
+  let onSubviewShowingCount = 0;
+  let onButtonCommandCallCount = 0;
+
+  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
+  let urlbarButtonID = BrowserPageActions._urlbarButtonNodeIDForActionID(id);
+
+  let panelViewIDPanel =
+    BrowserPageActions._panelViewNodeIDForActionID(id, false);
+  let panelViewIDUrlbar =
+    BrowserPageActions._panelViewNodeIDForActionID(id, true);
+
+  let onSubviewPlacedExpectedPanelViewID = panelViewIDPanel;
+  let onSubviewShowingExpectedPanelViewID;
+  let onButtonCommandExpectedButtonID;
+
+  let subview = {
+    buttons: [0, 1, 2].map(index => {
+      return {
+        id: "test-subview-button-" + index,
+        title: "Test subview Button " + index,
+      };
+    }),
+    onPlaced(panelViewNode) {
+      onSubviewPlacedCount++;
+      Assert.ok(panelViewNode,
+                "panelViewNode should be non-null: " + panelViewNode);
+      Assert.equal(panelViewNode.id, onSubviewPlacedExpectedPanelViewID,
+                   "panelViewNode.id");
+    },
+    onShowing(panelViewNode) {
+      onSubviewShowingCount++;
+      Assert.ok(panelViewNode,
+                "panelViewNode should be non-null: " + panelViewNode);
+      Assert.equal(panelViewNode.id, onSubviewShowingExpectedPanelViewID,
+                   "panelViewNode.id");
+    },
+  };
+  subview.buttons[0].onCommand = (event, buttonNode) => {
+    onButtonCommandCallCount++;
+    Assert.ok(event, "event should be non-null: " + event);
+    Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+    Assert.equal(buttonNode.id, onButtonCommandExpectedButtonID,
+                 "buttonNode.id");
+    for (let node = buttonNode.parentNode; node; node = node.parentNode) {
+      if (node.localName == "panel") {
+        node.hidePopup();
+        break;
+      }
+    }
+  };
+
+  let action = PageActions.addAction(new PageActions.Action({
+    iconURL: "chrome://browser/skin/email-link.svg",
+    id,
+    shownInUrlbar: true,
+    subview,
+    title: "Test subview",
+    onCommand(event, buttonNode) {
+      onActionCommandCallCount++;
+    },
+    onPlacedInPanel(buttonNode) {
+      onActionPlacedInPanelCallCount++;
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+    },
+    onPlacedInUrlbar(buttonNode) {
+      onActionPlacedInUrlbarCallCount++;
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+    },
+  }));
+
+  let panelViewButtonIDPanel =
+    BrowserPageActions._panelViewButtonNodeIDForActionID(
+      id, subview.buttons[0].id, false
+    );
+  let panelViewButtonIDUrlbar =
+    BrowserPageActions._panelViewButtonNodeIDForActionID(
+      id, subview.buttons[0].id, true
+    );
+
+  Assert.equal(action.id, id, "id");
+  Assert.notEqual(action.subview, null, "subview");
+  Assert.notEqual(action.subview.buttons, null, "subview.buttons");
+  Assert.equal(action.subview.buttons.length, subview.buttons.length,
+               "subview.buttons.length");
+  for (let i = 0; i < subview.buttons.length; i++) {
+    Assert.equal(action.subview.buttons[i].id, subview.buttons[i].id,
+                 "subview button id for index: " + i);
+    Assert.equal(action.subview.buttons[i].title, subview.buttons[i].title,
+                 "subview button title for index: " + i);
+  }
+
+  Assert.equal(onActionPlacedInPanelCallCount, 1,
+               "onActionPlacedInPanelCallCount should be inc'ed");
+  Assert.equal(onActionPlacedInUrlbarCallCount, 1,
+               "onActionPlacedInUrlbarCallCount should be inc'ed");
+  Assert.equal(onSubviewPlacedCount, 1,
+               "onSubviewPlacedCount should be inc'ed");
+  Assert.equal(onSubviewShowingCount, 0,
+               "onSubviewShowingCount should remain 0");
+
+  // The action's panel button and view (in the main page action panel) should
+  // have been created.
+  let panelButtonNode = document.getElementById(panelButtonID);
+  Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+  let panelViewButtonNodePanel =
+    document.getElementById(panelViewButtonIDPanel);
+  Assert.notEqual(panelViewButtonNodePanel, null, "panelViewButtonNodePanel");
+
+  // The action's urlbar button should have been created.
+  let urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+  // Open the panel, click the action's button, click the subview's first
+  // button.
+  await promisePageActionPanelOpen();
+  Assert.equal(onSubviewShowingCount, 0,
+               "onSubviewShowingCount should remain 0");
+  let subviewShownPromise = promisePageActionViewShown();
+  onSubviewShowingExpectedPanelViewID = panelViewIDPanel;
+
+  // synthesizeMouse often cannot seem to click the right node when used on
+  // buttons that show subviews and buttons inside subviews.  That's why we're
+  // using node.click() twice here: the first time to show the subview, the
+  // second time to click a button in the subview.
+//   EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+  panelButtonNode.click();
+  await subviewShownPromise;
+  Assert.equal(onActionCommandCallCount, 0,
+               "onActionCommandCallCount should remain 0");
+  Assert.equal(onSubviewShowingCount, 1,
+               "onSubviewShowingCount should be inc'ed");
+  onButtonCommandExpectedButtonID = panelViewButtonIDPanel;
+//   EventUtils.synthesizeMouseAtCenter(panelViewButtonNodePanel, {});
+  panelViewButtonNodePanel.click();
+  await promisePageActionPanelHidden();
+  Assert.equal(onActionCommandCallCount, 0,
+               "onActionCommandCallCount should remain 0");
+  Assert.equal(onButtonCommandCallCount, 1,
+               "onButtonCommandCallCount should be inc'ed");
+
+  // Click the action's urlbar button, which should open the temp panel showing
+  // the subview, and click the subview's first button.
+  onSubviewPlacedExpectedPanelViewID = panelViewIDUrlbar;
+  onSubviewShowingExpectedPanelViewID = panelViewIDUrlbar;
+  EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+  await promisePanelShown(BrowserPageActions._tempPanelID);
+  Assert.equal(onSubviewPlacedCount, 2,
+               "onSubviewPlacedCount should be inc'ed");
+  Assert.equal(onSubviewShowingCount, 2,
+               "onSubviewShowingCount should be inc'ed");
+  let panelViewButtonNodeUrlbar =
+    document.getElementById(panelViewButtonIDUrlbar);
+  Assert.notEqual(panelViewButtonNodeUrlbar, null, "panelViewButtonNodeUrlbar");
+  onButtonCommandExpectedButtonID = panelViewButtonIDUrlbar;
+  EventUtils.synthesizeMouseAtCenter(panelViewButtonNodeUrlbar, {});
+  await promisePanelHidden(BrowserPageActions._tempPanelID);
+  Assert.equal(onButtonCommandCallCount, 2,
+               "onButtonCommandCallCount should be inc'ed");
+
+  // Remove the action.
+  action.remove();
+  panelButtonNode = document.getElementById(panelButtonID);
+  Assert.equal(panelButtonNode, null, "panelButtonNode");
+  urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+  let panelViewNodePanel = document.getElementById(panelViewIDPanel);
+  Assert.equal(panelViewNodePanel, null, "panelViewNodePanel");
+  let panelViewNodeUrlbar = document.getElementById(panelViewIDUrlbar);
+  Assert.equal(panelViewNodeUrlbar, null, "panelViewNodeUrlbar");
+});
+
+
+// Tests a non-built-in action with an iframe.
+add_task(async function withIframe() {
+  let id = "test-iframe";
+
+  let onCommandCallCount = 0;
+  let onPlacedInPanelCallCount = 0;
+  let onPlacedInUrlbarCallCount = 0;
+  let onIframeShownCount = 0;
+
+  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
+  let urlbarButtonID = BrowserPageActions._urlbarButtonNodeIDForActionID(id);
+
+  let action = PageActions.addAction(new PageActions.Action({
+    iconURL: "chrome://browser/skin/email-link.svg",
+    id,
+    shownInUrlbar: true,
+    title: "Test iframe",
+    wantsIframe: true,
+    onCommand(event, buttonNode) {
+      onCommandCallCount++;
+    },
+    onIframeShown(iframeNode, panelNode) {
+      onIframeShownCount++;
+      Assert.ok(iframeNode, "iframeNode should be non-null: " + iframeNode);
+      Assert.equal(iframeNode.localName, "iframe", "iframe localName");
+      Assert.ok(panelNode, "panelNode should be non-null: " + panelNode);
+      Assert.equal(panelNode.id, BrowserPageActions._tempPanelID,
+                   "panelNode.id");
+    },
+    onPlacedInPanel(buttonNode) {
+      onPlacedInPanelCallCount++;
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+    },
+    onPlacedInUrlbar(buttonNode) {
+      onPlacedInUrlbarCallCount++;
+      Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+      Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+    },
+  }));
+
+  Assert.equal(action.id, id, "id");
+  Assert.equal(action.wantsIframe, true, "wantsIframe");
+
+  Assert.equal(onPlacedInPanelCallCount, 1,
+               "onPlacedInPanelCallCount should be inc'ed");
+  Assert.equal(onPlacedInUrlbarCallCount, 1,
+               "onPlacedInUrlbarCallCount should be inc'ed");
+  Assert.equal(onIframeShownCount, 0,
+               "onIframeShownCount should remain 0");
+  Assert.equal(onCommandCallCount, 0,
+               "onCommandCallCount should remain 0");
+
+  // The action's panel button should have been created.
+  let panelButtonNode = document.getElementById(panelButtonID);
+  Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+  // The action's urlbar button should have been created.
+  let urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+  // Open the panel, click the action's button.
+  await promisePageActionPanelOpen();
+  Assert.equal(onIframeShownCount, 0, "onIframeShownCount should remain 0");
+  EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+  await promisePanelShown(BrowserPageActions._tempPanelID);
+  Assert.equal(onCommandCallCount, 0, "onCommandCallCount should remain 0");
+  Assert.equal(onIframeShownCount, 1, "onIframeShownCount should be inc'ed");
+
+  // The temp panel should have opened, anchored to the action's urlbar button.
+  let tempPanel = document.getElementById(BrowserPageActions._tempPanelID);
+  Assert.notEqual(tempPanel, null, "tempPanel");
+  Assert.equal(tempPanel.anchorNode.id, urlbarButtonID,
+               "tempPanel.anchorNode.id");
+  EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+  await promisePanelHidden(BrowserPageActions._tempPanelID);
+
+  // Click the action's urlbar button.
+  EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+  await promisePanelShown(BrowserPageActions._tempPanelID);
+  Assert.equal(onCommandCallCount, 0, "onCommandCallCount should remain 0");
+  Assert.equal(onIframeShownCount, 2, "onIframeShownCount should be inc'ed");
+
+  // The temp panel should have opened, again anchored to the action's urlbar
+  // button.
+  tempPanel = document.getElementById(BrowserPageActions._tempPanelID);
+  Assert.notEqual(tempPanel, null, "tempPanel");
+  Assert.equal(tempPanel.anchorNode.id, urlbarButtonID,
+               "tempPanel.anchorNode.id");
+  EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+  await promisePanelHidden(BrowserPageActions._tempPanelID);
+
+  // Hide the action's button in the urlbar.
+  action.shownInUrlbar = false;
+  urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+
+  // Open the panel, click the action's button.
+  await promisePageActionPanelOpen();
+  EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+  await promisePanelShown(BrowserPageActions._tempPanelID);
+  Assert.equal(onCommandCallCount, 0, "onCommandCallCount should remain 0");
+  Assert.equal(onIframeShownCount, 3, "onIframeShownCount should be inc'ed");
+
+  // The temp panel should have opened, this time anchored to the main page
+  // action button in the urlbar.
+  tempPanel = document.getElementById(BrowserPageActions._tempPanelID);
+  Assert.notEqual(tempPanel, null, "tempPanel");
+  Assert.equal(tempPanel.anchorNode.id, BrowserPageActions.mainButtonNode.id,
+               "tempPanel.anchorNode.id");
+  EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+  await promisePanelHidden(BrowserPageActions._tempPanelID);
+
+  // Remove the action.
+  action.remove();
+  panelButtonNode = document.getElementById(panelButtonID);
+  Assert.equal(panelButtonNode, null, "panelButtonNode");
+  urlbarButtonNode = document.getElementById(urlbarButtonID);
+  Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+});
+
+
+// Tests an action with the _insertBeforeActionID option set.
+add_task(async function insertBeforeActionID() {
+  let id = "test-insertBeforeActionID";
+  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
+
+  let initialActions = PageActions.actions;
+  let initialBuiltInActions = PageActions.builtInActions;
+  let initialNonBuiltInActions = PageActions.nonBuiltInActions;
+  let initialBookmarkSeparatorIndex = PageActions.actions.findIndex(a => {
+    return a.id == PageActions.ACTION_ID_BOOKMARK_SEPARATOR;
+  });
+
+  let action = PageActions.addAction(new PageActions.Action({
+    id,
+    title: "Test insertBeforeActionID",
+    _insertBeforeActionID: PageActions.ACTION_ID_BOOKMARK_SEPARATOR,
+  }));
+
+  Assert.equal(action.id, id, "id");
+  Assert.ok("__insertBeforeActionID" in action, "__insertBeforeActionID");
+  Assert.equal(action.__insertBeforeActionID,
+               PageActions.ACTION_ID_BOOKMARK_SEPARATOR,
+               "action.__insertBeforeActionID");
+
+  Assert.equal(PageActions.actions.length,
+               initialActions.length + 1,
+               "PageActions.actions.length should be updated");
+  Assert.equal(PageActions.builtInActions.length,
+               initialBuiltInActions.length + 1,
+               "PageActions.builtInActions.length should be updated");
+  Assert.equal(PageActions.nonBuiltInActions.length,
+               initialNonBuiltInActions.length,
+               "PageActions.nonBuiltInActions.length should be updated");
+
+  let actionIndex = PageActions.actions.findIndex(a => a.id == id);
+  Assert.equal(initialBookmarkSeparatorIndex, actionIndex,
+               "initialBookmarkSeparatorIndex");
+  let newBookmarkSeparatorIndex = PageActions.actions.findIndex(a => {
+    return a.id == PageActions.ACTION_ID_BOOKMARK_SEPARATOR;
+  });
+  Assert.equal(newBookmarkSeparatorIndex, initialBookmarkSeparatorIndex + 1,
+               "newBookmarkSeparatorIndex");
+
+  // The action's panel button should have been created.
+  let panelButtonNode = document.getElementById(panelButtonID);
+  Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+  // The button's next sibling should be the bookmark separator.
+  Assert.notEqual(panelButtonNode.nextSibling, null,
+                  "panelButtonNode.nextSibling");
+  Assert.equal(
+    panelButtonNode.nextSibling.id,
+    BrowserPageActions._panelButtonNodeIDForActionID(
+      PageActions.ACTION_ID_BOOKMARK_SEPARATOR
+    ),
+    "panelButtonNode.nextSibling.id"
+  );
+
+  // The separator between the built-in and non-built-in actions should not have
+  // been created.
+  Assert.equal(
+    document.getElementById(
+      BrowserPageActions._panelButtonNodeIDForActionID(
+        PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+      )
+    ),
+    null,
+    "Separator should be gone"
+  );
+
+  action.remove();
+});
+
+
+// Tests that the ordering of multiple non-built-in actions is alphabetical.
+add_task(async function multipleNonBuiltInOrdering() {
+  let idPrefix = "test-multipleNonBuiltInOrdering-";
+  let titlePrefix = "Test multipleNonBuiltInOrdering ";
+
+  let initialActions = PageActions.actions;
+  let initialBuiltInActions = PageActions.builtInActions;
+  let initialNonBuiltInActions = PageActions.nonBuiltInActions;
+
+  // Create some actions in an out-of-order order.
+  let actions = [2, 1, 4, 3].map(index => {
+    return PageActions.addAction(new PageActions.Action({
+      id: idPrefix + index,
+      title: titlePrefix + index,
+    }));
+  });
+
+  // + 1 for the separator between built-in and non-built-in actions.
+  Assert.equal(PageActions.actions.length,
+               initialActions.length + actions.length + 1,
+               "PageActions.actions.length should be updated");
+
+  Assert.equal(PageActions.builtInActions.length,
+               initialBuiltInActions.length,
+               "PageActions.builtInActions.length should be same");
+  Assert.equal(PageActions.nonBuiltInActions.length,
+               initialNonBuiltInActions.length + actions.length,
+               "PageActions.nonBuiltInActions.length should be updated");
+
+  // Look at the final actions.length actions in PageActions.actions, from first
+  // to last.
+  for (let i = 0; i < actions.length; i++) {
+    let expectedIndex = i + 1;
+    let actualAction = PageActions.nonBuiltInActions[i];
+    Assert.equal(actualAction.id, idPrefix + expectedIndex,
+                 "actualAction.id for index: " + i);
+  }
+
+  // Check the button nodes in the panel.
+  let expectedIndex = 1;
+  let buttonNode = document.getElementById(
+    BrowserPageActions._panelButtonNodeIDForActionID(idPrefix + expectedIndex)
+  );
+  Assert.notEqual(buttonNode, null, "buttonNode");
+  Assert.notEqual(buttonNode.previousSibling, null,
+                  "buttonNode.previousSibling");
+  Assert.equal(
+    buttonNode.previousSibling.id,
+    BrowserPageActions._panelButtonNodeIDForActionID(
+      PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+    ),
+    "buttonNode.previousSibling.id"
+  );
+  for (let i = 0; i < actions.length; i++) {
+    Assert.notEqual(buttonNode, null, "buttonNode at index: " + i);
+    Assert.equal(
+      buttonNode.id,
+      BrowserPageActions._panelButtonNodeIDForActionID(idPrefix + expectedIndex),
+      "buttonNode.id at index: " + i
+    );
+    buttonNode = buttonNode.nextSibling;
+    expectedIndex++;
+  }
+  Assert.equal(buttonNode, null, "Nothing should come after the last button");
+
+  for (let action of actions) {
+    action.remove();
+  }
+
+  // The separator between the built-in and non-built-in actions should be gone.
+  Assert.equal(
+    document.getElementById(
+      BrowserPageActions._panelButtonNodeIDForActionID(
+        PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+      )
+    ),
+    null,
+    "Separator should be gone"
+  );
+});
+
+
+function promisePageActionPanelOpen() {
+  let button = document.getElementById("pageActionButton");
+  let shownPromise = promisePageActionPanelShown();
+  EventUtils.synthesizeMouseAtCenter(button, {});
+  return shownPromise;
+}
+
+function promisePageActionPanelShown() {
+  return promisePanelShown(BrowserPageActions.panelNode);
+}
+
+function promisePageActionPanelHidden() {
+  return promisePanelHidden(BrowserPageActions.panelNode);
+}
+
+function promisePanelShown(panelIDOrNode) {
+  return promisePanelEvent(panelIDOrNode, "popupshown");
+}
+
+function promisePanelHidden(panelIDOrNode) {
+  return promisePanelEvent(panelIDOrNode, "popuphidden");
+}
+
+function promisePanelEvent(panelIDOrNode, eventType) {
+  return new Promise(resolve => {
+    let panel = typeof(panelIDOrNode) != "string" ? panelIDOrNode :
+                document.getElementById(panelIDOrNode);
+    if (!panel ||
+        (eventType == "popupshowing" && panel.state == "open") ||
+        (eventType == "popuphidden" && panel.state == "closed")) {
+      executeSoon(resolve);
+      return;
+    }
+    panel.addEventListener(eventType, () => {
+      executeSoon(resolve);
+    }, { once: true });
+  });
+}
+
+function promisePageActionViewShown() {
+  return new Promise(resolve => {
+    BrowserPageActions.panelNode.addEventListener("ViewShown", (event) => {
+      let target = event.originalTarget;
+      window.setTimeout(() => {
+        resolve(target);
+      }, 5000);
+    }, { once: true });
+  });
+}
--- a/browser/themes/shared/urlbar-searchbar.inc.css
+++ b/browser/themes/shared/urlbar-searchbar.inc.css
@@ -108,16 +108,21 @@
 }
 
 #pageAction-panel-sendToDevice-fxa,
 #pageAction-urlbar-sendToDevice-fxa {
   list-style-image: url("chrome://browser/skin/sync.svg");
 }
 
 /* Page action urlbar buttons */
+#urlbar-icons {
+  /* Add more space between the last icon and the urlbar's edge. */
+  margin-inline-end: 3px;
+}
+
 .urlbar-icon {
   width: 22px;
   height: 16px;
   margin-inline-start: 6px;
   -moz-context-properties: fill, fill-opacity;
   fill: currentColor;
   fill-opacity: 0.6;
   color: inherit;