Bug 1537626 - Make use of ExtensionShortcuts module and add commands API tests. r=darktrojan a=jorgk
authorPhilipp Kewisch <mozilla@kewis.ch>
Tue, 29 Jan 2019 23:59:11 +0100
changeset 34000 1889b7254762388c423a33d5403333c791814188
parent 33999 173c2e53640c1c4159782a7f8e70754a1eb0eee0
child 34001 006b4646c4f2e12645fcc5b6afd53da71d50da20
push id2404
push usermozilla@jorgk.com
push dateTue, 07 May 2019 20:34:08 +0000
treeherdercomm-beta@ac7b1df1919b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdarktrojan, jorgk
bugs1537626
Bug 1537626 - Make use of ExtensionShortcuts module and add commands API tests. r=darktrojan a=jorgk
mail/components/extensions/jar.mn
mail/components/extensions/parent/ext-commands.js
mail/components/extensions/schemas/commands.json
mail/components/extensions/test/browser/browser.ini
mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js
mail/components/extensions/test/browser/browser_ext_commands_getAll.js
mail/components/extensions/test/browser/browser_ext_commands_onCommand.js
mail/components/extensions/test/browser/browser_ext_commands_update.js
mail/components/extensions/test/browser/head.js
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -10,33 +10,33 @@ messenger.jar:
     content/messenger/child/ext-menus-child.js     (child/ext-menus-child.js)
     content/messenger/child/ext-menus.js           (child/ext-menus.js)
     content/messenger/child/ext-tabs.js            (child/ext-tabs.js)
 
     content/messenger/parent/ext-accounts.js       (parent/ext-accounts.js)
     content/messenger/parent/ext-addressBook.js    (parent/ext-addressBook.js)
     content/messenger/parent/ext-browserAction.js  (parent/ext-browserAction.js)
     content/messenger/parent/ext-cloudFile.js      (parent/ext-cloudFile.js)
-    content/messenger/parent/ext-commands.js       (parent/ext-commands.js)
+    content/messenger/parent/ext-commands.js       (../../../../browser/components/extensions/parent/ext-commands.js)
     content/messenger/parent/ext-compose.js        (parent/ext-compose.js)
     content/messenger/parent/ext-composeAction.js  (parent/ext-composeAction.js)
     content/messenger/parent/ext-legacy.js         (parent/ext-legacy.js)
     content/messenger/parent/ext-mail.js           (parent/ext-mail.js)
     content/messenger/parent/ext-mailTabs.js       (parent/ext-mailTabs.js)
     content/messenger/parent/ext-menus.js          (parent/ext-menus.js)
     content/messenger/parent/ext-messages.js       (parent/ext-messages.js)
     content/messenger/parent/ext-pkcs11.js         (../../../../browser/components/extensions/parent/ext-pkcs11.js)
     content/messenger/parent/ext-tabs.js           (parent/ext-tabs.js)
     content/messenger/parent/ext-windows.js        (parent/ext-windows.js)
 
     content/messenger/schemas/accounts.json        (schemas/accounts.json)
     content/messenger/schemas/addressBook.json     (schemas/addressBook.json)
     content/messenger/schemas/browserAction.json   (schemas/browserAction.json)
     content/messenger/schemas/cloudFile.json       (schemas/cloudFile.json)
-    content/messenger/schemas/commands.json        (schemas/commands.json)
+    content/messenger/schemas/commands.json        (../../../../browser/components/extensions/schemas/commands.json)
     content/messenger/schemas/compose.json         (schemas/compose.json)
     content/messenger/schemas/composeAction.json   (schemas/composeAction.json)
     content/messenger/schemas/legacy.json          (schemas/legacy.json)
     content/messenger/schemas/mailTabs.json        (schemas/mailTabs.json)
     content/messenger/schemas/menus.json           (schemas/menus.json)
     content/messenger/schemas/menus_child.json     (schemas/menus_child.json)
     content/messenger/schemas/messages.json        (schemas/messages.json)
     content/messenger/schemas/pkcs11.json          (../../../../browser/components/extensions/schemas/pkcs11.json)
