Bug 1267810 - Use the new module to implement the omnibox api. r=aswan,robwu
☠☠ backed out by 0b58909e7626 ☠ ☠
authorMatthew Wein <mwein@mozilla.com>
Fri, 11 Nov 2016 02:06:43 +0000
changeset 352059 7ef35cccfd7b37a69354f08bfa4d118e4fcc4dd3
parent 352058 9cfc41a2869ee9a880aaae5d22a34e2daa7b64b6
child 352060 daaf3d17df3229983b8b825453ce0f3fe5ad008a
push id6795
push userjlund@mozilla.com
push dateMon, 23 Jan 2017 14:19:46 +0000
treeherdermozilla-esr52@76101b503191 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, robwu
bugs1267810
milestone52.0a1
Bug 1267810 - Use the new module to implement the omnibox api. r=aswan,robwu MozReview-Commit-ID: GYzueGSijyd
browser/components/extensions/ext-c-omnibox.js
browser/components/extensions/ext-omnibox.js
browser/components/extensions/extensions-browser.manifest
browser/components/extensions/jar.mn
browser/components/extensions/schemas/jar.mn
browser/components/extensions/schemas/omnibox.json
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_omnibox.js
browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js
browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js
browser/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-c-omnibox.js
@@ -0,0 +1,32 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+var {
+  runSafeSyncWithoutClone,
+  SingletonEventManager,
+} = ExtensionUtils;
+
+extensions.registerSchemaAPI("omnibox", "addon_child", context => {
+  return {
+    omnibox: {
+      onInputChanged: new SingletonEventManager(context, "omnibox.onInputChanged", fire => {
+        let listener = (text, id) => {
+          runSafeSyncWithoutClone(fire, text, suggestions => {
+            // TODO: Switch to using callParentFunctionNoReturn once bug 1314903 is fixed.
+            context.childManager.callParentAsyncFunction("omnibox_internal.addSuggestions", [
+              id,
+              suggestions,
+            ]);
+          });
+        };
+        context.childManager.getParentEvent("omnibox_internal.onInputChanged").addListener(listener);
+        return () => {
+          context.childManager.getParentEvent("omnibox_internal.onInputChanged").removeListener(listener);
+        };
+      }).api(),
+    },
+  };
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-omnibox.js
@@ -0,0 +1,104 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSearchHandler",
+                                  "resource://gre/modules/ExtensionSearchHandler.jsm");
+var {
+  SingletonEventManager,
+} = ExtensionUtils;
+
+// WeakMap[extension -> keyword]
+let gKeywordMap = new WeakMap();
+
+/* eslint-disable mozilla/balanced-listeners */
+extensions.on("manifest_omnibox", (type, directive, extension, manifest) => {
+  let keyword = manifest.omnibox.keyword;
+  try {
+    // This will throw if the keyword is already registered.
+    ExtensionSearchHandler.registerKeyword(keyword, extension);
+    gKeywordMap.set(extension, keyword);
+  } catch (e) {
+    extension.manifestError(e.message);
+  }
+});
+
+extensions.on("shutdown", (type, extension) => {
+  let keyword = gKeywordMap.get(extension);
+  if (keyword) {
+    ExtensionSearchHandler.unregisterKeyword(keyword);
+    gKeywordMap.delete(extension);
+  }
+});
+/* eslint-enable mozilla/balanced-listeners */
+
+extensions.registerSchemaAPI("omnibox", "addon_parent", context => {
+  let {extension} = context;
+  return {
+    omnibox: {
+      setDefaultSuggestion(suggestion) {
+        let keyword = gKeywordMap.get(extension);
+        try {
+          // This will throw if the keyword failed to register.
+          ExtensionSearchHandler.setDefaultSuggestion(keyword, suggestion);
+        } catch (e) {
+          return Promise.reject(e.message);
+        }
+      },
+
+      onInputStarted: new SingletonEventManager(context, "omnibox.onInputStarted", fire => {
+        let listener = (eventName) => {
+          fire();
+        };
+        extension.on(ExtensionSearchHandler.MSG_INPUT_STARTED, listener);
+        return () => {
+          extension.off(ExtensionSearchHandler.MSG_INPUT_STARTED, listener);
+        };
+      }).api(),
+
+      onInputCancelled: new SingletonEventManager(context, "omnibox.onInputCancelled", fire => {
+        let listener = (eventName) => {
+          fire();
+        };
+        extension.on(ExtensionSearchHandler.MSG_INPUT_CANCELLED, listener);
+        return () => {
+          extension.off(ExtensionSearchHandler.MSG_INPUT_CANCELLED, listener);
+        };
+      }).api(),
+
+      onInputEntered: new SingletonEventManager(context, "omnibox.onInputEntered", fire => {
+        let listener = (eventName, text, disposition) => {
+          fire(text, disposition);
+        };
+        extension.on(ExtensionSearchHandler.MSG_INPUT_ENTERED, listener);
+        return () => {
+          extension.off(ExtensionSearchHandler.MSG_INPUT_ENTERED, listener);
+        };
+      }).api(),
+    },
+
+    omnibox_internal: {
+      addSuggestions(id, suggestions) {
+        let keyword = gKeywordMap.get(extension);
+        try {
+          ExtensionSearchHandler.addSuggestions(keyword, id, suggestions);
+        } catch (e) {
+          // Silently fail because the extension developer can not know for sure if the user
+          // has already invalidated the callback when asynchronously providing suggestions.
+        }
+      },
+
+      onInputChanged: new SingletonEventManager(context, "omnibox_internal.onInputChanged", fire => {
+        let listener = (eventName, text, id) => {
+          fire(text, id);
+        };
+        extension.on(ExtensionSearchHandler.MSG_INPUT_CHANGED, listener);
+        return () => {
+          extension.off(ExtensionSearchHandler.MSG_INPUT_CHANGED, listener);
+        };
+      }).api(),
+    },
+  };
+});
--- a/browser/components/extensions/extensions-browser.manifest
+++ b/browser/components/extensions/extensions-browser.manifest
@@ -1,28 +1,31 @@
 # scripts
 category webextension-scripts bookmarks chrome://browser/content/ext-bookmarks.js
 category webextension-scripts browserAction chrome://browser/content/ext-browserAction.js
 category webextension-scripts commands chrome://browser/content/ext-commands.js
 category webextension-scripts contextMenus chrome://browser/content/ext-contextMenus.js
 category webextension-scripts desktop-runtime chrome://browser/content/ext-desktop-runtime.js
 category webextension-scripts history chrome://browser/content/ext-history.js
+category webextension-scripts omnibox chrome://browser/content/ext-omnibox.js
 category webextension-scripts pageAction chrome://browser/content/ext-pageAction.js
 category webextension-scripts sessions chrome://browser/content/ext-sessions.js
 category webextension-scripts tabs chrome://browser/content/ext-tabs.js
 category webextension-scripts utils chrome://browser/content/ext-utils.js
 category webextension-scripts windows chrome://browser/content/ext-windows.js
 
 # scripts that must run in the same process as addon code.
 category webextension-scripts-addon contextMenus chrome://browser/content/ext-c-contextMenus.js
+category webextension-scripts-addon omnibox chrome://browser/content/ext-c-omnibox.js
 category webextension-scripts-addon tabs chrome://browser/content/ext-c-tabs.js
 
 # schemas
 category webextension-schemas bookmarks chrome://browser/content/schemas/bookmarks.json
 category webextension-schemas browser_action chrome://browser/content/schemas/browser_action.json
 category webextension-schemas commands chrome://browser/content/schemas/commands.json
 category webextension-schemas context_menus chrome://browser/content/schemas/context_menus.json
 category webextension-schemas context_menus_internal chrome://browser/content/schemas/context_menus_internal.json
 category webextension-schemas history chrome://browser/content/schemas/history.json
+category webextension-schemas omnibox chrome://browser/content/schemas/omnibox.json
 category webextension-schemas page_action chrome://browser/content/schemas/page_action.json
 category webextension-schemas sessions chrome://browser/content/schemas/sessions.json
 category webextension-schemas tabs chrome://browser/content/schemas/tabs.json
 category webextension-schemas windows chrome://browser/content/schemas/windows.json
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -13,15 +13,17 @@ browser.jar:
 #endif
     content/browser/extension.svg
     content/browser/ext-bookmarks.js
     content/browser/ext-browserAction.js
     content/browser/ext-commands.js
     content/browser/ext-contextMenus.js
     content/browser/ext-desktop-runtime.js
     content/browser/ext-history.js
+    content/browser/ext-omnibox.js
     content/browser/ext-pageAction.js
     content/browser/ext-sessions.js
     content/browser/ext-tabs.js
     content/browser/ext-utils.js
     content/browser/ext-windows.js
     content/browser/ext-c-contextMenus.js
+    content/browser/ext-c-omnibox.js
     content/browser/ext-c-tabs.js
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -4,12 +4,13 @@
 
 browser.jar:
     content/browser/schemas/bookmarks.json
     content/browser/schemas/browser_action.json
     content/browser/schemas/commands.json
     content/browser/schemas/context_menus.json
     content/browser/schemas/context_menus_internal.json
     content/browser/schemas/history.json
+    content/browser/schemas/omnibox.json
     content/browser/schemas/page_action.json
     content/browser/schemas/sessions.json
     content/browser/schemas/tabs.json
     content/browser/schemas/windows.json
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/omnibox.json
@@ -0,0 +1,248 @@
+// 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": [
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "omnibox": {
+            "type": "object",
+            "additionalProperties": { "$ref": "UnrecognizedProperty" },
+            "properties": {
+              "keyword": {
+                "type": "string",
+                "pattern": "^[^?\\s:]([^\\s:]*[^/\\s:])?$"
+              }
+            },
+            "optional": true
+          }
+        }
+      }
+    ]
+  },
+  {
+    "namespace": "omnibox",
+    "description": "The omnibox API allows you to register a keyword with Firefox's address bar.",
+    "permissions": ["manifest:omnibox"],
+    "types": [
+      {
+        "id": "DescriptionStyleType",
+        "type": "string",
+        "description": "The style type.",
+        "enum": ["url", "match", "dim"]
+      },
+      {
+        "id": "OnInputEnteredDisposition",
+        "type": "string",
+        "enum": ["currentTab", "newForegroundTab", "newBackgroundTab"],
+        "description": "The window disposition for the omnibox query. This is the recommended context to display results. For example, if the omnibox command is to navigate to a certain URL, a disposition of 'newForegroundTab' means the navigation should take place in a new selected tab."
+      },
+      {
+        "id": "SuggestResult",
+        "type": "object",
+        "description": "A suggest result.",
+        "properties": {
+          "content": {
+            "type": "string",
+            "minLength": 1,
+            "description": "The text that is put into the URL bar, and that is sent to the extension when the user chooses this entry."
+          },
+          "description": {
+            "type": "string",
+            "minLength": 1,
+            "description": "The text that is displayed in the URL dropdown. Can contain XML-style markup for styling. The supported tags are 'url' (for a literal URL), 'match' (for highlighting text that matched what the user's query), and 'dim' (for dim helper text). The styles can be nested, eg. <dim><match>dimmed match</match></dim>. You must escape the five predefined entities to display them as text: stackoverflow.com/a/1091953/89484 "
+          },
+          "descriptionStyles": {
+            "optional": true,
+            "unsupported": true,
+            "type": "array",
+            "description": "An array of style ranges for the description, as provided by the extension.",
+            "items": {
+              "type": "object",
+              "description": "The style ranges for the description, as provided by the extension.",
+              "properties": {
+                "offset": { "type": "integer" },
+                "type": { "description": "The style type", "$ref": "DescriptionStyleType"},
+                "length": { "type": "integer", "optional": true }
+              }
+            }
+          },
+          "descriptionStylesRaw": {
+            "optional": true,
+            "unsupported": true,
+            "type": "array",
+            "description": "An array of style ranges for the description, as provided by ToValue().",
+            "items": {
+              "type": "object",
+              "description": "The style ranges for the description, as provided by ToValue().",
+              "properties": {
+                "offset": { "type": "integer" },
+                "type": { "type": "integer" }
+              }
+            }
+          }
+        }
+      },
+      {
+        "id": "DefaultSuggestResult",
+        "type": "object",
+        "description": "A suggest result.",
+        "properties": {
+          "description": {
+            "type": "string",
+            "minLength": 1,
+            "description": "The text that is displayed in the URL dropdown."
+          },
+          "descriptionStyles": {
+            "optional": true,
+            "unsupported": true,
+            "type": "array",
+            "description": "An array of style ranges for the description, as provided by the extension.",
+            "items": {
+              "type": "object",
+              "description": "The style ranges for the description, as provided by the extension.",
+              "properties": {
+                "offset": { "type": "integer" },
+                "type": { "description": "The style type", "$ref": "DescriptionStyleType"},
+                "length": { "type": "integer", "optional": true }
+              }
+            }
+          },
+          "descriptionStylesRaw": {
+            "optional": true,
+            "unsupported": true,
+            "type": "array",
+            "description": "An array of style ranges for the description, as provided by ToValue().",
+            "items": {
+              "type": "object",
+              "description": "The style ranges for the description, as provided by ToValue().",
+              "properties": {
+                "offset": { "type": "integer" },
+                "type": { "type": "integer" }
+              }
+            }
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "setDefaultSuggestion",
+        "type": "function",
+        "description": "Sets the description and styling for the default suggestion. The default suggestion is the text that is displayed in the first suggestion row underneath the URL bar.",
+        "parameters": [
+          {
+            "name": "suggestion",
+            "$ref": "DefaultSuggestResult",
+            "description": "A partial SuggestResult object, without the 'content' parameter."
+          }
+        ]
+      }
+    ],
+    "events": [
+      {
+        "name": "onInputStarted",
+        "type": "function",
+        "description": "User has started a keyword input session by typing the extension's keyword. This is guaranteed to be sent exactly once per input session, and before any onInputChanged events.",
+        "parameters": []
+      },
+      {
+        "name": "onInputChanged",
+        "type": "function",
+        "description": "User has changed what is typed into the omnibox.",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "text"
+          },
+          {
+            "name": "suggest",
+            "type": "function",
+            "description": "A callback passed to the onInputChanged event used for sending suggestions back to the browser.",
+            "parameters": [
+              {
+                "name": "suggestResults",
+                "type": "array",
+                "description": "Array of suggest results",
+                "items": {
+                  "$ref": "SuggestResult"
+                }
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "onInputEntered",
+        "type": "function",
+        "description": "User has accepted what is typed into the omnibox.",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "text"
+          },
+          {
+            "name": "disposition",
+            "$ref": "OnInputEnteredDisposition"
+          }
+        ]
+      },
+      {
+        "name": "onInputCancelled",
+        "type": "function",
+        "description": "User has ended the keyword input session without accepting the input.",
+        "parameters": []
+      }
+    ]
+  },
+  {
+    "namespace": "omnibox_internal",
+    "description": "The internal namespace used by the omnibox API.",
+    "defaultContexts": ["addon_parent_only"],
+    "functions": [
+      {
+        "name": "addSuggestions",
+        "type": "function",
+        "async": "callback",
+        "description": "Internal function used by omnibox.onInputChanged for adding search suggestions",
+        "parameters": [
+          {
+            "name": "id",
+            "type": "integer",
+            "description": "The ID of the callback received by onInputChangedInternal"
+          },
+          {
+            "name": "suggestResults",
+            "type": "array",
+            "description": "Array of suggest results",
+            "items": {
+              "$ref": "omnibox.SuggestResult"
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      }
+    ],
+    "events": [
+      {
+        "name": "onInputChanged",
+        "type": "function",
+        "description": "Identical to omnibox.onInputChanged except no 'suggest' callback is provided.",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "text"
+          }
+        ]
+      }
+    ]
+  }
+]
\ No newline at end of file
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -40,16 +40,17 @@ tags = webextensions
 [browser_ext_contextMenus_radioGroups.js]
 [browser_ext_contextMenus_uninstall.js]
 [browser_ext_contextMenus_urlPatterns.js]
 [browser_ext_currentWindow.js]
 [browser_ext_getViews.js]
 [browser_ext_incognito_popup.js]
 [browser_ext_lastError.js]
 [browser_ext_legacy_extension_context_contentscript.js]
+[browser_ext_omnibox.js]
 [browser_ext_optionsPage_privileges.js]
 [browser_ext_pageAction_context.js]
 [browser_ext_pageAction_popup.js]
 [browser_ext_pageAction_popup_resize.js]
 [browser_ext_pageAction_simple.js]
 [browser_ext_pageAction_title.js]
 [browser_ext_popup_api_injection.js]
 [browser_ext_popup_background.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_omnibox.js
@@ -0,0 +1,286 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function* setup() {
+  const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches";
+  Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, false);
+
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref(SUGGEST_URLBAR_PREF);
+  });
+}
+
+add_task(function* () {
+  let keyword = "test";
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "omnibox": {
+        "keyword": keyword,
+      },
+    },
+
+    background: function() {
+      browser.omnibox.onInputStarted.addListener(() => {
+        browser.test.sendMessage("on-input-started-fired");
+      });
+
+      let synchronous = true;
+      let suggestions = null;
+      let suggestCallback = null;
+
+      browser.omnibox.onInputChanged.addListener((text, suggest) => {
+        if (synchronous && suggestions) {
+          suggest(suggestions);
+        } else {
+          suggestCallback = suggest;
+        }
+        browser.test.sendMessage("on-input-changed-fired", {text});
+      });
+
+      browser.omnibox.onInputCancelled.addListener(() => {
+        browser.test.sendMessage("on-input-cancelled-fired");
+      });
+
+      browser.omnibox.onInputEntered.addListener((text, disposition) => {
+        browser.test.sendMessage("on-input-entered-fired", {text, disposition});
+      });
+
+      browser.test.onMessage.addListener((msg, data) => {
+        switch (msg) {
+          case "set-suggestions":
+            suggestions = data.suggestions;
+            browser.test.sendMessage("suggestions-set");
+            break;
+          case "set-default-suggestion":
+            browser.omnibox.setDefaultSuggestion(data.suggestion);
+            browser.test.sendMessage("default-suggestion-set");
+            break;
+          case "set-synchronous":
+            synchronous = data.synchronous;
+            break;
+          case "test-multiple-suggest-calls":
+            suggestions.forEach(suggestion => suggestCallback([suggestion]));
+            browser.test.sendMessage("test-ready");
+            break;
+          case "test-suggestions-after-delay":
+            Promise.resolve().then(() => {
+              suggestCallback(suggestions);
+              browser.test.sendMessage("test-ready");
+            });
+            break;
+        }
+      });
+    },
+  });
+
+  function* expectEvent(event, expected = {}) {
+    let actual = yield extension.awaitMessage(event);
+    if (expected.text) {
+      is(actual.text, expected.text,
+        `Expected "${event}" to have fired with text: "${expected.text}".`);
+    }
+    if (expected.disposition) {
+      is(actual.disposition, expected.disposition,
+        `Expected "${event}" to have fired with disposition: "${expected.disposition}".`);
+    }
+  }
+
+  function* startInputSession() {
+    gURLBar.focus();
+    gURLBar.value = keyword;
+    EventUtils.synthesizeKey(" ", {});
+    yield expectEvent("on-input-started-fired");
+    EventUtils.synthesizeKey("t", {});
+    yield expectEvent("on-input-changed-fired", {text: "t"});
+    return "t";
+  }
+
+  function* testInputEvents() {
+    gURLBar.focus();
+
+    // Start an input session by typing in <keyword><space>.
+    for (let letter of keyword) {
+      EventUtils.synthesizeKey(letter, {});
+    }
+    EventUtils.synthesizeKey(" ", {});
+    yield expectEvent("on-input-started-fired");
+
+    // We should expect input changed events now that the keyword is active.
+    EventUtils.synthesizeKey("b", {});
+    yield expectEvent("on-input-changed-fired", {text: "b"});
+
+    EventUtils.synthesizeKey("c", {});
+    yield expectEvent("on-input-changed-fired", {text: "bc"});
+
+    EventUtils.synthesizeKey("VK_BACK_SPACE", {});
+    yield expectEvent("on-input-changed-fired", {text: "b"});
+
+    // Even though the input is <keyword><space> We should not expect an
+    // input started event to fire since the keyword is active.
+    EventUtils.synthesizeKey("VK_BACK_SPACE", {});
+    yield expectEvent("on-input-changed-fired", {text: ""});
+
+    // Make the keyword inactive by hitting backspace.
+    EventUtils.synthesizeKey("VK_BACK_SPACE", {});
+    yield expectEvent("on-input-cancelled-fired");
+
+    // Activate the keyword by typing a space.
+    // Expect onInputStarted to fire.
+    EventUtils.synthesizeKey(" ", {});
+    yield expectEvent("on-input-started-fired");
+
+    // onInputChanged should fire even if a space is entered.
+    EventUtils.synthesizeKey(" ", {});
+    yield expectEvent("on-input-changed-fired", {text: " "});
+
+    // The active session should cancel if the input blurs.
+    gURLBar.blur();
+    yield expectEvent("on-input-cancelled-fired");
+  }
+
+  function* testHeuristicResult(expectedText, setDefaultSuggestion) {
+    if (setDefaultSuggestion) {
+      extension.sendMessage("set-default-suggestion", {
+        suggestion: {
+          description: expectedText,
+        },
+      });
+      yield extension.awaitMessage("default-suggestion-set");
+    }
+
+    let text = yield startInputSession();
+
+    let item = gURLBar.popup.richlistbox.children[0];
+
+    is(item.getAttribute("title"), expectedText,
+      `Expected heuristic result to have title: "${expectedText}".`);
+
+    is(item.getAttribute("displayurl"), `${keyword} ${text}`,
+      `Expected heuristic result to have displayurl: "${keyword} ${text}".`);
+
+    EventUtils.synthesizeMouseAtCenter(item, {});
+
+    yield expectEvent("on-input-entered-fired", {
+      text,
+      disposition: "currentTab",
+    });
+  }
+
+  function* testDisposition(suggestionIndex, expectedDisposition, expectedText) {
+    yield startInputSession();
+
+    // Select the suggestion.
+    for (let i = 0; i < suggestionIndex; i++) {
+      EventUtils.synthesizeKey("VK_DOWN", {});
+    }
+
+    let item = gURLBar.popup.richlistbox.children[suggestionIndex];
+    if (expectedDisposition == "currentTab") {
+      EventUtils.synthesizeMouseAtCenter(item, {});
+    } else if (expectedDisposition == "newForegroundTab") {
+      EventUtils.synthesizeMouseAtCenter(item, {accelKey: true});
+    } else if (expectedDisposition == "newBackgroundTab") {
+      EventUtils.synthesizeMouseAtCenter(item, {shiftKey: true, accelKey: true});
+    }
+
+    yield expectEvent("on-input-entered-fired", {
+      text: expectedText,
+      disposition: expectedDisposition,
+    });
+  }
+
+  function* testSuggestions(info) {
+    extension.sendMessage("set-synchronous", {synchronous: false});
+
+    function expectSuggestion({content, description}, index) {
+      let item = gURLBar.popup.richlistbox.children[index + 1]; // Skip the heuristic result.
+
+      ok(!!item, "Expected item to exist");
+      is(item.getAttribute("title"), description,
+        `Expected suggestion to have title: "${description}".`);
+
+      is(item.getAttribute("displayurl"), `${keyword} ${content}`,
+        `Expected suggestion to have displayurl: "${keyword} ${content}".`);
+    }
+
+    let text = yield startInputSession();
+
+    extension.sendMessage(info.test);
+    yield extension.awaitMessage("test-ready");
+
+    info.suggestions.forEach(expectSuggestion);
+
+    EventUtils.synthesizeMouseAtCenter(gURLBar.popup.richlistbox.children[0], {});
+    yield expectEvent("on-input-entered-fired", {
+      text,
+      disposition: "currentTab",
+    });
+  }
+
+  yield setup();
+  yield extension.startup();
+
+  yield testInputEvents();
+
+  // Test the heuristic result with default suggestions.
+  yield testHeuristicResult("Generated extension", false /* setDefaultSuggestion */);
+  yield testHeuristicResult("hello world", true /* setDefaultSuggestion */);
+  yield testHeuristicResult("foo bar", true /* setDefaultSuggestion */);
+
+  let suggestions = [
+    {content: "a", description: "select a"},
+    {content: "b", description: "select b"},
+    {content: "c", description: "select c"},
+  ];
+
+  extension.sendMessage("set-suggestions", {suggestions});
+  yield extension.awaitMessage("suggestions-set");
+
+  // Test each suggestion and search disposition.
+  yield testDisposition(1, "currentTab", suggestions[0].content);
+  yield testDisposition(2, "newForegroundTab", suggestions[1].content);
+  yield testDisposition(3, "newBackgroundTab", suggestions[2].content);
+
+  extension.sendMessage("set-suggestions", {suggestions});
+  yield extension.awaitMessage("suggestions-set");
+
+  // Test adding suggestions asynchronously.
+  yield testSuggestions({
+    test: "test-multiple-suggest-calls",
+    skipHeuristic: true,
+    suggestions,
+  });
+  yield testSuggestions({
+    test: "test-suggestions-after-delay",
+    skipHeuristic: true,
+    suggestions,
+  });
+
+  // Start monitoring the console.
+  SimpleTest.waitForExplicitFinish();
+  let waitForConsole = new Promise(resolve => {
+    SimpleTest.monitorConsole(resolve, [{
+      message: new RegExp(`The keyword provided is already registered: "${keyword}"`),
+    }]);
+  });
+
+  // Try registering another extension with the same keyword
+  let extension2 = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "omnibox": {
+        "keyword": keyword,
+      },
+    },
+  });
+
+  yield extension2.startup();
+
+  // Stop monitoring the console and confirm the correct errors are logged.
+  SimpleTest.endMonitorConsole();
+  yield waitForConsole;
+
+  yield extension2.unload();
+  yield extension.unload();
+});
--- a/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js
+++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js
@@ -5,16 +5,27 @@
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
                                   "resource://testing-common/PlacesTestUtils.jsm");
 
 const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches";
 const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
 
+function* promiseAutocompleteResultPopup(inputText) {
+  gURLBar.focus();
+  gURLBar.value = inputText;
+  gURLBar.controller.startSearch(inputText);
+  yield promisePopupShown(gURLBar.popup);
+  yield BrowserTestUtils.waitForCondition(() => {
+    return gURLBar.controller.searchStatus >=
+      Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH;
+  });
+}
+
 function* addBookmark(bookmark) {
   if (bookmark.keyword) {
     yield PlacesUtils.keywords.insert({
       keyword: bookmark.keyword,
       url: bookmark.url,
     });
   }
 
@@ -137,27 +148,19 @@ add_task(function* test_webnavigation_ur
     title: "Bookmark To Click",
     url: "http://example.com/?q=bookmark",
   });
 
   yield extension.startup();
 
   yield extension.awaitMessage("ready");
 
-  gURLBar.focus();
-  gURLBar.value = "Bookmark To Click";
-  gURLBar.controller.startSearch("Bookmark To Click");
-
-  let item;
+  yield promiseAutocompleteResultPopup("Bookmark To Click");
 
-  yield BrowserTestUtils.waitForCondition(() => {
-    item = gURLBar.popup.richlistbox.getItemAtIndex(1);
-    return item;
-  });
-
+  let item = gURLBar.popup.richlistbox.getItemAtIndex(1);
   item.click();
   yield extension.awaitFinish("webNavigation.from_address_bar.auto_bookmark");
 
   yield extension.unload();
   info("extension unloaded");
 });
 
 add_task(function* test_webnavigation_urlbar_keyword_transition() {
@@ -190,23 +193,17 @@ add_task(function* test_webnavigation_ur
     url: "http://example.com/?q=%s",
     keyword: "testkw",
   });
 
   yield extension.startup();
 
   yield extension.awaitMessage("ready");
 
-  gURLBar.focus();
-  gURLBar.value = "testkw search";
-  gURLBar.controller.startSearch("testkw search");
-
-  yield BrowserTestUtils.waitForCondition(() => {
-    return gURLBar.popup.input.controller.matchCount;
-  });
+  yield promiseAutocompleteResultPopup("testkw search");
 
   let item = gURLBar.popup.richlistbox.getItemAtIndex(0);
   item.click();
 
   yield extension.awaitFinish("webNavigation.from_address_bar.keyword");
 
   yield extension.unload();
   info("extension unloaded");
@@ -237,24 +234,17 @@ add_task(function* test_webnavigation_ur
     },
   });
 
   yield extension.startup();
 
   yield extension.awaitMessage("ready");
 
   yield prepareSearchEngine();