deleted file mode 100644
--- a/mail/components/extensions/parent/ext-commands.js
+++ /dev/null
@@ -1,369 +0,0 @@
-/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
-/* vim: set sts=2 sw=2 et tw=80: */
-"use strict";
-
-var {ExtensionParent} = "resource://gre/modules/ExtensionParent.jsm";
-ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
-                               "resource://gre/modules/ExtensionSettingsStore.jsm");
-
-var {
-  chromeModifierKeyMap,
-  ExtensionError,
-} = ExtensionUtils;
-
-const EXECUTE_BROWSER_ACTION = "_execute_browser_action";
-
-function normalizeShortcut(shortcut) {
-  return shortcut ? shortcut.replace(/\s+/g, "") : null;
-}
-
-this.commands = class extends ExtensionAPI {
-  static async onUninstall(extensionId) {
-    // Cleanup the updated commands. In some cases the extension is installed
-    // and uninstalled so quickly that `this.commands` hasn't loaded yet. To
-    // handle that we need to make sure ExtensionSettingsStore is initialized
-    // before we clean it up.
-    await ExtensionSettingsStore.initialize();
-    ExtensionSettingsStore
-      .getAllForExtension(extensionId, "commands")
-      .forEach(key => {
-        ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
-      });
-  }
-
-  async onManifestEntry(entryName) {
-    let {extension} = this;
-
-    this.id = makeWidgetId(extension.id);
-    this.windowOpenListener = null;
-
-    // Map[{String} commandName -> {Object} commandProperties]
-    this.manifestCommands = this.loadCommandsFromManifest(extension.manifest);
-
-    this.commands = new Promise(async (resolve) => {
-      // Deep copy the manifest commands to commands so we can keep the original
-      // manifest commands and update commands as needed.
-      let commands = new Map();
-      this.manifestCommands.forEach((command, name) => {
-        commands.set(name, {...command});
-      });
-
-      // Update the manifest commands with the persisted updates from
-      // browser.commands.update().
-      let savedCommands = await this.loadCommandsFromStorage(extension.id);
-      savedCommands.forEach((update, name) => {
-        let command = commands.get(name);
-        if (command) {
-          // We will only update commands, not add them.
-          Object.assign(command, update);
-        }
-      });
-
-      resolve(commands);
-    });
-
-    // WeakMap[Window -> <xul:keyset>]
-    this.keysetsMap = new WeakMap();
-
-    await this.register();
-  }
-
-  onShutdown(reason) {
-    this.unregister();
-  }
-
-  registerKeys(commands) {
-    for (let window of windowTracker.browserWindows()) {
-      this.registerKeysToDocument(window, commands);
-    }
-  }
-
-  /**
-   * Registers the commands to all open windows and to any which
-   * are later created.
-   */
-  async register() {
-    let commands = await this.commands;
-    this.registerKeys(commands);
-
-    this.windowOpenListener = (window) => {
-      if (!this.keysetsMap.has(window)) {
-        this.registerKeysToDocument(window, commands);
-      }
-    };
-
-    windowTracker.addOpenListener(this.windowOpenListener);
-  }
-
-  /**
-   * Unregisters the commands from all open windows and stops commands
-   * from being registered to windows which are later created.
-   */
-  unregister() {
-    for (let window of windowTracker.browserWindows()) {
-      if (this.keysetsMap.has(window)) {
-        this.keysetsMap.get(window).remove();
-      }
-    }
-
-    windowTracker.removeOpenListener(this.windowOpenListener);
-  }
-
-  /**
-   * Creates a Map from commands for each command in the manifest.commands object.
-   *
-   * @param {Object} manifest The manifest JSON object.
-   * @returns {Map<string, object>}
-   */
-  loadCommandsFromManifest(manifest) {
-    let commands = new Map();
-    // For Windows, chrome.runtime expects 'win' while chrome.commands
-    // expects 'windows'.  We can special case this for now.
-    let {PlatformInfo} = ExtensionParent;
-    let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os;
-    for (let [name, command] of Object.entries(manifest.commands)) {
-      let suggested_key = command.suggested_key || {};
-      let shortcut = normalizeShortcut(suggested_key[os] || suggested_key.default);
-      commands.set(name, {
-        description: command.description,
-        shortcut,
-      });
-    }
-    return commands;
-  }
-
-  async loadCommandsFromStorage(extensionId) {
-    await ExtensionSettingsStore.initialize();
-    let names = ExtensionSettingsStore.getAllForExtension(extensionId, "commands");
-    return names.reduce((map, name) => {
-      let command = ExtensionSettingsStore.getSetting(
-        "commands", name, extensionId).value;
-      return map.set(name, command);
-    }, new Map());
-  }
-
-  /**
-   * Registers the commands to a document.
-   * @param {ChromeWindow} window The XUL window to insert the Keyset.
-   * @param {Map} commands The commands to be set.
-   */
-  registerKeysToDocument(window, commands) {
-    let doc = window.document;
-    let keyset = doc.createXULElement("keyset");
-    keyset.id = `ext-keyset-id-${this.id}`;
-    if (this.keysetsMap.has(window)) {
-      this.keysetsMap.get(window).remove();
-    }
-    commands.forEach((command, name) => {
-      if (command.shortcut) {
-        let parts = command.shortcut.split("+");
-
-        // The key is always the last element.
-        let key = parts.pop();
-
-        if (/^[0-9]$/.test(key)) {
-          let shortcutWithNumpad = command.shortcut.replace(/[0-9]$/, "Numpad$&");
-          let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad);
-          keyset.appendChild(numpadKeyElement);
-        }
-
-        let keyElement = this.buildKey(doc, name, command.shortcut);
-        keyset.appendChild(keyElement);
-      }
-    });
-    doc.documentElement.appendChild(keyset);
-    this.keysetsMap.set(window, keyset);
-  }
-
-  /**
-   * Builds a XUL Key element and attaches an onCommand listener which
-   * emits a command event with the provided name when fired.
-   *
-   * @param {Document} doc The XUL document.
-   * @param {string} name The name of the command.
-   * @param {string} shortcut The shortcut provided in the manifest.
-   * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
-   *
-   * @returns {Document} The newly created Key element.
-   */
-  buildKey(doc, name, shortcut) {
-    let keyElement = this.buildKeyFromShortcut(doc, name, shortcut);
-
-    // We need to have the attribute "oncommand" for the "command" listener to fire,
-    // and it is currently ignored when set to the empty string.
-    keyElement.setAttribute("oncommand", "//");
-
-    /* eslint-disable mozilla/balanced-listeners */
-    // We remove all references to the key elements when the extension is shutdown,
-    // therefore the listeners for these elements will be garbage collected.
-    keyElement.addEventListener("command", (event) => {
-      let action;
-      if (name == EXECUTE_BROWSER_ACTION) {
-        action = browserActionFor(this.extension);
-      } else {
-        this.extension.tabManager
-            .addActiveTabPermission();
-        this.emit("command", name);
-        return;
-      }
-      if (action) {
-        let win = event.target.ownerGlobal;
-        action.triggerAction(win);
-      }
-    });
-    /* eslint-enable mozilla/balanced-listeners */
-
-    return keyElement;
-  }
-
-  /**
-   * Builds a XUL Key element from the provided shortcut.
-   *
-   * @param {Document} doc The XUL document.
-   * @param {string} name The name of the shortcut.
-   * @param {string} shortcut The shortcut provided in the manifest.
-   *
-   * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
-   * @returns {Document} The newly created Key element.
-   */
-  buildKeyFromShortcut(doc, name, shortcut) {
-    let keyElement = doc.createXULElement("key");
-
-    let parts = shortcut.split("+");
-
-    // The key is always the last element.
-    let chromeKey = parts.pop();
-
-    // The modifiers are the remaining elements.
-    keyElement.setAttribute("modifiers", this.getModifiersAttribute(parts));
-
-    if (/^[A-Z]$/.test(chromeKey)) {
-      // We use the key attribute for all single digits and characters.
-      keyElement.setAttribute("key", chromeKey);
-    } else {
-      keyElement.setAttribute("keycode", this.getKeycodeAttribute(chromeKey));
-      keyElement.setAttribute("event", "keydown");
-    }
-
-    return keyElement;
-  }
-
-  /**
-   * Determines the corresponding XUL keycode from the given chrome key.
-   *
-   * For example:
-   *
-   *    input     |  output
-   *    ---------------------------------------
-   *    "PageUP"  |  "VK_PAGE_UP"
-   *    "Delete"  |  "VK_DELETE"
-   *
-   * @param {string} chromeKey The chrome key (e.g. "PageUp", "Space", ...)
-   * @returns {string} The constructed value for the Key's 'keycode' attribute.
-   */
-  getKeycodeAttribute(chromeKey) {
-    if (/^[0-9]$/.test(chromeKey)) {
-      return `VK_${chromeKey}`;
-    }
-    return `VK${chromeKey.replace(/([A-Z])/g, "_$&").toUpperCase()}`;
-  }
-
-  /**
-   * Determines the corresponding XUL modifiers from the chrome modifiers.
-   *
-   * For example:
-   *
-   *    input             |   output
-   *    ---------------------------------------
-   *    ["Ctrl", "Shift"] |   "accel shift"
-   *    ["MacCtrl"]       |   "control"
-   *
-   * @param {Array} chromeModifiers The array of chrome modifiers.
-   * @returns {string} The constructed value for the Key's 'modifiers' attribute.
-   */
-  getModifiersAttribute(chromeModifiers) {
-    return Array.from(chromeModifiers, modifier => {
-      return chromeModifierKeyMap[modifier];
-    }).join(" ");
-  }
-
-  getAPI(context) {
-    return {
-      commands: {
-        getAll: async () => {
-          let commands = await this.commands;
-          return Array.from(commands, ([name, command]) => {
-            return ({
-              name,
-              description: command.description,
-              shortcut: command.shortcut,
-            });
-          });
-        },
-        update: async ({name, description, shortcut}) => {
-          let {extension} = this;
-          let commands = await this.commands;
-          let command = commands.get(name);
-
-          if (!command) {
-            throw new ExtensionError(`Unknown command "${name}"`);
-          }
-
-          // Only store the updates so manifest changes can take precedence
-          // later.
-          let previousUpdates = await ExtensionSettingsStore.getSetting(
-            "commands", name, extension.id);
-          let commandUpdates = (previousUpdates && previousUpdates.value) || {};
-
-          if (description && description != command.description) {
-            commandUpdates.description = description;
-            command.description = description;
-          }
-
-          if (shortcut && shortcut != command.shortcut) {
-            shortcut = normalizeShortcut(shortcut);
-            commandUpdates.shortcut = shortcut;
-            command.shortcut = shortcut;
-          }
-
-          await ExtensionSettingsStore.addSetting(
-            extension.id, "commands", name, commandUpdates);
-
-          this.registerKeys(commands);
-        },
-        reset: async (name) => {
-          let {extension, manifestCommands} = this;
-          let commands = await this.commands;
-          let command = commands.get(name);
-
-          if (!command) {
-            throw new ExtensionError(`Unknown command "${name}"`);
-          }
-
-          let storedCommand = ExtensionSettingsStore.getSetting(
-            "commands", name, extension.id);
-
-          if (storedCommand && storedCommand.value) {
-            commands.set(name, {...manifestCommands.get(name)});
-            ExtensionSettingsStore.removeSetting(extension.id, "commands", name);
-            this.registerKeys(commands);
-          }
-        },
-        onCommand: new EventManager({
-          context,
-          name: "commands.onCommand",
-          inputHandling: true,
-          register: fire => {
-            let listener = (eventName, commandName) => {
-              fire.async(commandName);
-            };
-            this.on("command", listener);
-            return () => {
-              this.off("command", listener);
-            };
-          },
-        }).api(),
-      },
-    };
-  }
-};
deleted file mode 100644
--- a/mail/components/extensions/schemas/commands.json
+++ /dev/null
@@ -1,181 +0,0 @@
-// Copyright (c) 2012 The Chromium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-[
-  {
-    "namespace": "manifest",
-    "types": [
-     {
-        "id": "KeyName",
-        "type": "string",
-        "format": "manifestShortcutKey"
-      },
-      {
-        "$extend": "WebExtensionManifest",
-        "properties": {
-          "commands": {
-            "type": "object",
-            "optional": true,
-            "additionalProperties": {
-              "type": "object",
-              "additionalProperties": { "$ref": "UnrecognizedProperty" },
-              "properties": {
-                "suggested_key": {
-                  "type": "object",
-                  "optional": true,
-                  "properties": {
-                    "default": {
-                      "$ref": "KeyName",
-                      "optional": true
-                    },
-                    "mac": {
-                      "$ref": "KeyName",
-                      "optional": true
-                    },
-                    "linux": {
-                      "$ref": "KeyName",
-                      "optional": true
-                    },
-                    "windows": {
-                      "$ref": "KeyName",
-                      "optional": true
-                    },
-                    "chromeos": {
-                      "type": "string",
-                      "optional": true
-                    },
-                    "android": {
-                      "type": "string",
-                      "optional": true
-                    },
-                    "ios": {
-                      "type": "string",
-                      "optional": true
-                    },
-                    "additionalProperties": {
-                      "type": "string",
-                      "deprecated": "Unknown platform name",
-                      "optional": true
-                    }
-                  }
-                },
-                "description": {
-                  "type": "string",
-                  "optional": true
-                }
-              }
-            }
-          }
-        }
-      }
-    ]
-  },
-  {
-    "namespace": "commands",
-    "description": "Use the commands API to add keyboard shortcuts that trigger actions in your extension, for example, an action to open the browser action or send a command to the xtension.",
-    "permissions": ["manifest:commands"],
-    "types": [
-      {
-        "id": "Command",
-        "type": "object",
-        "properties": {
-          "name":        {
-            "type": "string",
-            "optional": true,
-            "description": "The name of the Extension Command"
-          },
-          "description": {
-            "type": "string",
-            "optional": true,
-            "description": "The Extension Command description"
-          },
-          "shortcut": {
-            "type": "string",
-            "optional": true,
-            "description": "The shortcut active for this command, or blank if not active."
-          }
-        }
-      }
-    ],
-    "events": [
-      {
-        "name": "onCommand",
-        "description": "Fired when a registered command is activated using a keyboard shortcut.",
-        "type": "function",
-        "parameters": [
-          {
-            "name": "command",
-            "type": "string"
-          }
-        ]
-      }
-    ],
-    "functions": [
-      {
-        "name": "update",
-        "type": "function",
-        "async": true,
-        "description": "Update the details of an already defined command.",
-        "parameters": [
-          {
-            "type": "object",
-            "name": "detail",
-            "description": "The new description for the command.",
-            "properties": {
-              "name": {
-                "type": "string",
-                "description": "The name of the command."
-                },
-              "description": {
-                "type": "string",
-                "optional": true,
-                "description": "The new description for the command."
-              },
-              "shortcut": {
-                "$ref": "manifest.KeyName",
-                "optional": true
-              }
-            }
-          }
-        ]
-      },
-      {
-        "name": "reset",
-        "type": "function",
-        "async": true,
-        "description": "Reset a command's details to what is specified in the manifest.",
-        "parameters": [
-          {
-            "type": "string",
-            "name": "name",
-            "description": "The name of the command."
-          }
-        ]
-      },
-      {
-        "name": "getAll",
-        "type": "function",
-        "async": "callback",
-        "description": "Returns all the registered extension commands for this extension and their shortcut (if active).",
-        "parameters": [
-          {
-            "type": "function",
-            "name": "callback",
-            "optional": true,
-            "parameters": [
-              {
-                "name": "commands",
-                "type": "array",
-                "items": {
-                  "$ref": "Command"
-                }
-              }
-            ],
-            "description": "Called to return the registered commands."
-          }
-        ]
-      }
-    ]
-  }
-]
--- a/mail/components/extensions/test/browser/browser.ini
+++ b/mail/components/extensions/test/browser/browser.ini
@@ -9,12 +9,16 @@ prefs =
   mail.winsearch.firstRunDone=true
   mailnews.start_page.override_url=about:blank
   mailnews.start_page.url=about:blank
 subsuite = thunderbird
 tags = webextensions
 
 [browser_ext_addressBooksUI.js]
 [browser_ext_browserAction.js]
+[browser_ext_commands_execute_browser_action.js]
+[browser_ext_commands_getAll.js]
+[browser_ext_commands_onCommand.js]
+[browser_ext_commands_update.js]
 [browser_ext_composeAction.js]
 [browser_ext_menus.js]
 [browser_ext_mailTabs.js]
 [browser_ext_quickFilter.js]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js
@@ -0,0 +1,105 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function testExecuteBrowserActionWithOptions(options = {}) {
+  // Make sure the mouse isn't hovering over the browserAction widget.
+  let folderTree = document.getElementById("folderTree");
+  EventUtils.synthesizeMouseAtCenter(folderTree, {type: "mouseover"}, window);
+
+  let extensionOptions = {};
+
+  extensionOptions.manifest = {
+    "commands": {
+      "_execute_browser_action": {
+        "suggested_key": {
+          "default": "Alt+Shift+J",
+        },
+      },
+    },
+    "browser_action": {
+      "browser_style": true,
+    },
+  };
+
+  if (options.withPopup) {
+    extensionOptions.manifest.browser_action.default_popup = "popup.html";
+
+    extensionOptions.files = {
+      "popup.html": `
+        <!DOCTYPE html>
+        <html>
+          <head>
+            <meta charset="utf-8">
+            <script src="popup.js"></script>
+          </head>
+        </html>
+      `,
+
+      "popup.js": function() {
+        browser.runtime.sendMessage("from-browser-action-popup");
+      },
+    };
+  }
+
+  extensionOptions.background = () => {
+    browser.test.onMessage.addListener((message, withPopup) => {
+      browser.commands.onCommand.addListener((commandName) => {
+        if (commandName == "_execute_browser_action") {
+          browser.test.fail("The onCommand listener should never fire for _execute_browser_action.");
+        }
+      });
+
+      browser.browserAction.onClicked.addListener(() => {
+        if (withPopup) {
+          browser.test.fail("The onClick listener should never fire if the browserAction has a popup.");
+          browser.test.notifyFail("execute-browser-action-on-clicked-fired");
+        } else {
+          browser.test.notifyPass("execute-browser-action-on-clicked-fired");
+        }
+      });
+
+      browser.runtime.onMessage.addListener(msg => {
+        if (msg == "from-browser-action-popup") {
+          browser.test.notifyPass("execute-browser-action-popup-opened");
+        }
+      });
+
+      browser.test.sendMessage("send-keys");
+    });
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+
+  extension.onMessage("send-keys", () => {
+    EventUtils.synthesizeKey("j", {altKey: true, shiftKey: true});
+  });
+
+  await extension.startup();
+
+  await SimpleTest.promiseFocus(window);
+
+  extension.sendMessage("withPopup", options.withPopup);
+
+  if (options.withPopup) {
+    await extension.awaitFinish("execute-browser-action-popup-opened");
+
+    if (!getBrowserActionPopup(extension)) {
+      await awaitExtensionPanel(extension);
+    }
+    await closeBrowserAction(extension);
+  } else {
+    await extension.awaitFinish("execute-browser-action-on-clicked-fired");
+  }
+  await extension.unload();
+}
+
+add_task(async function test_execute_browser_action_with_popup() {
+  await testExecuteBrowserActionWithOptions({
+    withPopup: true,
+  });
+});
+
+add_task(async function test_execute_browser_action_without_popup() {
+  await testExecuteBrowserActionWithOptions();
+});
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_commands_getAll.js
@@ -0,0 +1,111 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function() {
+  let extension = ExtensionTestUtils.loadExtension({
+    files: {
+      "_locales/en/messages.json": {
+        "with_translation": {
+          "message": "The description",
+          "description": "A description",
+        },
+      },
+    },
+    manifest: {
+      "name": "Commands Extension",
+      "default_locale": "en",
+      "commands": {
+        "with-desciption": {
+          "suggested_key": {
+            "default": "Ctrl+Shift+Y",
+          },
+          "description": "should have a description",
+        },
+        "without-description": {
+          "suggested_key": {
+            "default": "Ctrl+Shift+D",
+          },
+        },
+        "with-platform-info": {
+          "suggested_key": {
+            "mac": "Ctrl+Shift+M",
+            "linux": "Ctrl+Shift+L",
+            "windows": "Ctrl+Shift+W",
+            "android": "Ctrl+Shift+A",
+          },
+        },
+        "with-translation": {
+          "description": "__MSG_with_translation__",
+        },
+        "without-suggested-key": {
+          "description": "has no suggested_key",
+        },
+        "without-suggested-key-nor-description": {
+        },
+      },
+    },
+
+    background() {
+      browser.test.onMessage.addListener((message, additionalScope) => {
+        browser.commands.getAll((commands) => {
+          let errorMessage = "getAll should return an array of commands";
+          browser.test.assertEq(commands.length, 6, errorMessage);
+
+          let command = commands.find(c => c.name == "with-desciption");
+
+          errorMessage = "The description should match what is provided in the manifest";
+          browser.test.assertEq("should have a description", command.description, errorMessage);
+
+          errorMessage = "The shortcut should match the default shortcut provided in the manifest";
+          browser.test.assertEq("Ctrl+Shift+Y", command.shortcut, errorMessage);
+
+          command = commands.find(c => c.name == "without-description");
+
+          errorMessage = "The description should be empty when it is not provided";
+          browser.test.assertEq(null, command.description, errorMessage);
+
+          errorMessage = "The shortcut should match the default shortcut provided in the manifest";
+          browser.test.assertEq("Ctrl+Shift+D", command.shortcut, errorMessage);
+
+          let platformKeys = {
+            macosx: "M",
+            linux: "L",
+            win: "W",
+            android: "A",
+          };
+
+          command = commands.find(c => c.name == "with-platform-info");
+          let platformKey = platformKeys[additionalScope.platform];
+          let shortcut = `Ctrl+Shift+${platformKey}`;
+          errorMessage = `The shortcut should match the one provided in the manifest for OS='${additionalScope.platform}'`;
+          browser.test.assertEq(shortcut, command.shortcut, errorMessage);
+
+          command = commands.find(c => c.name == "with-translation");
+          browser.test.assertEq(command.description, "The description", "The description can be localized");
+
+          command = commands.find(c => c.name == "without-suggested-key");
+
+          browser.test.assertEq("has no suggested_key", command.description, "The description should match what is provided in the manifest");
+
+          browser.test.assertEq(null, command.shortcut, "The shortcut should be empty if not provided");
+
+          command = commands.find(c => c.name == "without-suggested-key-nor-description");
+
+          browser.test.assertEq(null, command.description, "The description should be empty when it is not provided");
+
+          browser.test.assertEq(null, command.shortcut, "The shortcut should be empty if not provided");
+
+          browser.test.notifyPass("commands");
+        });
+      });
+      browser.test.sendMessage("ready");
+    },
+  });
+
+  await extension.startup();
+  await extension.awaitMessage("ready");
+  extension.sendMessage("additional-scope", {platform: AppConstants.platform});
+  await extension.awaitFinish("commands");
+  await extension.unload();
+});
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js
@@ -0,0 +1,285 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_user_defined_commands() {
+  const testCommands = [
+    // Ctrl Shortcuts
+    {
+      name: "toggle-ctrl-a",
+      shortcut: "Ctrl+A",
+      key: "A",
+      modifiers: {
+        accelKey: true,
+      },
+    },
+    {
+      name: "toggle-ctrl-up",
+      shortcut: "Ctrl+Up",
+      key: "VK_UP",
+      modifiers: {
+        accelKey: true,
+      },
+    },
+    // Alt Shortcuts
+    {
+      name: "toggle-alt-a",
+      shortcut: "Alt+A",
+      key: "A",
+      modifiers: {
+        altKey: true,
+      },
+    },
+    {
+      name: "toggle-alt-down",
+      shortcut: "Alt+Down",
+      key: "VK_DOWN",
+      modifiers: {
+        altKey: true,
+      },
+    },
+    // Mac Shortcuts
+    {
+      name: "toggle-command-shift-page-up",
+      shortcutMac: "Command+Shift+PageUp",
+      key: "VK_PAGE_UP",
+      modifiers: {
+        accelKey: true,
+        shiftKey: true,
+      },
+    },
+    {
+      name: "toggle-mac-control-shift+period",
+      shortcut: "Ctrl+Shift+Period",
+      shortcutMac: "MacCtrl+Shift+Period",
+      key: "VK_PERIOD",
+      modifiers: {
+        ctrlKey: true,
+        shiftKey: true,
+      },
+    },
+    // Ctrl+Shift Shortcuts
+    {
+      name: "toggle-ctrl-shift-left",
+      shortcut: "Ctrl+Shift+Left",
+      key: "VK_LEFT",
+      modifiers: {
+        accelKey: true,
+        shiftKey: true,
+      },
+    },
+    {
+      name: "toggle-ctrl-shift-1",
+      shortcut: "Ctrl+Shift+1",
+      key: "1",
+      modifiers: {
+        accelKey: true,
+        shiftKey: true,
+      },
+    },
+    // Alt+Shift Shortcuts
+    {
+      name: "toggle-alt-shift-1",
+      shortcut: "Alt+Shift+1",
+      key: "1",
+      modifiers: {
+        altKey: true,
+        shiftKey: true,
+      },
+    },
+    {
+      name: "toggle-alt-shift-a",
+      shortcut: "Alt+Shift+A",
+      key: "A",
+      modifiers: {
+        altKey: true,
+        shiftKey: true,
+      },
+    },
+    {
+      name: "toggle-alt-shift-right",
+      shortcut: "Alt+Shift+Right",
+      key: "VK_RIGHT",
+      modifiers: {
+        altKey: true,
+        shiftKey: true,
+      },
+    },
+    // Function keys
+    {
+      name: "function-keys-Alt+Shift+F3",
+      shortcut: "Alt+Shift+F3",
+      key: "VK_F3",
+      modifiers: {
+        altKey: true,
+        shiftKey: true,
+      },
+    },
+    {
+      name: "function-keys-F2",
+      shortcut: "F2",
+      key: "VK_F2",
+      modifiers: {
+        altKey: false,
+        shiftKey: false,
+      },
+    },
+    // Misc Shortcuts
+    {
+      name: "valid-command-with-unrecognized-property-name",
+      shortcut: "Alt+Shift+3",
+      key: "3",
+      modifiers: {
+        altKey: true,
+        shiftKey: true,
+      },
+      unrecognized_property: "with-a-random-value",
+    },
+    {
+      name: "spaces-in-shortcut-name",
+      shortcut: "  Alt + Shift + 2  ",
+      key: "2",
+      modifiers: {
+        altKey: true,
+        shiftKey: true,
+      },
+    },
+    {
+      name: "toggle-ctrl-space",
+      shortcut: "Ctrl+Space",
+      key: "VK_SPACE",
+      modifiers: {
+        accelKey: true,
+      },
+    },
+    {
+      name: "toggle-ctrl-comma",
+      shortcut: "Ctrl+Comma",
+      key: "VK_COMMA",
+      modifiers: {
+        accelKey: true,
+      },
+    },
+    {
+      name: "toggle-ctrl-period",
+      shortcut: "Ctrl+Period",
+      key: "VK_PERIOD",
+      modifiers: {
+        accelKey: true,
+      },
+    },
+    {
+      name: "toggle-ctrl-alt-v",
+      shortcut: "Ctrl+Alt+V",
+      key: "V",
+      modifiers: {
+        accelKey: true,
+        altKey: true,
+      },
+    },
+
+  ];
+
+  let win1 = await openNewMailWindow();
+
+  let commands = {};
+  let isMac = AppConstants.platform == "macosx";
+  let totalMacOnlyCommands = 0;
+  let numberNumericCommands = 4;
+
+  for (let testCommand of testCommands) {
+    let command = {
+      suggested_key: {},
+    };
+
+    if (testCommand.shortcut) {
+      command.suggested_key.default = testCommand.shortcut;
+    }
+
+    if (testCommand.shortcutMac) {
+      command.suggested_key.mac = testCommand.shortcutMac;
+    }
+
+    if (testCommand.shortcutMac && !testCommand.shortcut) {
+      totalMacOnlyCommands++;
+    }
+
+    if (testCommand.unrecognized_property) {
+      command.unrecognized_property = testCommand.unrecognized_property;
+    }
+
+    commands[testCommand.name] = command;
+  }
+
+  function background() {
+    browser.commands.onCommand.addListener(commandName => {
+      browser.test.sendMessage("oncommand", commandName);
+    });
+    browser.test.sendMessage("ready");
+  }
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "commands": commands,
+    },
+    background,
+  });
+
+  SimpleTest.waitForExplicitFinish();
+  let waitForConsole = new Promise(resolve => {
+    SimpleTest.monitorConsole(resolve, [{
+      message: /Reading manifest: Error processing commands.*.unrecognized_property: An unexpected property was found/,
+    }]);
+  });
+
+  await extension.startup();
+  await extension.awaitMessage("ready");
+
+  async function runTest(window) {
+    for (let testCommand of testCommands) {
+      if (testCommand.shortcutMac && !testCommand.shortcut && !isMac) {
+        continue;
+      }
+      EventUtils.synthesizeKey(testCommand.key, testCommand.modifiers, window);
+      let message = await extension.awaitMessage("oncommand");
+      is(message, testCommand.name, `Expected onCommand listener to fire with the correct name: ${testCommand.name}`);
+    }
+  }
+
+  // Create another window after the extension is loaded.
+  let win2 = await openNewMailWindow();
+
+  let totalTestCommands = Object.keys(testCommands).length + numberNumericCommands;
+  let expectedCommandsRegistered = isMac ? totalTestCommands : totalTestCommands - totalMacOnlyCommands;
+
+  // Confirm the keysets have been added to both windows.
+  let keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`;
+  let keyset = win1.document.getElementById(keysetID);
+  ok(keyset != null, "Expected keyset to exist");
+  is(keyset.children.length, expectedCommandsRegistered, "Expected keyset to have the correct number of children");
+
+  keyset = win2.document.getElementById(keysetID);
+  ok(keyset != null, "Expected keyset to exist");
+  is(keyset.children.length, expectedCommandsRegistered, "Expected keyset to have the correct number of children");
+
+  // Confirm that the commands are registered to both windows.
+  await focusWindow(win1);
+  await runTest(win1);
+
+  await focusWindow(win2);
+  await runTest(win2);
+
+  await extension.unload();
+
+  // Confirm that the keysets have been removed from both windows after the extension is unloaded.
+  keyset = win1.document.getElementById(keysetID);
+  is(keyset, null, "Expected keyset to be removed from the window");
+
+  keyset = win2.document.getElementById(keysetID);
+  is(keyset, null, "Expected keyset to be removed from the window");
+
+  await BrowserTestUtils.closeWindow(win1);
+  await BrowserTestUtils.closeWindow(win2);
+
+  SimpleTest.endMonitorConsole();
+  await waitForConsole;
+});
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_commands_update.js
@@ -0,0 +1,252 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
+                               "resource://gre/modules/ExtensionSettingsStore.jsm");
+ChromeUtils.defineModuleGetter(this, "AddonManager",
+                               "resource://gre/modules/AddonManager.jsm");
+
+function enableAddon(addon) {
+  return new Promise(resolve => {
+    AddonManager.addAddonListener({
+      onEnabled(enabledAddon) {
+        if (enabledAddon.id == addon.id) {
+          resolve();
+          AddonManager.removeAddonListener(this);
+        }
+      },
+    });
+    addon.enable();
+  });
+}
+
+function disableAddon(addon) {
+  return new Promise(resolve => {
+    AddonManager.addAddonListener({
+      onDisabled(disabledAddon) {
+        if (disabledAddon.id == addon.id) {
+          resolve();
+          AddonManager.removeAddonListener(this);
+        }
+      },
+    });
+    addon.disable();
+  });
+}
+
+add_task(async function test_update_defined_command() {
+  let extension;
+  let updatedExtension;
+
+  registerCleanupFunction(async () => {
+    await extension.unload();
+
+    // updatedExtension might not have started up if we didn't make it that far.
+    if (updatedExtension) {
+      await updatedExtension.unload();
+    }
+
+    // Check that ESS is cleaned up on uninstall.
+    let storedCommands = ExtensionSettingsStore.getAllForExtension(
+      extension.id, "commands");
+    is(storedCommands.length, 0, "There are no stored commands after unload");
+  });
+
+  extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      version: "1.0",
+      applications: {gecko: {id: "commands@mochi.test"}},
+      commands: {
+        foo: {
+          suggested_key: {
+            default: "Ctrl+Shift+I",
+          },
+          description: "The foo command",
+        },
+      },
+    },
+    background() {
+      browser.test.onMessage.addListener(async (msg, data) => {
+        if (msg == "update") {
+          await browser.commands.update(data);
+          browser.test.sendMessage("updateDone");
+          return;
+        } else if (msg == "reset") {
+          await browser.commands.reset(data);
+          browser.test.sendMessage("resetDone");
+          return;
+        } else if (msg != "run") {
+          return;
+        }
+        // Test initial manifest command.
+        let commands = await browser.commands.getAll();
+        browser.test.assertEq(1, commands.length, "There is 1 command");
+        let command = commands[0];
+        browser.test.assertEq("foo", command.name, "The name is right");
+        browser.test.assertEq("The foo command", command.description, "The description is right");
+        browser.test.assertEq("Ctrl+Shift+I", command.shortcut, "The shortcut is right");
+
+        // Update the shortcut.
+        await browser.commands.update({name: "foo", shortcut: "Ctrl+Shift+L"});
+
+        // Test the updated shortcut.
+        commands = await browser.commands.getAll();
+        browser.test.assertEq(1, commands.length, "There is still 1 command");
+        command = commands[0];
+        browser.test.assertEq("foo", command.name, "The name is unchanged");
+        browser.test.assertEq("The foo command", command.description, "The description is unchanged");
+        browser.test.assertEq("Ctrl+Shift+L", command.shortcut, "The shortcut is updated");
+
+        // Update the description.
+        await browser.commands.update({name: "foo", description: "The only command"});
+
+        // Test the updated shortcut.
+        commands = await browser.commands.getAll();
+        browser.test.assertEq(1, commands.length, "There is still 1 command");
+        command = commands[0];
+        browser.test.assertEq("foo", command.name, "The name is unchanged");
+        browser.test.assertEq("The only command", command.description, "The description is updated");
+        browser.test.assertEq("Ctrl+Shift+L", command.shortcut, "The shortcut is unchanged");
+
+        // Update the description and shortcut.
+        await browser.commands.update({
+          name: "foo",
+          description: "The new command",
+          shortcut: "   Alt+  Shift +9",
+        });
+
+        // Test the updated shortcut.
+        commands = await browser.commands.getAll();
+        browser.test.assertEq(1, commands.length, "There is still 1 command");
+        command = commands[0];
+        browser.test.assertEq("foo", command.name, "The name is unchanged");
+        browser.test.assertEq("The new command", command.description, "The description is updated");
+        browser.test.assertEq("Alt+Shift+9", command.shortcut, "The shortcut is updated");
+
+        // Test a bad shortcut update.
+        browser.test.assertThrows(
+          () => browser.commands.update({name: "foo", shortcut: "Ctl+Shift+L"}),
+          /Type error for parameter detail/,
+          "It rejects for a bad shortcut");
+
+        // Try to update a command that doesn't exist.
+        await browser.test.assertRejects(
+          browser.commands.update({name: "bar", shortcut: "Ctrl+Shift+L"}),
+          'Unknown command "bar"',
+          "It rejects for an unknown command");
+
+        browser.test.notifyPass("commands");
+      });
+      browser.test.sendMessage("ready");
+    },
+  });
+
+  await extension.startup();
+
+  function extensionKeyset(extensionId) {
+    return document.getElementById(makeWidgetId(`ext-keyset-id-${extensionId}`));
+  }
+
+  function checkKey(extensionId, shortcutKey, modifiers) {
+    let keyset = extensionKeyset(extensionId);
+    is(keyset.children.length, 1, "There is 1 key in the keyset");
+    let key = keyset.children[0];
+    is(key.getAttribute("key"), shortcutKey, "The key is correct");
+    is(key.getAttribute("modifiers"), modifiers, "The modifiers are correct");
+  }
+
+  function checkNumericKey(extensionId, key, modifiers) {
+    let keyset = extensionKeyset(extensionId);
+    is(keyset.children.length, 2, "There are 2 keys in the keyset now, 1 of which contains a keycode.");
+    let numpadKey = keyset.children[0];
+    is(numpadKey.getAttribute("keycode"), `VK_NUMPAD${key}`, "The numpad keycode is correct.");
+    is(numpadKey.getAttribute("modifiers"), modifiers, "The modifiers are correct");
+
+    let originalNumericKey = keyset.children[1];
+    is(originalNumericKey.getAttribute("keycode"), `VK_${key}`, "The original key is correct.");
+    is(originalNumericKey.getAttribute("modifiers"), modifiers, "The modifiers are correct");
+  }
+
+  // Check that the <key> is set for the original shortcut.
+  checkKey(extension.id, "I", "accel,shift");
+
+  await extension.awaitMessage("ready");
+  extension.sendMessage("run");
+  await extension.awaitFinish("commands");
+
+  // Check that the <keycode> has been updated.
+  checkNumericKey(extension.id, "9", "alt,shift");
+
+  // Check that the updated command is stored in ExtensionSettingsStore.
+  let storedCommands = ExtensionSettingsStore.getAllForExtension(
+    extension.id, "commands");
+  is(storedCommands.length, 1, "There is only one stored command");
+  let command = ExtensionSettingsStore.getSetting("commands", "foo", extension.id).value;
+  is(command.description, "The new command", "The description is stored");
+  is(command.shortcut, "Alt+Shift+9", "The shortcut is stored");
+
+  // Check that the key is updated immediately.
+  extension.sendMessage("update", {name: "foo", shortcut: "Ctrl+Shift+M"});
+  await extension.awaitMessage("updateDone");
+  checkKey(extension.id, "M", "accel,shift");
+
+  // Ensure all successive updates are stored.
+  // Force the command to only have a description saved.
+  await ExtensionSettingsStore.addSetting(
+    extension.id, "commands", "foo", {description: "description only"});
+  // This command now only has a description set in storage, also update the shortcut.
+  extension.sendMessage("update", {name: "foo", shortcut: "Alt+Shift+9"});
+  await extension.awaitMessage("updateDone");
+  let storedCommand = await ExtensionSettingsStore.getSetting(
+    "commands", "foo", extension.id);
+  is(storedCommand.value.shortcut, "Alt+Shift+9", "The shortcut is saved correctly");
+  is(storedCommand.value.description, "description only", "The description is saved correctly");
+
+  // Calling browser.commands.reset("foo") should reset to manifest version.
+  extension.sendMessage("reset", "foo");
+  await extension.awaitMessage("resetDone");
+
+  checkKey(extension.id, "I", "accel,shift");
+
+  // Check that enable/disable removes the keyset and reloads the saved command.
+  let addon = await AddonManager.getAddonByID(extension.id);
+  await disableAddon(addon);
+  let keyset = extensionKeyset(extension.id);
+  is(keyset, null, "The extension keyset is removed when disabled");
+  // Add some commands to storage, only "foo" should get loaded.
+  await ExtensionSettingsStore.addSetting(
+    extension.id, "commands", "foo", {shortcut: "Alt+Shift+9"});
+  await ExtensionSettingsStore.addSetting(
+    extension.id, "commands", "unknown", {shortcut: "Ctrl+Shift+P"});
+  storedCommands = ExtensionSettingsStore.getAllForExtension(extension.id, "commands");
+  is(storedCommands.length, 2, "There are now 2 commands stored");
+  await enableAddon(addon);
+  // Wait for the keyset to appear (it's async on enable).
+  await TestUtils.waitForCondition(() => extensionKeyset(extension.id));
+  // The keyset is back with the value from ExtensionSettingsStore.
+  checkNumericKey(extension.id, "9", "alt,shift");
+
+  // Check that an update to a shortcut in the manifest is mapped correctly.
+  updatedExtension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      version: "1.0",
+      applications: {gecko: {id: "commands@mochi.test"}},
+      commands: {
+        foo: {
+          suggested_key: {
+            default: "Ctrl+Shift+L",
+          },
+          description: "The foo command",
+        },
+      },
+    },
+  });
+  await updatedExtension.startup();
+
+  await TestUtils.waitForCondition(() => extensionKeyset(extension.id));
+  // Shortcut is unchanged since it was previously updated.
+  checkNumericKey(extension.id, "9", "alt,shift");
+});
--- a/mail/components/extensions/test/browser/head.js
+++ b/mail/components/extensions/test/browser/head.js
@@ -1,15 +1,25 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 var {MailServices} = ChromeUtils.import("resource:///modules/MailServices.jsm");
 
+// There are shutdown issues for which multiple rejections are left uncaught.
+// This bug should be fixed, but for the moment this directory is whitelisted.
+//
+// NOTE: Entire directory whitelisting should be kept to a minimum. Normally you
+//       should use "expectUncaughtRejection" to flag individual failures.
+const {PromiseTestUtils} = ChromeUtils.import("resource://testing-common/PromiseTestUtils.jsm");
+PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
+PromiseTestUtils.whitelistRejectionsGlobally(/No matching message handler/);
+PromiseTestUtils.whitelistRejectionsGlobally(/Receiving end does not exist/);
+
 function createAccount() {
   registerCleanupFunction(() => {
     [...MailServices.accounts.accounts.enumerate()].forEach(cleanUpAccount);
   });
 
   MailServices.accounts.createLocalMailAccount();
   let account = MailServices.accounts.accounts.enumerate().getNext();
   info(`Created account ${account.toString()}`);
@@ -41,8 +51,98 @@ function createMessages(folder, count) {
   folder.addMessageBatch(messageStrings.length, messageStrings);
 }
 
 async function promiseAnimationFrame(win = window) {
   await new Promise(win.requestAnimationFrame);
   // dispatchToMainThread throws if used as the first argument of Promise.
   return new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
 }
+
+function makeWidgetId(id) {
+  id = id.toLowerCase();
+  return id.replace(/[^a-z0-9_-]/g, "_");
+}
+
+async function focusWindow(win) {
+  if (Services.focus.activeWindow == win) {
+    return;
+  }
+
+  let promise = new Promise(resolve => {
+    win.addEventListener("focus", function() {
+      resolve();
+    }, {capture: true, once: true});
+  });
+
+  win.focus();
+  await promise;
+}
+
+function promisePopupShown(popup) {
+  return new Promise(resolve => {
+    if (popup.state == "open") {
+      resolve();
+    } else {
+      let onPopupShown = event => {
+        popup.removeEventListener("popupshown", onPopupShown);
+        resolve();
+      };
+      popup.addEventListener("popupshown", onPopupShown);
+    }
+  });
+}
+
+function getPanelForNode(node) {
+  while (node.localName != "panel") {
+    node = node.parentNode;
+  }
+  return node;
+}
+
+var awaitBrowserLoaded = browser => ContentTask.spawn(browser, null, () => {
+  if (content.document.readyState !== "complete" ||
+      content.document.documentURI === "about:blank") {
+    return ContentTaskUtils.waitForEvent(this, "load", true, event => {
+      return content.document.documentURI !== "about:blank";
+    }).then(() => {});
+  }
+  return Promise.resolve();
+});
+
+var awaitExtensionPanel = async function(extension, win = window, awaitLoad = true) {
+  let {originalTarget: browser} = await BrowserTestUtils.waitForEvent(
+    win.document, "WebExtPopupLoaded", true,
+    event => event.detail.extension.id === extension.id);
+
+  await Promise.all([
+    promisePopupShown(getPanelForNode(browser)),
+    awaitLoad && awaitBrowserLoaded(browser),
+  ]);
+
+  return browser;
+};
+
+function getBrowserActionPopup(extension, win = window) {
+  return window.document.getElementById(makeWidgetId(extension.id) + "-panel");
+}
+
+function closeBrowserAction(extension, win = window) {
+  let popup = getBrowserActionPopup(extension, win);
+  let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+  popup.hidePopup();
+
+  return hidden;
+}
+
+async function openNewMailWindow(options = {}) {
+  if (!options.newAccountWizard) {
+    Services.prefs.setBoolPref("mail.provider.suppress_dialog_on_startup", true);
+  }
+
+  let win = window.openDialog("chrome://messenger/content/", "_blank", "chrome,all,dialog=no");
+  await Promise.all([
+    BrowserTestUtils.waitForEvent(win, "focus", true),
+    BrowserTestUtils.waitForEvent(win, "activate", true),
+  ]);
+
+  return win;
+}