-
-  gURLBar.focus();
-  gURLBar.value = "foo";
-  gURLBar.controller.startSearch("foo");
-
-  yield BrowserTestUtils.waitForCondition(() => {
-    return gURLBar.popup.input.controller.matchCount;
-  });
+  yield promiseAutocompleteResultPopup("foo");
 
   let item = gURLBar.popup.richlistbox.getItemAtIndex(0);
   item.click();
 
   yield extension.awaitFinish("webNavigation.from_address_bar.generated");
 
   yield extension.unload();
   info("extension unloaded");
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js
@@ -0,0 +1,61 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function* testKeyword(params) {
+  let normalized = yield ExtensionTestUtils.normalizeManifest({
+    "omnibox": {
+      "keyword": params.keyword,
+    },
+  });
+
+  if (params.expectError) {
+    let expectedError = (
+      String.raw`omnibox.keyword: String "${params.keyword}" ` +
+      String.raw`must match /^[^?\s:]([^\s:]*[^/\s:])?$/`
+    );
+    ok(normalized.error.includes(expectedError),
+       `The manifest error ${JSON.stringify(normalized.error)} ` +
+       `must contain ${JSON.stringify(expectedError)}`);
+  } else {
+    equal(normalized.error, undefined, "Should not have an error");
+    equal(normalized.errors.length, 0, "Should not have warnings");
+  }
+}
+
+add_task(function* test_manifest_commands() {
+  // accepted single character keywords
+  yield testKeyword({keyword: "a", expectError: false});
+  yield testKeyword({keyword: "-", expectError: false});
+  yield testKeyword({keyword: "嗨", expectError: false});
+  yield testKeyword({keyword: "*", expectError: false});
+  yield testKeyword({keyword: "/", expectError: false});
+
+  // rejected single character keywords
+  yield testKeyword({keyword: "?", expectError: true});
+  yield testKeyword({keyword: " ", expectError: true});
+  yield testKeyword({keyword: ":", expectError: true});
+
+  // accepted multi-character keywords
+  yield testKeyword({keyword: "aa", expectError: false});
+  yield testKeyword({keyword: "http", expectError: false});
+  yield testKeyword({keyword: "f?a", expectError: false});
+  yield testKeyword({keyword: "fa?", expectError: false});
+  yield testKeyword({keyword: "f/x", expectError: false});
+  yield testKeyword({keyword: "/fx", expectError: false});
+
+  // rejected multi-character keywords
+  yield testKeyword({keyword: " a", expectError: true});
+  yield testKeyword({keyword: "a ", expectError: true});
+  yield testKeyword({keyword: "  ", expectError: true});
+  yield testKeyword({keyword: " a ", expectError: true});
+  yield testKeyword({keyword: "?fx", expectError: true});
+  yield testKeyword({keyword: "fx/", expectError: true});
+  yield testKeyword({keyword: "f:x", expectError: true});
+  yield testKeyword({keyword: "fx:", expectError: true});
+  yield testKeyword({keyword: "f x", expectError: true});
+
+  // miscellaneous tests
+  yield testKeyword({keyword: "こんにちは", expectError: false});
+  yield testKeyword({keyword: "http://", expectError: true});
+});
--- a/browser/components/extensions/test/xpcshell/xpcshell.ini
+++ b/browser/components/extensions/test/xpcshell/xpcshell.ini
@@ -2,9 +2,10 @@
 head = head.js
 tail =
 firefox-appdir = browser
 tags = webextensions
 
 [test_ext_bookmarks.js]
 [test_ext_history.js]
 [test_ext_manifest_commands.js]
+[test_ext_manifest_omnibox.js]
 [test_ext_manifest_permissions.js]